A complete, layered Spring Boot backend for a food-ordering platform: users register/login, browse restaurants and menus, place orders, and admins manage restaurants/menu items and order status.
| Concern | Technology |
|---|---|
| Language / runtime | Java 17 |
| Framework | Spring Boot 3.3.4 |
| Data access | Spring Data JPA + Hibernate |
| Database | PostgreSQL |
| Boilerplate reduction | Lombok |
| Security | Spring Security + JWT (stateless) |
| Validation | Jakarta Bean Validation (@Valid) |
| Error handling | @RestControllerAdvice global handler |
src/main/java/com/example/demo
├── config/ SecurityConfig, DataSeeder (seeds a default admin)
├── controller/ REST controllers (Auth, Restaurant, MenuItem, Order)
├── dto/
│ ├── request/ Input DTOs with bean-validation annotations
│ └── response/ Output DTOs, ApiResponse<T> envelope, ErrorResponse
├── entity/ JPA entities (User, Restaurant, MenuItem, Order, OrderItem) + enums
├── exception/ Custom exceptions + GlobalExceptionHandler
├── repository/ Spring Data JPA repositories
├── security/ JWT util, filter, UserDetails adapter, entry point / access-denied handler
└── service/ Service interfaces + impl/ (business logic, transactions)
- JDK 17+
- Maven 3.9+ (or use your IDE's bundled Maven)
- PostgreSQL running locally with a database named
demo
Create the database (credentials already match application.properties):
CREATE DATABASE demo;
CREATE USER "user" WITH PASSWORD 'gI7_36rpP_aQEj5FyJp9';
GRANT ALL PRIVILEGES ON DATABASE demo TO "user";If you already have a
user/database with those exact credentials, skip this step —spring.jpa.hibernate.ddl-auto=updatewill auto-create all tables on first run.
cd food-ordering-app
mvn spring-boot:runOr import the folder as a Maven project into IntelliJ IDEA / Eclipse / VS Code and run
DemoApplication.java directly.
The app starts on http://localhost:8080.
On first boot, DataSeeder automatically creates a default admin account (see
application.properties):
email: admin@foodapp.com
password: Admin@123
Use it to log in and manage restaurants/menu items right away.
POST /api/auth/register→ creates aCUSTOMERaccount, returns a JWT.POST /api/auth/login→ returns a JWT for any existing account (customer or admin).- Send the token on every protected request:
Authorization: Bearer <token>
Admin-only endpoints are protected with @PreAuthorize("hasRole('ADMIN')").
| Method | Endpoint | Body |
|---|---|---|
| POST | /api/auth/register |
{ name, email, password, phone, address } |
| POST | /api/auth/login |
{ email, password } |
| Method | Endpoint | Access |
|---|---|---|
| GET | /api/restaurants |
public |
| GET | /api/restaurants/{id} |
public |
| POST | /api/restaurants |
ADMIN |
| PUT | /api/restaurants/{id} |
ADMIN |
| DELETE | /api/restaurants/{id} |
ADMIN |
| Method | Endpoint | Access |
|---|---|---|
| GET | /api/restaurants/{restaurantId}/menu-items |
public |
| GET | /api/menu-items/{id} |
public |
| POST | /api/restaurants/{restaurantId}/menu-items |
ADMIN |
| PUT | /api/menu-items/{id} |
ADMIN |
| DELETE | /api/menu-items/{id} |
ADMIN |
| Method | Endpoint | Access |
|---|---|---|
| POST | /api/orders |
authenticated (places order) |
| GET | /api/orders/my |
authenticated (own orders) |
| GET | /api/orders/{id} |
owner or ADMIN |
| PATCH | /api/orders/{id}/cancel |
owner (only while PLACED) |
| GET | /api/orders |
ADMIN (all orders) |
| PATCH | /api/orders/{id}/status |
ADMIN (advance order status) |
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com","password":"secret123","phone":"9999999999","address":"221B Baker St"}'
# Login as admin
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@foodapp.com","password":"Admin@123"}'
# Create a restaurant (use the admin token from above)
curl -X POST http://localhost:8080/api/restaurants \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-d '{"name":"Pizza Palace","description":"Best pizza in town","address":"12 Main St","phone":"1234567890"}'
# Add a menu item to restaurant id=1
curl -X POST http://localhost:8080/api/restaurants/1/menu-items \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-d '{"name":"Margherita Pizza","description":"Classic cheese & tomato","price":9.99,"category":"Pizza"}'
# Place an order (as the customer, "jane" token)
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <CUSTOMER_TOKEN>" \
-d '{"restaurantId":1,"deliveryAddress":"221B Baker St","items":[{"menuItemId":1,"quantity":2}]}'Success — every endpoint returns a consistent envelope:
{
"success": true,
"message": "Order placed successfully",
"data": { "...": "..." },
"timestamp": "2026-07-27T10:00:00"
}Error — handled centrally by GlobalExceptionHandler:
{
"timestamp": "2026-07-27T10:00:00",
"status": 404,
"error": "Not Found",
"message": "Restaurant not found with id : '99'",
"path": "/api/restaurants/99"
}Validation errors additionally include a validationErrors map of field -> message.
- Custom exceptions:
ResourceNotFoundException(404),DuplicateResourceException(409),BadRequestException(400),UnauthorizedActionException(403) - Centralized
@RestControllerAdvicetranslating exceptions → consistent JSON - Bean Validation (
@Valid,@NotBlank,@Email,@DecimalMin, etc.) with field-level error maps - Spring Security exception translation via custom
AuthenticationEntryPoint(401) andAccessDeniedHandler(403), instead of Spring's default HTML/whitelabel pages DataIntegrityViolationExceptionhandling for DB constraint races (e.g. duplicate email)- Generic fallback handler so unexpected exceptions never leak stack traces to clients
- Move
jwt.secretand DB credentials to environment variables / a secrets manager before deploying — they're inapplication.propertieshere only for local dev convenience. - Add refresh tokens / token blacklisting if you need logout-before-expiry.
- Add pagination (
Pageable) to theGET /api/restaurants,/api/orderslist endpoints as data grows. - Add integration tests with Testcontainers (spin up a real Postgres for
mvn test) — the currentDemoApplicationTestsis a minimal context-load smoke test and expects the configured Postgres instance to be reachable.
This guide explains how to run the application using Docker with your existing local PostgreSQL.
- Docker Engine 20.10+
- Docker Compose V2 (
docker composecommand) - PostgreSQL running locally on port 5432
- Database
demoexists with the correct user/credentials
# Check if PostgreSQL is running
sudo systemctl status postgresql
# Start if needed
sudo systemctl start postgresql
# Create database if not exists
sudo -u postgres psql -c "CREATE DATABASE demo;"
sudo -u postgres psql -c "CREATE USER \"user\" WITH PASSWORD 'gI7_36rpP_aQEj5FyJp9';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE demo TO \"user\";"Important: The username
useris a PostgreSQL reserved word, so it must be double-quoted in SQL statements. Theapplication.propertiesfile handles this correctly viaspring.datasource.username=user.
make upOr directly:
docker compose up --buildThis will:
- Build the Spring Boot application (multi-stage Docker build with Maven)
- Run database migrations automatically (Hibernate
ddl-auto=update) - Seed the default admin user via
DataSeeder - Expose the app on
http://localhost:8080
make downOr directly:
docker compose downThe container uses host networking (network_mode: host) to directly access your local PostgreSQL at localhost:5432. This avoids the complexity of Docker networking between containers and keeps the database on the host machine.
The Dockerfile uses a multi-stage build:
| Stage | Base Image | Purpose |
|---|---|---|
build |
maven:3.9.9-amazoncorretto-17-alpine |
Compiles the application with Maven |
final |
amazoncorretto:17-alpine |
Runs the compiled JAR in a lightweight runtime |
This keeps the final image small (~200MB) by excluding Maven and build-time dependencies.
services:
app:
build: .
network_mode: host
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/demo
SPRING_DATASOURCE_USERNAME: user
SPRING_DATASOURCE_PASSWORD: gI7_36rpP_aQEj5FyJp9Environment variables override the values in application.properties, making the container portable across environments.
┌─────────────────────────────────────────────────┐
│ Host Machine │
│ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Browser │ │ PostgreSQL │ │
│ │ │ │ :5432 │ │
│ └──────┬──────┘ └──────▲───────┘ │
│ │ │ │
│ │ HTTP │ TCP │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────────────────────┐ │
│ │ Docker Container (Spring) │ │
│ │ network_mode: host │ │
│ │ :8080 │────────────┘
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
| Resource | URL |
|---|---|
| API Base URL | http://localhost:8080/api |
| Health Check | http://localhost:8080/actuator/health |
| Admin Login | POST /api/auth/login with admin@foodapp.com / Admin@123 |
The docker-compose.yml defines a single service:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
network_mode: host
environment:
- SPRING_PROFILES_ACTIVE=default
- SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/demo
- SPRING_DATASOURCE_USERNAME=user
- SPRING_DATASOURCE_PASSWORD=gI7_36rpP_aQEj5FyJp9
- JWT_SECRET=your-production-secret-key-change-me-32-chars-min
- JWT_EXPIRATION_MS=86400000| Variable | Description | Default (application.properties) |
|---|---|---|
SPRING_DATASOURCE_URL |
PostgreSQL JDBC URL | jdbc:postgresql://localhost:5432/demo |
SPRING_DATASOURCE_USERNAME |
DB username | user |
SPRING_DATASOURCE_PASSWORD |
DB password | gI7_36rpP_aQEj5FyJp9 |
JWT_SECRET |
HMAC-SHA256 signing key (min 32 chars) | (set in properties) |
JWT_EXPIRATION_MS |
Token lifetime in milliseconds | 86400000 (24h) |
make up # Build and start
make down # Stop and remove container
make build # Build Docker image only
make clean # Build without cache (fresh build)
docker compose logs -f app # Follow application logs
docker compose exec app sh # Shell into running containerThe Makefile provides the following targets:
| Target | Command | Description |
|---|---|---|
up |
docker compose up --build -d |
Build and start in detached mode |
down |
docker compose down |
Stop and remove the container |
build |
docker compose build |
Build image only (no start) |
clean |
docker compose build --no-cache |
Rebuild from scratch |
# Verify PostgreSQL is running
sudo systemctl status postgresql
# Check if it's listening on the default port
ss -tlnp | grep 5432
# Test connection from inside the container
docker compose run --rm app sh -c "nc -zv localhost 5432"# Create the user role (note the double quotes for the reserved word)
sudo -u postgres psql -c "CREATE ROLE \"user\" WITH LOGIN PASSWORD 'gI7_36rpP_aQEj5FyJp9';"# Check logs
docker compose logs appCommon causes:
- PostgreSQL not running / unreachable
- Invalid database credentials
- Port 8080 already in use on the host
# Add your user to the docker group
sudo usermod -aG docker $USER
# Log out and back in, or run:
newgrp docker
⚠️ Production Warning The credentials inapplication.propertiesanddocker-compose.ymlare for local development only. Before deploying:
- Change all default credentials — use environment variables or a secrets manager
- Use a strong JWT secret — at least 32 characters, cryptographically random:
openssl rand -base64 32
- Set environment variables in production:
export SPRING_DATASOURCE_PASSWORD='<strong-password>' export JWT_SECRET='<random-64-char-string>'
- Disable Docker host networking in production — use a Docker network or Kubernetes pod networking instead
- Add SSL/TLS via a reverse proxy (nginx, Traefik, or Spring Cloud Gateway)
- Use a non-root user inside the container (the Dockerfile already creates one via the
USERdirective)