Project: E-Commerce Backend
This capstone designs a larger, production-shaped e-commerce backend: a catalog of products and categories, registered users with roles, a shopping cart, and order placement — secured with JWT authentication and role-based authorization, persisted in PostgreSQL with Flyway migrations, filtered with JPA Specifications, observed through Actuator, and shipped as a Docker image. Rather than print every line, this page gives an architecture overview and the load-bearing excerpts per module, linking each to its deep-dive page. Pair it with the simpler Blog REST API project first if you want the CRUD fundamentals.
Architecture overview
The service is organized package-by-feature so each domain area is self-contained:
com.shop
├── catalog Product, Category, search/filter
├── user User, Role, registration
├── cart Cart, CartItem
├── order Order, OrderItem, checkout
├── security JWT filter, SecurityConfig, token service
├── common ProblemDetail advice, base DTOs, mappers
└── config OpenAPI, CORS, Actuator wiring
Requests flow Controller → Service → Repository, DTOs are exchanged at the edge (never entities), and cross-cutting concerns (auth, error mapping, observability) live in dedicated modules.
| Concern | Choice | Page |
|---|---|---|
| Persistence | PostgreSQL + Spring Data JPA | PostgreSQL |
| Schema | Flyway versioned migrations | Flyway |
| Auth | Stateless JWT, BCrypt passwords | JWT Authentication |
| Authorization | Roles + method security | Authorization |
| Filtering | JPA Specifications | Specifications |
| Observability | Actuator + Micrometer | Actuator |
| Delivery | Multi-stage Docker image | Dockerizing |
Step 1 — Dependencies and configuration
Generate from Spring Initializr with Web, Spring Data JPA, Validation, Security, PostgreSQL Driver, Flyway, Actuator, and Lombok. Add the JWT library:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Configuration uses externalized config with environment variables so no secret is committed:
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:5432/shop
username: ${DB_USER:shop}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Flyway owns the schema; Hibernate only validates
open-in-view: false
flyway:
enabled: true
app:
jwt:
secret: ${JWT_SECRET}
expiration-minutes: 60
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
Warning: With Flyway managing the schema, set
ddl-auto: validate(neverupdate). Hibernate then confirms the mapping matches the migrated tables instead of silently altering them.
Step 2 — Flyway schema
The first migration V1__init.sql lives in src/main/resources/db/migration. See Flyway for the lifecycle.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER'
);
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price NUMERIC(12,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
category_id BIGINT REFERENCES categories(id)
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL,
unit_price NUMERIC(12,2) NOT NULL
);
Step 3 — Domain entities
A Product belongs to a Category (many-to-one lazy), and an Order aggregates OrderItems. Auditing timestamps come from JPA Auditing.
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private int stock;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
}
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String password; // BCrypt hash, never plain text
@Enumerated(EnumType.STRING)
private Role role = Role.CUSTOMER;
}
public enum Role { CUSTOMER, ADMIN }
Step 4 — Security: JWT and roles
Passwords are hashed with BCrypt, login mints a signed JWT, and a filter validates the token on every request. The configuration uses the Spring Security 6 lambda DSL — see JWT Authentication for the full token service.
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtFilter;
public SecurityConfig(JwtAuthFilter jwtFilter) { this.jwtFilter = jwtFilter; }
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(org.springframework.http.HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
Method-level checks back up the URL rules for sensitive operations — see Authorization:
@PreAuthorize("hasRole('ADMIN')")
public ProductResponse create(ProductRequest req) { /* ... */ }
Note:
hasRole('ADMIN')matches the authorityROLE_ADMIN. Store the bare role (ADMIN) and let Spring add theROLE_prefix, or usehasAuthority(...)if you store the prefix yourself.
Step 5 — Catalog filtering with Specifications
Product search composes optional filters (name, category, price range) into one type-safe query using Specifications, paginated per Pagination with JPA.
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecs {
public static Specification<Product> nameContains(String q) {
return (root, cq, cb) -> q == null ? null
: cb.like(cb.lower(root.get("name")), "%" + q.toLowerCase() + "%");
}
public static Specification<Product> inCategory(Long categoryId) {
return (root, cq, cb) -> categoryId == null ? null
: cb.equal(root.get("category").get("id"), categoryId);
}
public static Specification<Product> priceAtMost(BigDecimal max) {
return (root, cq, cb) -> max == null ? null
: cb.lessThanOrEqualTo(root.get("price"), max);
}
}
@GetMapping("/api/products")
public Page<ProductResponse> search(
@RequestParam(required = false) String q,
@RequestParam(required = false) Long category,
@RequestParam(required = false) BigDecimal maxPrice,
Pageable pageable) {
Specification<Product> spec = Specification
.where(ProductSpecs.nameContains(q))
.and(ProductSpecs.inCategory(category))
.and(ProductSpecs.priceAtMost(maxPrice));
return repo.findAll(spec, pageable).map(mapper::toResponse);
}
The repository extends JpaSpecificationExecutor:
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {}
Step 6 — Cart and checkout
The order module turns a cart into an Order, decrementing stock inside a transaction so an oversold race fails cleanly. See Transactions.
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final ProductRepository products;
private final OrderRepository orders;
public OrderResponse checkout(User user, List<CartItem> items) {
Order order = new Order();
order.setUser(user);
order.setStatus(OrderStatus.PLACED);
for (CartItem item : items) {
Product p = products.findById(item.productId())
.orElseThrow(() -> new NotFoundException("Product", item.productId()));
if (p.getStock() < item.quantity())
throw new OutOfStockException(p.getId());
p.setStock(p.getStock() - item.quantity());
order.addItem(p, item.quantity(), p.getPrice());
}
order.recalculateTotal();
return OrderMapper.toResponse(orders.save(order));
}
}
Tip: For high-contention inventory, add optimistic locking with a
@Versioncolumn onProductso concurrent checkouts can’t both decrement past zero. See Transactions.
Step 7 — Errors and DTOs
Every domain exception maps to an RFC 7807 ProblemDetail through one @RestControllerAdvice, and the API speaks only in DTOs (DTO pattern):
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail notFound(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(OutOfStockException.class)
public ProblemDetail conflict(OutOfStockException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}
Step 8 — Observability with Actuator
Actuator exposes liveness/readiness probes and a Prometheus scrape endpoint. The config in Step 1 already opted in. A quick check:
curl http://localhost:8080/actuator/health
{ "status": "UP", "components": { "db": { "status": "UP" } } }
Step 9 — Dockerize and run
A multi-stage build produces a slim, non-root image — see Dockerizing for the full Dockerfile and buildpack alternative. Run the whole stack with Compose:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
ports: ["5432:5432"]
api:
build: .
environment:
DB_HOST: db
DB_PASSWORD: secret
JWT_SECRET: change-me-in-production
ports: ["8080:8080"]
depends_on: [db]
docker compose up --build
Flyway applies the migrations on first boot against the fresh Postgres container, and the API comes up ready to serve.
End-to-end flow
POST /api/auth/register → create user (BCrypt hash)
POST /api/auth/login → returns { "token": "eyJ..." }
GET /api/products?q=desk&maxPrice=200 (public, paginated)
POST /api/orders (Authorization: Bearer <token>) → 201 + order
POST /api/admin/products (ADMIN only) → 201 / 403 for customers
Where to go next
Harden it further with refresh tokens, add Redis caching for hot product reads, push metrics through Micrometer, enable graceful shutdown for zero-drop deploys, and document the API with Swagger/OpenAPI. The Best Practices guide collects the patterns this project relies on.