Command Palette

Search for a command to run...

[Spring Boot Basics] Capstone Project: An Order Management REST API with Spring Boot

Forty-one articles built one catalogue a piece at a time, each piece in a project of its own: endpoints in Chapter 3, JPA and Flyway in Chapter 4, users and JWT in Chapter 5, tests in Chapter 6, Actuator and Docker in Chapter 7. This capstone puts every piece into one application, an order management REST API: customers register and log in, browse a paginated product catalogue, place orders that take stock, cancel them, and an administrator ships them. It runs on H2 during development and on PostgreSQL 18 in Docker Compose, documents itself in Swagger UI, and is covered by tests at every level.

Combining features is where new problems appear, so the article does not repeat what earlier articles explain; it points back to them by number and spends its words on the interactions: stock that oversells under concurrent requests until the rows are locked, a Swagger UI parameter that Pageable breaks, a @DataJpaTest that fails without the auditing configuration, an H2 check constraint that stops working after Flyway's connection closes, and a private key that has to reach a container without being part of the image.

An order moving through the API into a container stack, with a lock on the stock

The capstone uses Spring Boot 4.1.1, Java 21 and springdoc-openapi 3.1.1, with H2 for development and tests and PostgreSQL 18 in Docker Compose. Requests are sent with curl and jq, and the application listens on host port 8142 instead of the default 8080.

What the capstone builds and where each part was taught

Part of the projectWhat it usesTaught in
Endpoints, status codes, the cancel action URLthe worked design table, 400 vs 422, 409 for statearticle 15
Request DTOs and validationrecords with Bean Validation, @Validarticles 18 and 19
Error responsesProblemDetail, ResponseEntityExceptionHandler, 422 field lists, catch-allarticle 20
Package layout and layerspackage by feature, controller maps DTOs, service holds rulesarticle 21
API documentationspringdoc-openapi 3.1.1, Swagger UIarticle 22
Entities and repositoriesJpaRepository, derived queries, @Queryarticles 26 and 27
Orders and lines@ManyToOne(fetch = LAZY), mappedBy, cascade, orphanRemoval, @EntityGrapharticle 28
Paged listsPageable, @PageableDefault, PageResponse, max-page-sizearticle 29
All-or-nothing orders@Transactional on the use casearticle 30
SchemaFlyway migrations, ddl-auto=validate on PostgreSQLarticle 31
Audit timestampsAuditableEntity, @EnableJpaAuditing, DateTimeProviderarticle 32
Accounts and passwordsusers table, BCrypt, UserDetailsService, registrationarticle 34
TokensRSA key pair, NimbusJwtEncoder, resource server, roles to ROLE_article 35
AuthorizationURL rules, @EnableMethodSecurity, @PreAuthorize, the AccessDeniedException rethrowarticle 36
Unit testsJUnit 6, AssertJ, Mockitoarticle 37
Slice and end-to-end tests@WebMvcTest, @DataJpaTest, RANDOM_PORT with RestTestClientarticle 38
Health checkActuator /actuator/healtharticle 39
Containermulti-stage Dockerfile, Compose with PostgreSQL and health checksarticle 41
Configurationprofiles, environment variablesarticles 11 and 13

The capstone's packages and layers, each labelled with the article that taught it: the security filter chain in front of the controllers, services with method security and transactions, repositories, Flyway and PostgreSQL below, and springdoc and Actuator beside the API

Generating the project

The Spring Initializr ids are the ones earlier articles used, plus springdoc-openapi and actuator:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=orders&name=orders&packageName=com.example.orders&dependencies=web,validation,data-jpa,postgresql,h2,flyway,security,oauth2-resource-server,actuator,springdoc-openapi" -o orders.zip
Bash
unzip orders.zip -d orders

