Skip to content
Spring Boot projects 6 min read

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.

ConcernChoicePage
PersistencePostgreSQL + Spring Data JPAPostgreSQL
SchemaFlyway versioned migrationsFlyway
AuthStateless JWT, BCrypt passwordsJWT Authentication
AuthorizationRoles + method securityAuthorization
FilteringJPA SpecificationsSpecifications
ObservabilityActuator + MicrometerActuator
DeliveryMulti-stage Docker imageDockerizing

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 (never update). 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 authority ROLE_ADMIN. Store the bare role (ADMIN) and let Spring add the ROLE_ prefix, or use hasAuthority(...) 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 @Version column on Product so 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.

Last updated June 13, 2026
Was this helpful?