The generated build.gradle has every starter and test starter, including spring-boot-starter-actuator-test, and springdoc-openapi-starter-webmvc-ui:3.1.0. Four changes turn it into the capstone's build file:

  • springdoc 3.1.1 instead of 3.1.0, for the Swagger UI with the DOMPurify fix, as article 22 recommended.
  • The Mockito agent from article 37, so the test JVM loads Mockito as -javaagent instead of self-attaching.
  • testLogging with passed, skipped and failed, so the console lists every test.
  • The plain jar task disabled, which the COPY build/libs/*.jar in article 41's Dockerfile relies on.
build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.1.1'
	id 'io.spring.dependency-management' version '1.1.7'
}
 
group = 'com.example'
version = '0.0.1-SNAPSHOT'
 
java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}
 
configurations {
	mockitoAgent
}
 
repositories {
	mavenCentral()
}
 
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.flywaydb:flyway-database-postgresql'
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	mockitoAgent('org.mockito:mockito-core') {
		transitive = false
	}
}
 
tasks.named('test') {
	useJUnitPlatform()
	jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
	testLogging {
		events 'passed', 'skipped', 'failed'
	}
}
 
tasks.named('jar') {
	enabled = false
}

RSA keys outside the source tree

Article 35 generated the key pair into src/main/resources/certs, which is fine for a lab and wrong for anything that is built into an image: everything under src/main/resources ends up in the JAR. Here the keys go into a secrets directory at the project root, which neither Git nor Docker sees:

Bash
mkdir -p secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out secrets/private.pem
openssl pkey -in secrets/private.pem -pubout -out secrets/public.pem
Bash
printf '\n### Local secrets ###\nsecrets/\n.env\n' >> .gitignore

The application reads them with file: locations relative to the working directory, which is the project root for ./gradlew bootRun, ./gradlew test and java -jar build/libs/orders-0.0.1-SNAPSHOT.jar started from there. The Docker section gives the container its own copy at runtime.

The project tree

Tree
orders
├── build.gradle
├── compose.yaml
├── Dockerfile
├── .dockerignore
├── .env                          not committed: APP_ADMIN_PASSWORD
├── secrets                       not committed: private.pem, public.pem
└── src
    ├── main
    │   ├── java/com/example/orders
    │   │   ├── OrdersApplication.java
    │   │   ├── common
    │   │   │   ├── AuditableEntity.java
    │   │   │   ├── AuditingConfig.java
    │   │   │   ├── AuthConfig.java
    │   │   │   ├── ConflictException.java
    │   │   │   ├── GlobalExceptionHandler.java
    │   │   │   ├── MethodSecurityConfig.java
    │   │   │   ├── NotFoundException.java
    │   │   │   ├── OpenApiConfig.java
    │   │   │   ├── PageResponse.java
    │   │   │   ├── ProblemDetailSecurityHandler.java
    │   │   │   └── SecurityConfig.java
    │   │   ├── order
    │   │   │   ├── Order.java
    │   │   │   ├── OrderAccess.java
    │   │   │   ├── OrderController.java
    │   │   │   ├── OrderItem.java
    │   │   │   ├── OrderLine.java
    │   │   │   ├── OrderNotFoundException.java
    │   │   │   ├── OrderRepository.java
    │   │   │   ├── OrderResponse.java
    │   │   │   ├── OrderService.java
    │   │   │   ├── OrderStatus.java
    │   │   │   ├── OrderStatusException.java
    │   │   │   └── PlaceOrderRequest.java
    │   │   ├── product
    │   │   │   ├── CreateProductRequest.java
    │   │   │   ├── DuplicateSkuException.java
    │   │   │   ├── InsufficientStockException.java
    │   │   │   ├── Product.java
    │   │   │   ├── ProductController.java
    │   │   │   ├── ProductNotFoundException.java
    │   │   │   ├── ProductRepository.java
    │   │   │   ├── ProductResponse.java
    │   │   │   └── ProductService.java
    │   │   └── user
    │   │       ├── AdminAccountInitializer.java
    │   │       ├── AppUser.java
    │   │       ├── AppUserRepository.java
    │   │       ├── AuthController.java
    │   │       ├── JpaUserDetailsService.java
    │   │       ├── LoginRequest.java
    │   │       ├── RegisterRequest.java
    │   │       ├── Role.java
    │   │       ├── TokenResponse.java
    │   │       ├── TokenService.java
    │   │       ├── UserAlreadyExistsException.java
    │   │       ├── UserResponse.java
    │   │       └── UserService.java
    │   └── resources
    │       ├── application.properties
    │       ├── application-postgres.properties
    │       └── db/migration
    │           ├── V1__create_users.sql
    │           ├── V2__create_products.sql
    │           └── V3__create_orders.sql
    └── test/java/com/example/orders
        ├── OrdersApplicationTests.java
        └── order
            ├── OrderApiTest.java
            ├── OrderControllerTest.java
            ├── OrderRepositoryTest.java
            └── OrderServiceTest.java

OrdersApplication and OrdersApplicationTests are Initializr's, unchanged. The static and templates directories Initializr creates stay empty.

The API: endpoints, roles and status codes

Article 15's design placed an order under its customer, POST /api/customers/{id}/orders. With JWT the customer is already identified: the token's subject says who is calling, so a customer id in the URL can only repeat it, and every request where the two disagree needs a check whose only possible outcome is a 403. The capstone therefore places orders with POST /api/orders for the authenticated user and keeps the rest of article 15's order design: the new order's own URL in Location, and cancelling as the action POST /api/orders/{id}/cancel. Shipping follows the same shape as an administrator action, and an order that belongs to someone else answers 403, as it did in article 36.

MethodPathWhoSuccessErrors
POST/api/auth/registeranyone201400, 409 username or email taken, 422
POST/api/auth/loginanyone200 with the token400, 401 bad credentials, 422
GET/api/auth/meauthenticated200401
GET/api/productsanyone200, a page; ?page=, ?size=, ?sort=400 unknown sort property
GET/api/products/{id}anyone200400, 404
POST/api/productsADMIN201 + Location400, 401, 403, 409 duplicate SKU, 415, 422
POST/api/ordersauthenticated201 + Location: /api/orders/{orderId}400, 401, 404 unknown product, 409 not enough stock, 415, 422
GET/api/ordersauthenticated: own orders; ADMIN: all200, a page400, 401
GET/api/orders/{id}the owner or ADMIN200400, 401, 403 someone else's order, 404
POST/api/orders/{id}/cancelthe owner or ADMIN200400, 401, 403, 404, 409 not PLACED
POST/api/orders/{id}/shipADMIN200400, 401, 403, 404, 409 not PLACED

Every status in the table was produced against the finished application. Products have no PUT or DELETE here: order lines reference them, and an admin restock endpoint would take the same row lock that placing an order takes.

Configuration and Flyway migrations

src/main/resources/application.properties
spring.application.name=orders
spring.jpa.open-in-view=false
spring.flyway.validate-migration-naming=true
spring.data.web.pageable.max-page-size=100
 
spring.security.oauth2.resourceserver.jwt.public-key-location=file:secrets/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
app.jwt.private-key-location=file:secrets/private.pem
app.jwt.issuer=https://orders.example.com
 
app.admin.username=admin
app.admin.email=admin@example.com
  • open-in-view=false as in every article since 26, so each endpoint fetches what it maps.
  • validate-migration-naming=true from article 31, so a misnamed migration stops startup instead of being skipped.
  • max-page-size=100 from article 29.
  • The key locations use file: instead of article 35's classpath:, for the reason given above.
  • app.jwt.issuer replaces the constant in article 35's TokenService; the same value feeds the issuer validator.
  • app.admin.* configures the first administrator. The password has no default; the section on users explains why.

The postgres profile is article 41's, with this project's port and no password:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:5442/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validate

Three migrations, one per feature, in article 31's style: named constraints, indexes on foreign keys, NUMERIC(10, 2) for money, TIMESTAMP WITH TIME ZONE for article 32's Instant audit columns, and checks that protect the stock rules even from code that bypasses the entities:

src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE users (
    id            BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    username      VARCHAR(50)  NOT NULL,
    email         VARCHAR(254) NOT NULL,
    password_hash VARCHAR(100) NOT NULL,
    role          VARCHAR(20)  NOT NULL,
    enabled       BOOLEAN      NOT NULL,
    CONSTRAINT uk_users_username UNIQUE (username),
    CONSTRAINT uk_users_email UNIQUE (email)
);
src/main/resources/db/migration/V2__create_products.sql
CREATE TABLE products (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name       VARCHAR(120)             NOT NULL,
    sku        VARCHAR(40)              NOT NULL,
    price      NUMERIC(10, 2)           NOT NULL,
    stock      INTEGER                  NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku),
    CONSTRAINT ck_products_price CHECK (price > 0),
    CONSTRAINT ck_products_stock CHECK (stock >= 0)
);
src/main/resources/db/migration/V3__create_orders.sql
CREATE TABLE orders (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    customer_id BIGINT                   NOT NULL,
    status      VARCHAR(20)              NOT NULL,
    created_at  TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at  TIMESTAMP WITH TIME ZONE NOT NULL,
    CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES users (id)
);
 
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
 
CREATE TABLE order_lines (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   BIGINT         NOT NULL,
    product_id BIGINT         NOT NULL,
    quantity   INTEGER        NOT NULL,
    unit_price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT fk_order_lines_order FOREIGN KEY (order_id) REFERENCES orders (id),
    CONSTRAINT fk_order_lines_product FOREIGN KEY (product_id) REFERENCES products (id),
    CONSTRAINT ck_order_lines_quantity CHECK (quantity > 0)
);
 
CREATE INDEX idx_order_lines_order_id ON order_lines (order_id);
CREATE INDEX idx_order_lines_product_id ON order_lines (product_id);

Why role and status have no CHECK constraint

The first version of these migrations had CONSTRAINT ck_users_role CHECK (role IN ('USER', 'ADMIN')) and a matching check on orders.status. The application started on H2 and on PostgreSQL, and registration worked on both. Then @DataJpaTest failed on its first persist of a user:

Text
org.hibernate.exception.ConstraintViolationException: could not execute statement [Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
insert into users (email,enabled,password_hash,role,username,id) values (?,?,?,?,?,default) [23514-240]]
Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
Caused by: org.h2.message.DbException: The database has been closed [90098-240]

A plain JDBC program narrowed it down on H2 2.4.240: a CHECK that compares strings, IN (...) or = 'USER' OR = 'ADMIN' alike, worked while the connection that created the table was open and failed with exactly this error on a new connection after that one was closed; a numeric check such as quantity > 0 kept working. In the application, Flyway's connection went back to the HikariCP pool and stayed open, so registration worked; the embedded DataSource that @DataJpaTest puts in place hands out plain org.h2.jdbc.JdbcConnection objects, and a probe test saw one report isClosed() as true after close(), so Flyway's connection was already closed when the test inserted. The values of both columns come from Java enums through @Enumerated(EnumType.STRING), so the capstone keeps the numeric checks and drops the two string checks rather than depend on which connection happens to stay open.

Shared code in common

Article 21 suggested that features throw subclasses of a few base exceptions from common, so that the advice stops importing feature classes. The capstone does exactly that, with one base class per status:

src/main/java/com/example/orders/common/NotFoundException.java
package com.example.orders.common;
 
public abstract class NotFoundException extends RuntimeException {
 
    protected NotFoundException(String message) {
        super(message);
    }
}
src/main/java/com/example/orders/common/ConflictException.java
package com.example.orders.common;
 
public abstract class ConflictException extends RuntimeException {
 
    protected ConflictException(String message) {
        super(message);
    }
}

AuditableEntity is article 32's without the createdBy and updatedBy columns; an order already records its customer:

src/main/java/com/example/orders/common/AuditableEntity.java
package com.example.orders.common;
 
import java.time.Instant;
 
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
 
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
 
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
 
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;
 
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
 
    public Instant getCreatedAt() {
        return createdAt;
    }
 
    public Instant getUpdatedAt() {
        return updatedAt;
    }
}

AuditingConfig has article 32's DateTimeProvider, with one change that the Docker run made necessary:

src/main/java/com/example/orders/common/AuditingConfig.java
package com.example.orders.common;
 
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
 
    @Bean
    DateTimeProvider auditingDateTimeProvider() {
        return () -> Optional.of(Instant.now().truncatedTo(ChronoUnit.MICROS));
    }
}

With the default provider, a product created in the container came back from POST with "createdAt":"2026-09-16T09:18:35.296391794Z" and from the next GET with "createdAt":"2026-09-16T09:18:35.296392Z". The JVM on the Linux container read the clock to the nanosecond, the JSON of the POST was written from that value, and PostgreSQL stored and returned microseconds, rounded. On the macOS host the clock only had microseconds, so nothing showed there. Truncating to microseconds before the value reaches the entity made both responses identical, 09:19:15.316336Z in the rebuilt stack.

PageResponse is article 29's record, unchanged, in com.example.orders.common. The advice is article 20's ResponseEntityExceptionHandler subclass with the handlers every later article added: the 409 for DataIntegrityViolationException from article 26, the 400 for an unknown sort property from article 29, the 401 for a failed login from articles 34 and 35 and the AccessDeniedException rethrow from article 36:

src/main/java/com/example/orders/common/GlobalExceptionHandler.java
package com.example.orders.common;
 
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
 
import jakarta.servlet.http.HttpServletRequest;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterErrors;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
 
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
    record FieldViolation(String field, String message) {
    }
 
    @ExceptionHandler(NotFoundException.class)
    public ProblemDetail notFound(NotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
 
    @ExceptionHandler(ConflictException.class)
    public ProblemDetail conflict(ConflictException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    }
 
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ProblemDetail constraintViolated(DataIntegrityViolationException ex) {
        log.warn("Constraint violation: {}", ex.getMostSpecificCause().getMessage());
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data.");
    }
 
    @ExceptionHandler(PropertyReferenceException.class)
    public ProblemDetail unknownSortProperty(PropertyReferenceException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
                "Unknown sort property: " + ex.getPropertyName());
    }
 
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ProblemDetail> authenticationFailed(AuthenticationException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
                "Invalid username or password");
        return ResponseEntity.of(problem)
                .header(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"orders\"")
                .build();
    }
 
    @ExceptionHandler(AccessDeniedException.class)
    public void rethrowAccessDenied(AccessDeniedException ex) {
        throw ex;
    }
 
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
        problem.setTitle("Internal Server Error");
        return problem;
    }
 
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
        }
        HttpStatus responseStatus = HttpStatus.UNPROCESSABLE_CONTENT;
        return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
    }
 
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        boolean bodyInvalid = false;
        for (ParameterErrors result : ex.getBeanResults()) {
            if (result.getMethodParameter().hasParameterAnnotation(RequestBody.class)) {
                bodyInvalid = true;
            }
            for (FieldError error : result.getFieldErrors()) {
                errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
            }
        }
        for (ParameterValidationResult result : ex.getValueResults()) {
            String name = result.getMethodParameter().getParameterName();
            for (MessageSourceResolvable error : result.getResolvableErrors()) {
                errors.add(new FieldViolation(name, error.getDefaultMessage()));
            }
        }
        HttpStatus responseStatus = bodyInvalid ? HttpStatus.UNPROCESSABLE_CONTENT : HttpStatus.BAD_REQUEST;
        return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
    }
 
    private ProblemDetail validationProblem(HttpStatus status, List<FieldViolation> errors) {
        errors.sort(Comparator.comparing(FieldViolation::field));
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                status, "Request has " + errors.size() + " invalid value(s).");
        problem.setProperty("errors", errors);
        return problem;
    }
}

The login handler catches AuthenticationException rather than only BadCredentialsException, so the DisabledException that article 34 met for a disabled account, another AuthenticationException, is answered by the same handler.

Security: one filter chain, JWT and method security

The filter chain

Article 33 split the application into an API chain and a form-login chain, and article 39 added a third for Actuator. The capstone serves no pages, so one chain covers every request, and nothing can fall outside all chains, the gap article 39 found for /actuator:

src/main/java/com/example/orders/common/SecurityConfig.java
package com.example.orders.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
                        .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/orders/*/ship").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}
  • Public: product reads, registration and login (article 36's rules), GET /actuator/health for the Compose health check, and the documentation paths.
  • ADMIN: creating products and shipping orders, decided by URL because the rule depends on nothing but the path.
  • Authenticated: everything else, including all other order endpoints, whose finer rule is method security below.
  • The rest is article 35's resource server with ProblemDetailSecurityHandler as both entry points, CSRF off for a bearer API and no session.

Actuator exposes only health over HTTP by default (article 39); /actuator and /actuator/info without a token answered the chain's 401.

Why the chain and the token beans live in separate classes

Article 38 imported its SecurityConfig into a @WebMvcTest and hit a StackOverflowError as soon as a request carried a real, invalid bearer token, because that class also declared the AuthenticationManager bean. Here the chain has a class of its own, and the beans that authentication and token signing need are in another:

src/main/java/com/example/orders/common/AuthConfig.java
package com.example.orders.common;
 
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 
@Configuration
public class AuthConfig {
 
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) {
        return configuration.getAuthenticationManager();
    }
 
    @Bean
    JwtEncoder jwtEncoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.public-key-location}") RSAPublicKey publicKey,
            @Value("${app.jwt.private-key-location}") RSAPrivateKey privateKey) {
        return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build();
    }
 
    @Bean
    JwtTimestampValidator jwtTimestampValidator() {
        JwtTimestampValidator validator = new JwtTimestampValidator(Duration.ZERO);
        validator.setAllowEmptyExpiryClaim(false);
        return validator;
    }
 
    @Bean
    JwtIssuerValidator jwtIssuerValidator(@Value("${app.jwt.issuer}") String issuer) {
        return new JwtIssuerValidator(issuer);
    }
}

The web slice imports SecurityConfig and ProblemDetailSecurityHandler only. To confirm the split is what matters, a copy of the test class, SingleSecurityConfigControllerTest, added AuthConfig to its @Import, and its test that sends Authorization: Bearer abc.def.ghi failed exactly like article 38's:

Text
SingleSecurityConfigControllerTest > placeWithAnInvalidTokenIs401() FAILED
    java.lang.StackOverflowError at ReflectiveOperationException.java:90
Text
java.lang.StackOverflowError
	at java.base/java.lang.ReflectiveOperationException.<init>(ReflectiveOperationException.java:90)
	at java.base/java.lang.reflect.InvocationTargetException.<init>(InvocationTargetException.java:68)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
	at jdk.proxy3/jdk.proxy3.$Proxy220.authenticate(Unknown Source)

Without AuthConfig the slice has no AuthenticationManager bean for the lazy proxy to call, and the same request is the 401 the test expects. Method security has a class of its own for the same reason, so the slice does not wrap its mocked service in authorization checks:

src/main/java/com/example/orders/common/MethodSecurityConfig.java
package com.example.orders.common;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
 
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

The entry point and access denied handler combine article 33's ProblemDetail writer with article 35's delegation to BearerTokenAuthenticationEntryPoint; article 35 only showed that change as a diff, so here is the whole class:

src/main/java/com/example/orders/common/ProblemDetailSecurityHandler.java
package com.example.orders.common;
 
import java.io.IOException;
import java.net.URI;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
 
import tools.jackson.databind.json.JsonMapper;
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
    private final BearerTokenAuthenticationEntryPoint bearerEntryPoint = new BearerTokenAuthenticationEntryPoint();
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
        this.bearerEntryPoint.setRealmName("orders");
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        bearerEntryPoint.commence(request, response, ex);
        write(request, response, HttpStatus.valueOf(response.getStatus()),
                "Valid credentials are required to access this resource.");
    }
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
    }
 
    private void write(HttpServletRequest request, HttpServletResponse response,
                       HttpStatus status, String detail) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setInstance(URI.create(request.getRequestURI()));
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        jsonMapper.writeValue(response.getOutputStream(), problem);
    }
}

Users, registration, login and the first admin

The user package is articles 34 and 35 almost unchanged. These classes are identical to their final versions there, with the package renamed to com.example.orders.user:

ClassAs in
Rolearticle 34: USER, ADMIN
AppUserRepositoryarticle 34: findByUsername, existsByUsername, existsByEmail
JpaUserDetailsServicearticle 34, including updatePassword
RegisterRequestarticle 34, with the @AssertTrue 72-byte check
LoginRequest, UserResponsearticle 34
UserServicearticle 34: register and findByUsername
TokenResponsearticle 35

AppUser gains a constructor that takes the role, for the administrator:

src/main/java/com/example/orders/user/AppUser.java
package com.example.orders.user;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "users")
public class AppUser {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 50, unique = true)
    private String username;
 
    @Column(nullable = false, length = 254, unique = true)
    private String email;
 
    @Column(nullable = false, length = 100)
    private String passwordHash;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role = Role.USER;
 
    private boolean enabled = true;
 
    protected AppUser() {
    }
 
    public AppUser(String username, String email, String passwordHash) {
        this(username, email, passwordHash, Role.USER);
    }
 
    public AppUser(String username, String email, String passwordHash, Role role) {
        this.username = username;
        this.email = email;
        this.passwordHash = passwordHash;
        this.role = role;
    }
 
    public Long getId() { return id; }
 
    public String getUsername() { return username; }
 
    public String getEmail() { return email; }
 
    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
 
    public Role getRole() { return role; }
 
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AppUser other)) return false;
        return id != null && id.equals(other.getId());
    }
 
    @Override
    public int hashCode() {
        return AppUser.class.hashCode();
    }
}

UserAlreadyExistsException now extends ConflictException:

src/main/java/com/example/orders/user/UserAlreadyExistsException.java
package com.example.orders.user;
 
import com.example.orders.common.ConflictException;
 
public class UserAlreadyExistsException extends ConflictException {
 
    public UserAlreadyExistsException(String message) {
        super(message);
    }
}

TokenService reads the issuer from app.jwt.issuer:

src/main/java/com/example/orders/user/TokenService.java
package com.example.orders.user;
 
import java.time.Duration;
import java.time.Instant;
import java.util.List;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
 
@Service
public class TokenService {
 
    private static final Duration LIFETIME = Duration.ofMinutes(15);
 
    private final JwtEncoder jwtEncoder;
    private final String issuer;
 
    public TokenService(JwtEncoder jwtEncoder, @Value("${app.jwt.issuer}") String issuer) {
        this.jwtEncoder = jwtEncoder;
        this.issuer = issuer;
    }
 
    public TokenResponse issue(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(authority -> authority.startsWith("ROLE_"))
                .map(authority -> authority.substring("ROLE_".length()))
                .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer(issuer)
                .subject(authentication.getName())
                .issuedAt(now)
                .expiresAt(now.plus(LIFETIME))
                .claim("roles", roles)
                .build();
        String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
        return new TokenResponse(token, "Bearer", LIFETIME.toSeconds());
    }
}

AuthController is article 35's with springdoc annotations: a tag, and the bearer requirement on me:

src/main/java/com/example/orders/user/AuthController.java
package com.example.orders.user;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication")
public class AuthController {
 
    private final UserService userService;
    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;
 
    public AuthController(UserService userService, AuthenticationManager authenticationManager,
                          TokenService tokenService) {
        this.userService = userService;
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
        AppUser user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
    }
 
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
        return tokenService.issue(authentication);
    }
 
    @GetMapping("/me")
    @SecurityRequirement(name = "bearer-jwt")
    public UserResponse me(@AuthenticationPrincipal Jwt jwt) {
        return UserResponse.from(userService.findByUsername(jwt.getSubject()));
    }
}

Creating the first administrator

Registration only creates USER accounts, so the first ADMIN has to come from somewhere else. A Flyway migration could insert it with a BCrypt hash, but that hash, and therefore one known password, would then exist in every database ever built from the repository, development and production alike. A runner that creates the account only when app.admin.password is set, and only if the username does not exist yet, keeps the credential out of the repository and lets each environment choose its own:

src/main/java/com/example/orders/user/AdminAccountInitializer.java
package com.example.orders.user;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
 
@Component
class AdminAccountInitializer implements ApplicationRunner {
 
    private static final Logger log = LoggerFactory.getLogger(AdminAccountInitializer.class);
 
    private final AppUserRepository users;
    private final PasswordEncoder passwordEncoder;
    private final String username;
    private final String email;
    private final String password;
 
    AdminAccountInitializer(AppUserRepository users, PasswordEncoder passwordEncoder,
                            @Value("${app.admin.username}") String username,
                            @Value("${app.admin.email}") String email,
                            @Value("${app.admin.password:}") String password) {
        this.users = users;
        this.passwordEncoder = passwordEncoder;
        this.username = username;
        this.email = email;
        this.password = password;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        if (password.isBlank()) {
            log.info("app.admin.password is not set, no admin account created");
            return;
        }
        if (users.existsByUsername(username)) {
            log.info("Admin account '{}' already exists", username);
            return;
        }
        users.save(new AppUser(username, email, passwordEncoder.encode(password), Role.ADMIN));
        log.info("Created admin account '{}'", username);
    }
}

Without the property, as in the tests, it logs app.admin.password is not set, no admin account created. The first start of the Compose stack logged Created admin account 'admin', and a restart of the application container Admin account 'admin' already exists. Changing the password later is not its job.

Products

Product is article 37's entity with its stock rule, now with audit columns, lengths that match V2, and the opposite operation for cancellations:

src/main/java/com/example/orders/product/Product.java
package com.example.orders.product;
 
import java.math.BigDecimal;
 
import com.example.orders.common.AuditableEntity;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "products")
public class Product extends AuditableEntity {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(nullable = false, length = 40, unique = true)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    protected Product() {
    }
 
    public Product(String name, String sku, BigDecimal price, int stock) {
        this.name = name;
        this.sku = sku;
        this.price = price;
        this.stock = stock;
    }
 
    public void decreaseStock(int quantity) {
        requirePositive(quantity);
        if (quantity > stock) {
            throw new InsufficientStockException(sku, stock, quantity);
        }
        stock -= quantity;
    }
 
    public void increaseStock(int quantity) {
        requirePositive(quantity);
        stock += quantity;
    }
 
    private static void requirePositive(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive, was " + quantity);
        }
    }
 
    public Long getId() {
        return id;
    }
 
    public String getName() {
        return name;
    }
 
    public String getSku() {
        return sku;
    }
 
    public BigDecimal getPrice() {
        return price;
    }
 
    public int getStock() {
        return stock;
    }
}
src/main/java/com/example/orders/product/ProductRepository.java
package com.example.orders.product;
 
import java.util.Optional;
 
import jakarta.persistence.LockModeType;
 
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
 
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    boolean existsBySku(String sku);
 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findForUpdateById(Long id);
}

findForUpdateById is a derived query from article 27 with a lock mode on it; the section on the order service explains why placing an order needs it.

src/main/java/com/example/orders/product/ProductService.java
package com.example.orders.product;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@Transactional(readOnly = true)
public class ProductService {
 
    private final ProductRepository products;
 
    public ProductService(ProductRepository products) {
        this.products = products;
    }
 
    public Page<Product> findPage(Pageable pageable) {
        return products.findAll(pageable);
    }
 
    public Product findById(Long id) {
        return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
    }
 
    @Transactional
    public Product create(Product product) {
        if (products.existsBySku(product.getSku())) {
            throw new DuplicateSkuException(product.getSku());
        }
        return products.save(product);
    }
}
src/main/java/com/example/orders/product/CreateProductRequest.java
package com.example.orders.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
 
public record CreateProductRequest(
        @NotBlank @Size(max = 120) String name,
        @NotBlank @Size(max = 40) String sku,
        @NotNull @Positive @Digits(integer = 8, fraction = 2) BigDecimal price,
        @NotNull @PositiveOrZero Integer stock) {
 
    Product toProduct() {
        return new Product(name, sku, price, stock);
    }
}
src/main/java/com/example/orders/product/ProductResponse.java
package com.example.orders.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock,
                              Instant createdAt, Instant updatedAt) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
                product.getStock(), product.getCreatedAt(), product.getUpdatedAt());
    }
}
src/main/java/com/example/orders/product/ProductController.java
package com.example.orders.product;
 
import java.net.URI;
 
import com.example.orders.common.PageResponse;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/products")
@Tag(name = "Products")
public class ProductController {
 
    private final ProductService service;
 
    public ProductController(ProductService service) {
        this.service = service;
    }
 
    @GetMapping
    public PageResponse<ProductResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable) {
        return PageResponse.from(service.findPage(pageable).map(ProductResponse::from));
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return ProductResponse.from(service.findById(id));
    }
 
    @PostMapping
    @SecurityRequirement(name = "bearer-jwt")
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = service.create(request.toProduct());
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(product.getId())
                .toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(product));
    }
}

@ParameterObject is for springdoc, and the documentation section shows what it fixes. The three exceptions keep the messages articles 21 and 37 used:

src/main/java/com/example/orders/product/ProductNotFoundException.java
package com.example.orders.product;
 
import com.example.orders.common.NotFoundException;
 
public class ProductNotFoundException extends NotFoundException {
 
    public ProductNotFoundException(Long id) {
        super("Product " + id + " not found");
    }
}
src/main/java/com/example/orders/product/DuplicateSkuException.java
package com.example.orders.product;
 
import com.example.orders.common.ConflictException;
 
public class DuplicateSkuException extends ConflictException {
 
    public DuplicateSkuException(String sku) {
        super("SKU " + sku + " already exists");
    }
}
src/main/java/com/example/orders/product/InsufficientStockException.java
package com.example.orders.product;
 
import com.example.orders.common.ConflictException;
 
public class InsufficientStockException extends ConflictException {
 
    public InsufficientStockException(String sku, int available, int requested) {
        super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
    }
}

Orders: entities, repository and ownership

An order has a customer, a status and lines. The status transitions live in the entity, as article 37 put the stock rule into Product:

src/main/java/com/example/orders/order/OrderStatus.java
package com.example.orders.order;
 
public enum OrderStatus {
    PLACED, SHIPPED, CANCELLED
}
src/main/java/com/example/orders/order/Order.java
package com.example.orders.order;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
 
import com.example.orders.common.AuditableEntity;
import com.example.orders.user.AppUser;
 
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "orders")
public class Order extends AuditableEntity {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private AppUser customer;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status = OrderStatus.PLACED;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();
 
    protected Order() {
    }
 
    public Order(AppUser customer) {
        this.customer = customer;
    }
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
 
    public void ship() {
        if (status != OrderStatus.PLACED) {
            throw new OrderStatusException("Order " + id + " is " + status + " and cannot be shipped");
        }
        status = OrderStatus.SHIPPED;
    }
 
    public void cancel() {
        if (status != OrderStatus.PLACED) {
            throw new OrderStatusException("Order " + id + " is " + status + " and cannot be cancelled");
        }
        status = OrderStatus.CANCELLED;
    }
 
    public BigDecimal total() {
        return lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
 
    public Long getId() {
        return id;
    }
 
    public AppUser getCustomer() {
        return customer;
    }
 
    public OrderStatus getStatus() {
        return status;
    }
 
    public List<OrderLine> getLines() {
        return lines;
    }
}
src/main/java/com/example/orders/order/OrderLine.java
package com.example.orders.order;
 
import java.math.BigDecimal;
 
import com.example.orders.product.Product;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "order_lines")
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
 
    protected OrderLine() {
    }
 
    public OrderLine(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = product.getPrice();
    }
 
    public BigDecimal lineTotal() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
 
    void setOrder(Order order) {
        this.order = order;
    }
 
    public Long getId() {
        return id;
    }
 
    public Product getProduct() {
        return product;
    }
 
    public int getQuantity() {
        return quantity;
    }
 
    public BigDecimal getUnitPrice() {
        return unitPrice;
    }
}

OrderLine is article 28's final mapping: both @ManyToOne lazy and NOT NULL, the unit price copied from the product when the line is created. The request and the response records are article 28's too, with the status and audit timestamps added to the response:

src/main/java/com/example/orders/order/OrderItem.java
package com.example.orders.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/orders/order/PlaceOrderRequest.java
package com.example.orders.order;
 
import java.util.List;
 
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
 
public record PlaceOrderRequest(@NotEmpty List<@Valid Line> lines) {
 
    public record Line(@NotNull Long productId, @NotNull @Positive Integer quantity) {
    }
}
src/main/java/com/example/orders/order/OrderResponse.java
package com.example.orders.order;
 
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
 
public record OrderResponse(Long id, Long customerId, OrderStatus status, List<OrderLineResponse> lines,
                            BigDecimal total, Instant createdAt, Instant updatedAt) {
 
    public record OrderLineResponse(Long productId, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) {
 
        static OrderLineResponse from(OrderLine line) {
            return new OrderLineResponse(line.getProduct().getId(), line.getQuantity(),
                    line.getUnitPrice(), line.lineTotal());
        }
    }
 
    static OrderResponse from(Order order) {
        return new OrderResponse(order.getId(), order.getCustomer().getId(), order.getStatus(),
                order.getLines().stream().map(OrderLineResponse::from).toList(), order.total(),
                order.getCreatedAt(), order.getUpdatedAt());
    }
}

customerId reads the id of a lazy AppUser reference, which article 28 showed costs no query; the response never touches the rest of the customer.

src/main/java/com/example/orders/order/OrderRepository.java
package com.example.orders.order;
 
import java.util.Optional;
 
import jakarta.persistence.LockModeType;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
 
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findWithLinesById(Long id);
 
    @EntityGraph(attributePaths = "lines")
    Page<Order> findByCustomerUsername(String username, Pageable pageable);
 
    @Override
    @EntityGraph(attributePaths = "lines")
    Page<Order> findAll(Pageable pageable);
 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findForUpdateById(Long id);
 
    @Query("select o.customer.username from Order o where o.id = :id")
    Optional<String> findCustomerUsernameById(Long id);
}
  • findWithLinesById and the paged findByCustomerUsername and findAll fetch the lines with an entity graph, as articles 28 and 29 did.
  • findForUpdateById locks the order row for cancel and ship, with the lines.
  • findCustomerUsernameById answers one question for the ownership rule: who owns this order.
src/main/java/com/example/orders/order/OrderAccess.java
package com.example.orders.order;
 
import org.springframework.stereotype.Component;
 
@Component
public class OrderAccess {
 
    private final OrderRepository orders;
 
    public OrderAccess(OrderRepository orders) {
        this.orders = orders;
    }
 
    public boolean isOwner(Long orderId, String username) {
        return orders.findCustomerUsernameById(orderId)
                .map(owner -> owner.equals(username))
                .orElse(true); // an unknown id passes, so the service itself answers 404
    }
}
src/main/java/com/example/orders/order/OrderNotFoundException.java
package com.example.orders.order;
 
import com.example.orders.common.NotFoundException;
 
public class OrderNotFoundException extends NotFoundException {
 
    public OrderNotFoundException(Long id) {
        super("Order " + id + " not found");
    }
}
src/main/java/com/example/orders/order/OrderStatusException.java
package com.example.orders.order;
 
import com.example.orders.common.ConflictException;
 
public class OrderStatusException extends ConflictException {
 
    public OrderStatusException(String message) {
        super(message);
    }
}

The business logic: OrderService.place, cancel and ship

src/main/java/com/example/orders/order/OrderService.java
package com.example.orders.order;
 
import java.util.Comparator;
import java.util.List;
 
import com.example.orders.product.Product;
import com.example.orders.product.ProductNotFoundException;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@Transactional(readOnly = true)
public class OrderService {
 
    private final OrderRepository orders;
    private final ProductRepository products;
    private final AppUserRepository users;
 
    public OrderService(OrderRepository orders, ProductRepository products, AppUserRepository users) {
        this.orders = orders;
        this.products = products;
        this.users = users;
    }
 
    @Transactional
    public Order place(String username, List<OrderItem> items) {
        AppUser customer = users.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user named " + username));
        Order order = new Order(customer);
        List<OrderItem> inProductOrder = items.stream()
                .sorted(Comparator.comparing(OrderItem::productId))
                .toList();
        for (OrderItem item : inProductOrder) {
            Product product = products.findForUpdateById(item.productId())
                    .orElseThrow(() -> new ProductNotFoundException(item.productId()));
            product.decreaseStock(item.quantity());
            order.addLine(new OrderLine(product, item.quantity()));
        }
        return orders.save(order);
    }
 
    @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
    public Order findById(Long id) {
        return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
 
    public Page<Order> findByCustomer(String username, Pageable pageable) {
        return orders.findByCustomerUsername(username, pageable);
    }
 
    @PreAuthorize("hasRole('ADMIN')")
    public Page<Order> findAll(Pageable pageable) {
        return orders.findAll(pageable);
    }
 
    @Transactional
    @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
    public Order cancel(Long id) {
        Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
        order.cancel();
        List<OrderLine> inProductOrder = order.getLines().stream()
                .sorted(Comparator.comparing(line -> line.getProduct().getId()))
                .toList();
        for (OrderLine line : inProductOrder) {
            Product product = products.findForUpdateById(line.getProduct().getId()).orElseThrow();
            product.increaseStock(line.getQuantity());
        }
        return order;
    }
 
    @Transactional
    public Order ship(Long id) {
        Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
        order.ship();
        return order;
    }
}
  • place runs in one transaction (article 30). It loads the customer named by the token, then for each item locks the product row, lets Product.decreaseStock apply the rule and adds a line with the current price. If any line is short, InsufficientStockException rolls the whole transaction back, stock decreases of earlier lines included, and the advice answers 409. Only when every line passed is the order saved, and cascading persists its lines (article 28).
  • cancel locks the order, lets Order.cancel refuse anything that is not PLACED, and puts each line's quantity back on its product, again under a row lock.
  • ship locks the order and lets Order.ship apply the same state rule.
  • Reads inherit readOnly = true from the class (article 30).

Every method returns entities, and OrderController maps them after the transaction has committed. That is safe here because each method returns an order whose lines were fetched or touched inside the transaction, and because the audit listener sets updatedAt on the managed object during the flush, the updatedAt a cancel returns is already the new one; article 32's "one flush behind" only applied to mapping inside the transaction.

Placing an order step by step: the bearer token becomes ROLE_USER, validation runs before the controller, the transactional service locks and checks each product in id order, and the response is 201 with Location; the failure branches show a 401 from the resource server, a 422 from the advice before the service, and a 409 with a rollback from the stock check

Why placing an order locks the product rows

Article 30's reserveStock reads a product, lowers the stock in memory and lets dirty checking write it. Inside one transaction that is all or nothing, but two transactions can read the same stock at once, and PostgreSQL's default isolation, READ COMMITTED, lets both write. To measure it, @Lock was removed from findForUpdateById for this experiment, and twenty requests for one USB-C hub each were sent in parallel against a product with 5 in stock, three times, with the one-minute load average at 3.7:

Bash
seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":3,"quantity":1}]}' http://localhost:8142/api/orders | sort | uniq -c
Run without the lock201409stock afterwards
12002
21820
32000

Twenty orders for five hubs, and in the first run a stock of 2 left over as well: every lost update is a sold hub that was never subtracted. With @Lock(LockModeType.PESSIMISTIC_WRITE) back, the same burst on the Compose stack below, for a desk lamp (product 4, LMP-01) with 5 in stock:

Text
   5 201
  15 409
Text
  sku   | stock 
--------+-------
 LMP-01 |     0
(1 row)
 
 orders_for_lamp 
-----------------
               5
(1 row)

Five orders, five lines, stock 0. The SQL shows how. The same jar against PostgreSQL 18 with --logging.level.org.hibernate.SQL=debug, for alice's order of one mouse (product 2) and two keyboards (product 1), with the column lists of the SELECTs shortened:

Text
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
insert into orders (created_at,customer_id,status,updated_at) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
  • Hibernate 7.4.5 writes PESSIMISTIC_WRITE as for no key update of p1_0 on PostgreSQL: a row lock on the selected product only.
  • A concurrent request waits at its own select … for no key update until the first transaction ends, then reads the stock that transaction committed. That is why the burst stopped at exactly five.
  • The first UPDATE comes before the second lock. Hibernate flushes the keyboard's changed stock before it runs another query on products; the mouse's UPDATE waits for the commit.
  • Keyboard first, although the request listed the mouse first. place sorts the items by product id, so two orders that share products always lock them in the same order and cannot deadlock on each other.

The order that asks for five mice when two are left stops after the second lock:

Text
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0

The keyboard's UPDATE was sent and then rolled back with the transaction, and no INSERT was ever sent; the curl walk-through below reads the unchanged stock back.

cancel has one more detail. The lock and the entity graph are on the same repository method, and Hibernate did not put a locking clause on the fetch join: it ran the join without one and then locked the order row with a second statement:

Text
select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.id=?
select tbl.id,tbl.created_at,tbl.customer_id,tbl.status,tbl.updated_at from orders tbl where tbl.id in (?) for no key update of tbl

Ten parallel cancels of one order of two keyboards answered one 200 and nine 409, and the keyboard stock went from 8 back to exactly 10: the requests that waited for the lock saw the order as CANCELLED once they had it.

Where the ownership rule lives

"A user sees and cancels only their own orders" could be enforced in three places. A URL rule sees /api/orders/7 but not who owns order 7. A repository query scoped to the caller, findByIdAndCustomerUsername, would turn someone else's order into a 404 indistinguishable from a missing one, where article 36 answers 403. So the rule is method security on the service, as article 36's table recommends for rules about data, written once in OrderAccess and used by findById and cancel alike; @PostAuthorize would work for the read, but a cancel must be refused before it writes. Its orElse(true) lets an unknown id through to the method, so a missing order stays a 404 for everyone. The list endpoint is the one place where a scoped query is exactly right, because a list has no "someone else's" case: users get findByCustomerUsername, admins get findAll.

src/main/java/com/example/orders/order/OrderController.java
package com.example.orders.order;
 
import java.net.URI;
import java.util.List;
 
import com.example.orders.common.PageResponse;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders")
@SecurityRequirement(name = "bearer-jwt")
public class OrderController {
 
    private final OrderService service;
 
    public OrderController(OrderService service) {
        this.service = service;
    }
 
    @PostMapping
    public ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request,
                                               Authentication authentication) {
        List<OrderItem> items = request.lines().stream()
                .map(line -> new OrderItem(line.productId(), line.quantity()))
                .toList();
        Order order = service.place(authentication.getName(), items);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(order.getId())
                .toUri();
        return ResponseEntity.created(location).body(OrderResponse.from(order));
    }
 
    @GetMapping
    public PageResponse<OrderResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable,
                                               Authentication authentication) {
        Page<Order> page = isAdmin(authentication)
                ? service.findAll(pageable)
                : service.findByCustomer(authentication.getName(), pageable);
        return PageResponse.from(page.map(OrderResponse::from));
    }
 
    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        return OrderResponse.from(service.findById(id));
    }
 
    @PostMapping("/{id}/cancel")
    public OrderResponse cancel(@PathVariable Long id) {
        return OrderResponse.from(service.cancel(id));
    }
 
    @PostMapping("/{id}/ship")
    public OrderResponse ship(@PathVariable Long id) {
        return OrderResponse.from(service.ship(id));
    }
 
    private static boolean isAdmin(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
    }
}

The controller takes Authentication rather than article 35's @AuthenticationPrincipal Jwt, because the list needs the authorities as well as the name.

API documentation with springdoc and a bearer scheme

Article 22's FAQ left the Authorize button for a bearer token to the security chapter. It needs a security scheme in the document:

src/main/java/com/example/orders/common/OpenApiConfig.java
package com.example.orders.common;
 
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityScheme;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class OpenApiConfig {
 
    @Bean
    OpenAPI ordersOpenApi() {
        return new OpenAPI()
                .info(new Info()
                        .title("Orders API")
                        .version("1.0.0")
                        .description("Products, orders and JWT authentication for the Spring Boot Basics capstone."))
                .components(new Components()
                        .addSecuritySchemes("bearer-jwt", new SecurityScheme()
                                .type(SecurityScheme.Type.HTTP)
                                .scheme("bearer")
                                .bearerFormat("JWT")));
    }
}

@SecurityRequirement(name = "bearer-jwt") on OrderController, on ProductController.create and on AuthController.me attaches that scheme to the operations that need a token. The document at /v3/api-docs, trimmed to the scheme and the operation that places an order:

Bash
curl -s http://localhost:8142/v3/api-docs | jq '{securitySchemes: .components.securitySchemes, placeOrder: .paths["/api/orders"].post}'
JSON
{
  "securitySchemes": {
    "bearer-jwt": {
      "type": "http",
      "scheme": "bearer",
      "bearerFormat": "JWT"
    }
  },
  "placeOrder": {
    "tags": [
      "Orders"
    ],
    "operationId": "place",
    "requestBody": {
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/PlaceOrderRequest"
          }
        }
      },
      "required": true
    },
    "responses": {
      "200": {
        "description": "OK",
        "content": {
          "*/*": {
            "schema": {
              "$ref": "#/components/schemas/OrderResponse"
            }
          }
        }
      }
    },
    "security": [
      {
        "bearer-jwt": []
      }
    ]
  }
}

The Authentication parameter of place is not in the document, and neither is the @AuthenticationPrincipal Jwt of me: springdoc left both out. The 200 is article 22's limitation, since springdoc cannot see a status chosen in the method body; article 22's @ApiResponse annotations document the real 201 and the errors.

Pageable needs @ParameterObject

Without @ParameterObject, both list endpoints were documented with one required query parameter:

JSON
{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}

Swagger UI would build a form for a pageable object that the controller does not read. With the annotation, the same operation lists what Spring Data's resolver actually takes, including the defaults from @PageableDefault:

Bash
curl -s http://localhost:8142/v3/api-docs | jq -c '.paths["/api/orders"].get.parameters'
JSON
[{"name":"page","in":"query","description":"Zero-based page index (0..N)","required":false,"schema":{"type":"integer","default":0,"minimum":0}},{"name":"size","in":"query","description":"The size of the page to be returned","required":false,"schema":{"type":"integer","default":20,"minimum":1}},{"name":"sort","in":"query","description":"Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.","required":false,"schema":{"type":"array","default":["id,ASC"],"items":{"type":"string"}}}]

The paths Swagger UI needs through security

The chain permits /v3/api-docs/**, /swagger-ui/** and /swagger-ui.html. Headless Chrome opening http://localhost:8142/swagger-ui.html without a token followed the 302 to /swagger-ui/index.html and received 200 for every request the page made: index.html, swagger-ui.css, index.css, swagger-ui-standalone-preset.js, swagger-ui-bundle.js, swagger-initializer.js, favicon-32x32.png, /v3/api-docs/swagger-config and /v3/api-docs. One documentation URL is not covered: /v3/api-docs.yaml is not below /v3/api-docs/, and it answered the chain's 401. Add it to the rule if you publish the YAML form.

The Swagger UI flow with a token, as it ran against the Compose stack:

  1. The page lists the tags Products, Orders and Authentication; a lock icon marks POST /api/products, every order operation and GET /api/auth/me.
  2. Authorize opens a dialog headed bearer-jwt (http, Bearer) with one Value field. Paste the accessToken from POST /api/auth/login, without the word Bearer, and press Authorize; the dialog then shows Authorized and Value: ******. Close it.
  3. Expand POST /api/orders, press Try it out, replace the example body with {"lines":[{"productId":3,"quantity":1}]} and press Execute.

The Curl box then showed the header Swagger UI added, token shortened here:

Text
curl -X 'POST' \
  'http://localhost:8142/api/orders' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer eyJraWQiOiJ1cWU1bnBBb1ZC…' \
  -H 'Content-Type: application/json' \
  -d '{"lines":[{"productId":3,"quantity":1}]}'

Server response reported 201, marked Undocumented because the document only knows 200, with location: http://localhost:8142/api/orders/8 among the response headers. Try it out places real orders; article 22's production profile turns the documentation off.

Tests

Unit tests of the order rules

The service is built with new and its three repositories are Mockito mocks, in article 37's style. The products and orders are real objects, so decreaseStock, increaseStock, cancel and ship run for real:

src/test/java/com/example/orders/order/OrderServiceTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
 
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
 
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
 
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
 
    @Mock
    private OrderRepository orders;
 
    @Mock
    private ProductRepository products;
 
    @Mock
    private AppUserRepository users;
 
    private OrderService service;
    private AppUser alice;
    private Product keyboard;
    private Product mouse;
 
    @BeforeEach
    void setUp() {
        service = new OrderService(orders, products, users);
        alice = new AppUser("alice", "alice@example.com", "hash");
        keyboard = withId(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10), 1L);
        mouse = withId(new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1), 2L);
    }
 
    @Test
    void placeTakesStockForEveryLineAndSavesTheOrder() {
        given(users.findByUsername("alice")).willReturn(Optional.of(alice));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
        given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
        given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
 
        Order order = service.place("alice", List.of(new OrderItem(2L, 1), new OrderItem(1L, 2)));
 
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
        assertThat(order.getLines())
                .extracting(line -> line.getProduct().getSku(), OrderLine::getQuantity)
                .containsExactly(tuple("KB-01", 2), tuple("MS-01", 1));
        assertThat(order.total()).isEqualByComparingTo("204.30");
        assertThat(keyboard.getStock()).isEqualTo(8);
        assertThat(mouse.getStock()).isZero();
    }
 
    @Test
    void placeSavesNothingWhenALineHasTooLittleStock() {
        given(users.findByUsername("alice")).willReturn(Optional.of(alice));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
        given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
 
        assertThatThrownBy(() -> service.place("alice", List.of(new OrderItem(1L, 2), new OrderItem(2L, 3))))
                .isInstanceOf(InsufficientStockException.class)
                .hasMessage("Only 1 of MS-01 in stock, 3 requested");
 
        then(orders).should(never()).save(any());
    }
 
    @Test
    void cancelPutsTheStockBack() {
        Order order = placedOrder(7L, keyboard, 2);
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
 
        service.cancel(7L);
 
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
        assertThat(keyboard.getStock()).isEqualTo(12);
    }
 
    @Test
    void cancelRefusesAShippedOrderAndKeepsTheStock() {
        Order order = placedOrder(7L, keyboard, 2);
        order.ship();
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
 
        assertThatThrownBy(() -> service.cancel(7L))
                .isInstanceOf(OrderStatusException.class)
                .hasMessage("Order 7 is SHIPPED and cannot be cancelled");
 
        assertThat(keyboard.getStock()).isEqualTo(10);
        then(products).should(never()).findForUpdateById(any());
    }
 
    @Test
    void shipRefusesACancelledOrder() {
        Order order = placedOrder(7L, keyboard, 2);
        order.cancel();
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
 
        assertThatThrownBy(() -> service.ship(7L))
                .isInstanceOf(OrderStatusException.class)
                .hasMessage("Order 7 is CANCELLED and cannot be shipped");
    }
 
    private Order placedOrder(Long id, Product product, int quantity) {
        Order order = new Order(alice);
        order.addLine(new OrderLine(product, quantity));
        return withId(order, id);
    }
 
    private static <T> T withId(T entity, Long id) {
        ReflectionTestUtils.setField(entity, "id", id);
        return entity;
    }
}

placeTakesStockForEveryLineAndSavesTheOrder also pins down the lock order: the lines come back keyboard first, because place sorted the items. As article 37 noted, a unit test cannot prove the rollback; the stock checks in the Docker walk-through do.

@WebMvcTest of OrderController with security

src/test/java/com/example/orders/order/OrderControllerTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
 
import java.math.BigDecimal;
import java.util.List;
 
import com.example.orders.common.ProblemDetailSecurityHandler;
import com.example.orders.common.SecurityConfig;
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
 
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class OrderControllerTest {
 
    private static final String TWO_KEYBOARDS = """
            {"lines":[{"productId":1,"quantity":2}]}
            """;
 
    @Autowired
    MockMvcTester mvc;
 
    @MockitoBean
    OrderService orderService;
 
    @Test
    void placeWithoutATokenIs401() {
        assertThat(mvc.post().uri("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.UNAUTHORIZED)
                .hasHeader(HttpHeaders.WWW_AUTHENTICATE,
                        "Bearer realm=\"orders\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")
                .bodyJson()
                .extractingPath("$.detail").asString().isEqualTo("Valid credentials are required to access this resource.");
    }
 
    @Test
    void placeWithAnInvalidTokenIs401() {
        assertThat(mvc.post().uri("/api/orders")
                .header(HttpHeaders.AUTHORIZATION, "Bearer abc.def.ghi")
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
 
    @Test
    void placeAnswers201WithTheLocationOfTheOrder() {
        given(orderService.place(eq("alice"), anyList())).willReturn(order(42L));
 
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.CREATED)
                .hasHeader(HttpHeaders.LOCATION, "http://localhost/api/orders/42")
                .bodyJson()
                .isLenientlyEqualTo("""
                        {"id":42,"status":"PLACED","lines":[{"productId":1,"quantity":2}],"total":179.80}
                        """);
 
        then(orderService).should().place("alice", List.of(new OrderItem(1L, 2)));
    }
 
    @Test
    void invalidBodyIs422AndNeverReachesTheService() {
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"lines":[{"productId":1,"quantity":0}]}
                        """))
                .hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
                .bodyJson()
                .isLenientlyEqualTo("""
                        {"status":422,"errors":[{"field":"lines[0].quantity","message":"must be greater than 0"}]}
                        """);
 
        then(orderService).shouldHaveNoInteractions();
    }
 
    @Test
    void insufficientStockIs409() {
        given(orderService.place(eq("alice"), anyList()))
                .willThrow(new InsufficientStockException("KB-01", 1, 2));
 
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.CONFLICT)
                .bodyJson()
                .extractingPath("$.detail").asString().isEqualTo("Only 1 of KB-01 in stock, 2 requested");
    }
 
    @Test
    void methodSecurityDenialIs403NotA500() {
        given(orderService.findById(7L)).willThrow(new AuthorizationDeniedException("Access Denied"));
 
        assertThat(mvc.get().uri("/api/orders/7").with(user("bob")))
                .hasStatus(HttpStatus.FORBIDDEN)
                .bodyJson()
                .extractingPath("$.title").asString().isEqualTo("Forbidden");
    }
 
    @Test
    void shippingNeedsTheAdminRole() {
        assertThat(mvc.post().uri("/api/orders/7/ship").with(user("alice")))
                .hasStatus(HttpStatus.FORBIDDEN);
 
        then(orderService).shouldHaveNoInteractions();
    }
 
    private static JwtRequestPostProcessor user(String username) {
        return jwt().jwt(token -> token.subject(username))
                .authorities(new SimpleGrantedAuthority("ROLE_USER"));
    }
 
    private static Order order(Long id) {
        Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 8);
        ReflectionTestUtils.setField(keyboard, "id", 1L);
        Order order = new Order(new AppUser("alice", "alice@example.com", "hash"));
        order.addLine(new OrderLine(keyboard, 2));
        ReflectionTestUtils.setField(order, "id", id);
        return order;
    }
}
  • @Import({SecurityConfig.class, ProblemDetailSecurityHandler.class}) brings the real chain and nothing else, which is the split from the security section.
  • placeWithAnInvalidTokenIs401 sends a real Authorization header through the resource server; with AuthConfig imported as well, this is the test that died with StackOverflowError.
  • jwt().jwt(...).authorities(...) gives each request a JwtAuthenticationToken; as article 38 found, the post-processor does not apply authorities-claim-name, so the role is set directly.
  • methodSecurityDenialIs403NotA500 throws the exception a @PreAuthorize denial produces from the mocked service and proves that the advice's rethrow and the access denied handler turn it into a 403 ProblemDetail, without the method security proxy in the slice.
  • shippingNeedsTheAdminRole is decided by the URL rule before the controller runs, so the service is never called.

@DataJpaTest of the order queries

src/test/java/com/example/orders/order/OrderRepositoryTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import java.math.BigDecimal;
 
import com.example.orders.common.AuditingConfig;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
 
@DataJpaTest
@Import(AuditingConfig.class)
class OrderRepositoryTest {
 
    @Autowired
    TestEntityManager entityManager;
 
    @Autowired
    OrderRepository orders;
 
    private Long bobsOrderId;
 
    @BeforeEach
    void setUp() {
        AppUser alice = entityManager.persist(new AppUser("alice", "alice@example.com", "hash"));
        AppUser bob = entityManager.persist(new AppUser("bob", "bob@example.com", "hash"));
        Product keyboard = entityManager.persist(
                new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
        place(alice, keyboard, 1);
        bobsOrderId = place(bob, keyboard, 2).getId();
        place(alice, keyboard, 3);
        entityManager.flush();
        entityManager.clear();
    }
 
    @Test
    void findsTheUsernameOfTheCustomerWhoOwnsAnOrder() {
        assertThat(orders.findCustomerUsernameById(bobsOrderId)).contains("bob");
        assertThat(orders.findCustomerUsernameById(999L)).isEmpty();
    }
 
    @Test
    void pagesTheOrdersOfOneCustomerWithTheirLines() {
        Page<Order> page = orders.findByCustomerUsername("alice", PageRequest.of(0, 1, Sort.by("id")));
        entityManager.clear();
 
        assertThat(page.getTotalElements()).isEqualTo(2);
        assertThat(page.getTotalPages()).isEqualTo(2);
        assertThat(page.getContent()).singleElement()
                .satisfies(order -> assertThat(order.getLines())
                        .extracting(OrderLine::getQuantity)
                        .containsExactly(1));
    }
 
    private Order place(AppUser customer, Product product, int quantity) {
        Order order = new Order(customer);
        order.addLine(new OrderLine(product, quantity));
        return entityManager.persist(order);
    }
}

@Import(AuditingConfig.class) is not optional. @DataJpaTest scans only JPA components, AuditingConfig is a @Configuration, and without it nothing fills the audit columns. The same class without the import:

Text
org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "CREATED_AT"; SQL statement:
insert into products (created_at,name,price,sku,stock,updated_at,id) values (?,?,?,?,?,?,default) [23502-240]]

Flyway runs in the slice: the test log showed Successfully applied 3 migrations to schema "PUBLIC" against the embedded database before Hibernate started, so the queries are tested against the migrated schema. The SQL the paged query sent, with showSql on as @DataJpaTest sets it:

Text
Hibernate: select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from (select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.status,o1_0.updated_at from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=? order by o1_0.id offset ? rows fetch first ? rows only) o1_0(id,created_at,customer_id,status,updated_at) left join order_lines l1_0 on o1_0.id=l1_0.order_id order by o1_0.id
Hibernate: select count(*) from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=?

The page of one order is cut inside the derived table and the lines are joined onto it, and the count has no join to order_lines: the shapes article 29 measured for an entity graph on a derived query. The entityManager.clear() right after the query detaches the orders, so the assertion on the lines only passes if the query fetched them. With @EntityGraph removed from findByCustomerUsername, the same test failed with LazyInitializationException: Cannot lazily initialize collection of role 'com.example.orders.order.Order.lines' with key '4' (no session).

An end-to-end test on a random port

src/test/java/com/example/orders/order/OrderApiTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
 
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUserRepository;
import com.example.orders.user.LoginRequest;
import com.example.orders.user.RegisterRequest;
import com.example.orders.user.TokenResponse;
 
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
 
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderApiTest {
 
    @Autowired
    RestTestClient client;
 
    @Autowired
    OrderRepository orders;
 
    @Autowired
    ProductRepository products;
 
    @Autowired
    AppUserRepository users;
 
    private Product keyboard;
 
    @BeforeEach
    void createProduct() {
        keyboard = products.save(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
    }
 
    @AfterEach
    void deleteWhatTheServerCommitted() {
        orders.deleteAll();
        products.deleteAll();
        users.deleteAll();
    }
 
    @Test
    void customerRegistersPlacesAnOrderAndReadsItBack() {
        String carol = registerAndLogIn("carol");
 
        URI location = client.post().uri("/api/orders")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
                .contentType(MediaType.APPLICATION_JSON)
                .body(new PlaceOrderRequest(List.of(new PlaceOrderRequest.Line(keyboard.getId(), 3))))
                .exchange()
                .expectStatus().isCreated()
                .returnResult()
                .getResponseHeaders().getLocation();
 
        client.get().uri(location)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.status").isEqualTo("PLACED")
                .jsonPath("$.total").isEqualTo(269.70);
 
        assertThat(products.findById(keyboard.getId())).get()
                .extracting(Product::getStock).isEqualTo(7);
 
        String dave = registerAndLogIn("dave");
        client.get().uri(location)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + dave)
                .exchange()
                .expectStatus().isForbidden();
    }
 
    private String registerAndLogIn(String username) {
        String password = "Secret-" + username + "-2026";
        client.post().uri("/api/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new RegisterRequest(username, username + "@example.com", password))
                .exchange()
                .expectStatus().isCreated();
        return client.post().uri("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new LoginRequest(username, password))
                .exchange()
                .expectStatus().isOk()
                .expectBody(TokenResponse.class)
                .returnResult()
                .getResponseBody()
                .accessToken();
    }
}

Nothing is simulated: BCrypt checks carol's password, TokenService signs with secrets/private.pem, the decoder verifies with secrets/public.pem, the service locks the product row, and @PreAuthorize refuses dave. The server commits on its own threads, so the test deletes orders, products and users afterwards instead of relying on @Transactional, as article 38 showed.

Running the suite

Bash
./gradlew test
Text
OrdersApplicationTests > contextLoads() PASSED
 
OrderApiTest > customerRegistersPlacesAnOrderAndReadsItBack() PASSED
 
OrderControllerTest > methodSecurityDenialIs403NotA500() PASSED
 
OrderControllerTest > invalidBodyIs422AndNeverReachesTheService() PASSED
 
OrderControllerTest > placeWithAnInvalidTokenIs401() PASSED
 
OrderControllerTest > placeWithoutATokenIs401() PASSED
 
OrderControllerTest > shippingNeedsTheAdminRole() PASSED
 
OrderControllerTest > placeAnswers201WithTheLocationOfTheOrder() PASSED
 
OrderControllerTest > insufficientStockIs409() PASSED
 
OrderRepositoryTest > findsTheUsernameOfTheCustomerWhoOwnsAnOrder() PASSED
 
OrderRepositoryTest > pagesTheOrdersOfOneCustomerWithTheirLines() PASSED
 
OrderServiceTest > cancelPutsTheStockBack() PASSED
 
OrderServiceTest > shipRefusesACancelledOrder() PASSED
 
OrderServiceTest > placeTakesStockForEveryLineAndSavesTheOrder() PASSED
 
OrderServiceTest > cancelRefusesAShippedOrderAndKeepsTheStock() PASSED
 
OrderServiceTest > placeSavesNothingWhenALineHasTooLittleStock() PASSED
 
BUILD SUCCESSFUL in 5s

The times per class from the JUnit reports of the fastest of three runs with ./gradlew test --rerun, at a one-minute load average of 7.0; they are indicative:

Test classKindTestsTime
OrdersApplicationTests@SpringBootTest, the generated context test12.485 s
OrderApiTest@SpringBootTest(webEnvironment = RANDOM_PORT)11.141 s
OrderControllerTest@WebMvcTest with the security chain70.301 s
OrderRepositoryTest@DataJpaTest20.229 s
OrderServiceTestplain JUnit and Mockito50.131 s
Total164.287 s

The first class pays for the cold JVM and the first application context; the web slice and the JPA slice built their smaller contexts in a warm JVM.

Docker: the image, Compose and the JWT keys

The Dockerfile is article 41's final multi-stage version with the BuildKit cache mount, unchanged:

Dockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon > /dev/null
COPY src src
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemon
 
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY --from=build /workspace/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

It copies gradlew, the build files, gradle and src, and nothing else, so the keys in secrets never enter the build. .dockerignore is article 41's with the two local files added, so no future COPY can pick them up either:

.dockerignore
.git
.gradle
.idea
build/*
!build/libs/
secrets
.env

compose.yaml adapted from article 41

compose.yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "localhost", "-U", "shop", "-d", "shop"]
      interval: 2s
      timeout: 3s
      retries: 15
 
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: postgres
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_PASSWORD: secret
      SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATION: file:/run/secrets/jwt-public-key
      APP_JWT_PRIVATE_KEY_LOCATION: file:/run/secrets/jwt-private-key
      APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:?set APP_ADMIN_PASSWORD in .env}
    secrets: 
      - jwt-public-key
      - jwt-private-key
    ports:
      - "8141:8080"
      - "8142:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
 
secrets: 
  jwt-public-key: 
    file: ./secrets/public.pem
  jwt-private-key: 
    file: ./secrets/private.pem
 
volumes:
  db-data:
  • Two environment variables point the key properties at /run/secrets, where Compose mounts each entry of the service's secrets list under its name. Relaxed binding maps SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATION onto the property that both Boot's decoder and the @Value in AuthConfig read.
  • ${APP_ADMIN_PASSWORD:?...} makes Compose refuse to start without the variable, which it reads from a .env file next to compose.yaml; the runs below used the value shown, and .gitignore keeps the file out of Git.
  • The database password stays in the file, as in article 41: PostgreSQL publishes no port, while the admin password guards an API published on 8142.
.env
APP_ADMIN_PASSWORD=Admin-2026-secret

Without the file, up stopped before building anything:

Text
Error while interpolating services.app.environment.APP_ADMIN_PASSWORD: required variable APP_ADMIN_PASSWORD is missing a value: set APP_ADMIN_PASSWORD in .env

Providing the JWT keys with Compose secrets

The private key signs every token the API accepts, so it has to be available to the running container without being part of the image, where anyone who can pull the image could read it. Compose secrets with file: do that: the file stays on the host and is mounted read-only into the container at start. Inside the running application container:

Bash
docker exec sb-a42-app-1 id
docker exec sb-a42-app-1 ls -l /run/secrets/
docker exec sb-a42-app-1 sh -c 'mount | grep secrets'
Text
uid=999(spring) gid=999(spring) groups=999(spring)
total 8
-rw------- 1 spring spring 1708 Sep 16 09:05 jwt-private-key
-rw-r--r-- 1 spring spring  451 Sep 16 09:05 jwt-public-key
/run/host_mark/private on /run/secrets/jwt-public-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)
/run/host_mark/private on /run/secrets/jwt-private-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)

Both files are read-only mounts, and openssl genpkey's mode 600 survived. They appear owned by spring because Docker Desktop's file sharing presents host files with the owner of the process that reads them, the fakeowner mount type above; that is why the non-root user from article 41 could read a 600 file created by another user. On a Linux host a bind mount keeps the file's real numeric owner. The same private key copied into a Docker volume, where ownership is real, owned by uid 1000 with mode 600 and mounted read-only at /run/secrets in a container running as uid 999, could not be read:

Text
head: cannot open '/run/secrets/private.pem' for reading: Permission denied

On Linux, give the key to uid 999 or a group that user is in before starting the stack. Compose's long syntax has uid, gid and mode for secrets, but Compose 5.3.1 ignored them for file-based secrets and said so: secrets 'uid', 'gid' and 'mode' are not supported, they will be ignored. In production the same two files come from the platform's secret store, such as Kubernetes Secrets or a cloud secret manager, mounted as files or injected by the platform, never from the repository or the image.

Starting the stack

Bash
docker compose -p sb-a42 up --build -d

After the BuildKit steps, which took 55 seconds from an empty build cache at a load average of 3.3:

Text
 Image sb-a42-app Built 
 Network sb-a42_default Creating 
 Network sb-a42_default Created 
 Volume sb-a42_db-data Creating 
 Volume sb-a42_db-data Created 
 Container sb-a42-db-1 Creating 
 Container sb-a42-db-1 Created 
 Container sb-a42-app-1 Creating 
 Container sb-a42-app-1 Created 
 Container sb-a42-db-1 Starting 
 Container sb-a42-db-1 Started 
 Container sb-a42-db-1 Waiting 
 Container sb-a42-db-1 Healthy 
 Container sb-a42-app-1 Starting 
 Container sb-a42-app-1 Started 

A rebuild after changing one source file, the AuditingConfig above, took 9.7 seconds with docker compose -p sb-a42 down -v and the same up --build -d: every layer up to COPY src src came from the cache, and bootJar with the cache mount ran in 4.0 seconds.

Bash
docker compose -p sb-a42 ps
Text
NAME           IMAGE         COMMAND                  SERVICE   CREATED          STATUS                    PORTS
sb-a42-app-1   sb-a42-app    "java -jar /app/app.…"   app       13 seconds ago   Up 10 seconds (healthy)   0.0.0.0:8142->8080/tcp, [::]:8142->8080/tcp
sb-a42-db-1    postgres:18   "docker-entrypoint.s…"   db        13 seconds ago   Up 12 seconds (healthy)   5432/tcp
Bash
docker compose -p sb-a42 logs app

The lines that show the profile, the migrations and the administrator:

Text
app-1  | 2026-09-16T09:19:04.953Z  INFO 1 --- [orders] [           main] com.example.orders.OrdersApplication     : The following 1 profile is active: "postgres"
app-1  | 2026-09-16T09:19:05.975Z  INFO 1 --- [orders] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://db:5432/shop (PostgreSQL 18.6)
app-1  | 2026-09-16T09:19:06.055Z  INFO 1 --- [orders] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 3 migrations to schema "public", now at version v3 (execution time 00:00.006s)
app-1  | 2026-09-16T09:19:07.526Z  INFO 1 --- [orders] [           main] com.example.orders.OrdersApplication     : Started OrdersApplication in 2.732 seconds (process running for 2.981)
app-1  | 2026-09-16T09:19:07.676Z  INFO 1 --- [orders] [           main] c.e.orders.user.AdminAccountInitializer  : Created admin account 'admin'

Started OrdersApplication also means ddl-auto=validate accepted the migrated schema on PostgreSQL. docker images sb-a42-app reported 606MB of disk usage and 176MB of content, 10 MB more content than article 41's catalogue image; app.jar is 69,402,210 bytes here against 58,557,202 there, with Security, springdoc and Actuator inside.

The Compose stack: the app container running as spring with its curl health check on /actuator/health, published on port 8142, the db container with the sb-a42_db-data volume and its pg_isready check, both on the sb-a42_default network, and the two key files mounted read-only at /run/secrets

Running the API end to end with curl

The requests below ran in this order against the fresh stack, so ids follow from one another: the administrator has id 1, alice 2 and bob 3. Security headers such as X-Frame-Options and Cache-Control, and Date, Transfer-Encoding and Content-Length, are left out of the curl -i outputs.

Registering, logging in and creating products

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8142/api/auth/register
Http
HTTP/1.1 201 
Content-Type: application/json
 
{"id":2,"username":"alice","email":"alice@example.com","role":"USER"}

bob registered the same way, with the password Builder-2026-bob, and got id 3. Logging in:

Bash
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login
JSON
{"accessToken":"eyJraWQiOiJ1cWU1bnBBb1ZCYVhDYnBfVHZ0MVZYV3VuSzhSQ0I4QTFGUXVWMElPRjNNIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29yZGVycy5leGFtcGxlLmNvbSIs…","tokenType":"Bearer","expiresIn":900}

The shell keeps one token per user; the admin password is the one from .env:

Bash
ALICE=$(curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
BOB=$(curl -s -H 'Content-Type: application/json' -d '{"username":"bob","password":"Builder-2026-bob"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
ADMIN=$(curl -s -H 'Content-Type: application/json' -d '{"username":"admin","password":"Admin-2026-secret"}' http://localhost:8142/api/auth/login | jq -r .accessToken)

Decoded with article 35's b64url_decode, the payloads of alice's and the administrator's tokens were:

JSON
{"iss":"https://orders.example.com","sub":"alice","exp":1789551255,"iat":1789550355,"roles":["USER"]}
{"iss":"https://orders.example.com","sub":"admin","exp":1789551255,"iat":1789550355,"roles":["ADMIN"]}

The administrator creates a keyboard with 10 in stock, then a mouse with 3 and a USB-C hub with 5 the same way:

Bash
curl -s -i -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10}' http://localhost:8142/api/products
Http
HTTP/1.1 201 
Location: http://localhost:8142/api/products/1
Content-Type: application/json
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"}

Anyone can read the catalogue, a page at a time:

Bash
curl -s -i "http://localhost:8142/api/products?size=2&sort=price,desc"
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"content":[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"},{"id":3,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":5,"createdAt":"2026-09-16T09:19:15.346947Z","updatedAt":"2026-09-16T09:19:15.346947Z"}],"page":0,"size":2,"totalElements":3,"totalPages":2,"hasNext":true}

Placing an order and the answers when it fails

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":2,"quantity":1},{"productId":1,"quantity":2}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 201 
Location: http://localhost:8142/api/orders/1
Content-Type: application/json
 
{"id":1,"customerId":2,"status":"PLACED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.401168Z"}

The stock after it:

Bash
curl -s "http://localhost:8142/api/products?sort=id" | jq -c '[.content[] | {sku, stock}]'
JSON
[{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]

An order for one keyboard and five mice, with two mice left:

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":5}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 409 
Content-Type: application/problem+json
 
{"detail":"Only 2 of MS-01 in stock, 5 requested","instance":"/api/orders","status":409,"title":"Conflict"}

The same stock query returned [{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]: the keyboard line had been processed first, and the rollback left no trace of it. An invalid body:

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":0},{"quantity":2}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 422 
Content-Type: application/problem+json
 
{"detail":"Request has 2 invalid value(s).","instance":"/api/orders","status":422,"title":"Unprocessable Content","errors":[{"field":"lines[0].quantity","message":"must be greater than 0"},{"field":"lines[1].productId","message":"must not be null"}]}

No token:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 401 
WWW-Authenticate: Bearer realm="orders", resource_metadata="http://localhost:8142/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/orders","status":401,"title":"Unauthorized"}

Ownership, cancelling and shipping

bob asks for alice's order:

Bash
curl -s -i -H "Authorization: Bearer $BOB" http://localhost:8142/api/orders/1
Http
HTTP/1.1 403 
Content-Type: application/problem+json
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders/1","status":403,"title":"Forbidden"}

The same request with alice's token answered 200 with the order, and bob's GET /api/orders answered {"content":[],"page":0,"size":20,"totalElements":0,"totalPages":0,"hasNext":false}. alice cancels:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/1/cancel
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"id":1,"customerId":2,"status":"CANCELLED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.565838Z"}

The stock is back where it started, [{"sku":"KB-01","stock":10},{"sku":"MS-01","stock":3},{"sku":"HUB-07","stock":5}], and updatedAt is the time of the cancellation. alice then ordered one hub, order 2. She cannot ship it herself: POST /api/orders/2/ship with her token answered the URL rule's 403. The administrator can:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ADMIN" http://localhost:8142/api/orders/2/ship
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"id":2,"customerId":2,"status":"SHIPPED","lines":[{"productId":3,"quantity":1,"unitPrice":39.00,"lineTotal":39.00}],"total":39.00,"createdAt":"2026-09-16T09:19:15.590799Z","updatedAt":"2026-09-16T09:19:15.616806Z"}

And a shipped order can no longer be cancelled:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/2/cancel
Http
HTTP/1.1 409 
Content-Type: application/problem+json
 
{"detail":"Order 2 is SHIPPED and cannot be cancelled","instance":"/api/orders/2/cancel","status":409,"title":"Conflict"}

When you are done, docker compose -p sb-a42 down -v --rmi local removes the containers, the network, the volume and the image built for the stack.

Where to go next

The capstone stops where the Advanced course starts. Each of these builds directly on something above:

  • Queries at scale: N+1 detection and batch fetching, projections instead of entities for the lists, Specifications for filters, keyset pagination for deep pages.
  • Testing against the real database with Testcontainers and @ServiceConnection, instead of H2 and its surprises.
  • Caching product reads with Spring's cache abstraction.
  • Observability: Micrometer metrics, tracing and structured logs, and Actuator beyond health.
  • Tokens with a lifecycle: refresh tokens and an authorization server, which this API's 15-minute tokens deliberately leave out.
  • Delivery: layered JARs and buildpacks, Spring Boot's Docker Compose support for development, CI pipelines, GraalVM native images and Kubernetes.
  • Splitting the system: events, an outbox and microservices once orders and products outgrow one application.

File uploads for product images, order confirmation emails and scheduled jobs from article 40 would slot into the same feature packages without changing anything shown here.

FAQ

Should an order be created under the customer's URL or under /api/orders with JWT?

With a bearer token, POST /api/orders for the authenticated user. The token's subject already identifies the customer; a customer id in the URL can only repeat it or contradict it, and a contradiction can only be answered with 403. Keep the order's own URL in Location, here http://localhost:8142/api/orders/1, and let administrators reach other users' orders through the same URLs with a role check.

How do I stop concurrent orders from overselling stock in Spring Boot?

Lock the product rows inside the transaction before reading the stock. Without a lock, twenty parallel orders for five items got 20, 18 and 20 answers of 201 in three runs on PostgreSQL 18, and one run even ended with stock 2. With @Lock(LockModeType.PESSIMISTIC_WRITE) on the repository method, which Hibernate 7.4.5 sent as for no key update, exactly five succeeded and fifteen got 409. Lock several products in a fixed order, such as by id, so two orders cannot deadlock.

Why does my @WebMvcTest throw StackOverflowError for an invalid bearer token?

Because the imported configuration class also declares the AuthenticationManager bean from AuthenticationConfiguration.getAuthenticationManager(). In the slice there is no UserDetailsService, so that bean becomes a lazy proxy that calls itself when the resource server's ProviderManager asks its parent. Put the filter chain in one class and the authentication and token beans in another, and import only the chain: the same test then got its 401.

How do I give a Docker container a JWT private key without putting it in the image?

Keep the key out of src/main/resources and out of the build context, and mount it at runtime. With Compose secrets and file: ./secrets/private.pem, the key appeared read-only at /run/secrets/jwt-private-key, and APP_JWT_PRIVATE_KEY_LOCATION=file:/run/secrets/jwt-private-key pointed the application at it. On a Linux host the mounted file keeps its owner and mode, so make it readable by the container's user; in production, use the platform's secret store.

Why does Swagger UI show a pageable object instead of page, size and sort?

springdoc-openapi 3.1.1 documents a plain Pageable controller parameter as one required query parameter named pageable with an object schema. Annotate it with @ParameterObject and the operation lists page, size and sort, with the defaults from @PageableDefault.

Why does @DataJpaTest fail with NULL not allowed for column CREATED_AT?

@DataJpaTest does not load @Configuration classes, so @EnableJpaAuditing is missing and AuditingEntityListener fills nothing. Add @Import with the auditing configuration class to the test, as OrderRepositoryTest does.

Conclusion

The capstone is the course in one application. Feature packages hold controllers that map DTOs, services that own the rules and repositories that fetch what each endpoint needs; Flyway builds the schema that Hibernate validates; ProblemDetail answers every error with the status article 15 chose for it; a JWT identifies the caller, URL rules decide by path and @PreAuthorize decides by owner; springdoc documents it with a bearer scheme; tests cover the rules, the web layer with its security, the queries and the whole stack; and Compose runs it on PostgreSQL with the keys mounted at runtime.

The interesting parts were the seams. @Transactional alone kept an order all or nothing but let twenty concurrent orders sell five hubs, until the product rows were locked in id order. A web slice with the authentication beans imported crashed on an invalid token, and a JPA slice without the auditing configuration could not insert a row. Pageable needed @ParameterObject to be usable in Swagger UI, the container's clock needed truncating to what PostgreSQL stores, H2 needed its string checks dropped, and the private key needed a way into the container that is not the image.

That closes Spring Boot Basics: from the Java a Spring application needs, through beans, configuration, REST, data, security, testing and packaging, to a complete API. The Advanced course picks up from here, starting with the questions this API raises at scale.

Related Posts

[Spring Boot Basics] @ConfigurationProperties in Spring Boot: Type-Safe Configuration with Validation

Type-safe configuration in Spring Boot 4.1.1 with @ConfigurationProperties: binding to records without @ConstructorBinding, JavaBean binding and @DefaultValue, the three ways to register a properties class, nested objects, lists, maps, enums, Duration and DataSize conversion, relaxed binding and environment variable names, @Validated fail-fast startup errors, the configuration processor metadata, and a side-by-side comparison with @Value.

[Spring Boot Basics] @Transactional in Spring Boot: What a Transaction Is, Where to Put It and When It Rolls Back

@Transactional in Spring Boot 4.1.1 with PostgreSQL: the partial write an order leaves without a transaction, the JpaTransactionManager DEBUG log for begin, commit and rollback, the CGLIB proxy behind the injected bean, service method vs repository and class vs method level, rollback rules for unchecked and checked exceptions, rollbackFor, noRollbackFor and caught exceptions, what readOnly does to dirty checking and to PostgreSQL writes, self-invocation, private and protected methods, UnexpectedRollbackException from the rollback-only trap, jakarta.transaction.Transactional and TransactionTemplate.

[Spring Boot Basics] Constructor, Setter and Field Injection in Spring, with @Qualifier and @Primary

The three Spring injection points compared by when the container writes the value: why @Autowired is optional on a single constructor, why final fields and plain JUnit 5 tests are only possible with constructor injection, @Autowired(required=false) versus Optional and ObjectProvider, the NoUniqueBeanDefinitionException fixed four ways with @Primary, @Qualifier, parameter names and a custom qualifier annotation, List and Map injection with @Order, and why a constructor cycle fails at startup while a field cycle does not.

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.