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.
![]()
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 project | What it uses | Taught in |
|---|---|---|
| Endpoints, status codes, the cancel action URL | the worked design table, 400 vs 422, 409 for state | article 15 |
| Request DTOs and validation | records with Bean Validation, @Valid | articles 18 and 19 |
| Error responses | ProblemDetail, ResponseEntityExceptionHandler, 422 field lists, catch-all | article 20 |
| Package layout and layers | package by feature, controller maps DTOs, service holds rules | article 21 |
| API documentation | springdoc-openapi 3.1.1, Swagger UI | article 22 |
| Entities and repositories | JpaRepository, derived queries, @Query | articles 26 and 27 |
| Orders and lines | @ManyToOne(fetch = LAZY), mappedBy, cascade, orphanRemoval, @EntityGraph | article 28 |
| Paged lists | Pageable, @PageableDefault, PageResponse, max-page-size | article 29 |
| All-or-nothing orders | @Transactional on the use case | article 30 |
| Schema | Flyway migrations, ddl-auto=validate on PostgreSQL | article 31 |
| Audit timestamps | AuditableEntity, @EnableJpaAuditing, DateTimeProvider | article 32 |
| Accounts and passwords | users table, BCrypt, UserDetailsService, registration | article 34 |
| Tokens | RSA key pair, NimbusJwtEncoder, resource server, roles to ROLE_ | article 35 |
| Authorization | URL rules, @EnableMethodSecurity, @PreAuthorize, the AccessDeniedException rethrow | article 36 |
| Unit tests | JUnit 6, AssertJ, Mockito | article 37 |
| Slice and end-to-end tests | @WebMvcTest, @DataJpaTest, RANDOM_PORT with RestTestClient | article 38 |
| Health check | Actuator /actuator/health | article 39 |
| Container | multi-stage Dockerfile, Compose with PostgreSQL and health checks | article 41 |
| Configuration | profiles, environment variables | articles 11 and 13 |

Generating the project
The Spring Initializr ids are the ones earlier articles used, plus springdoc-openapi and actuator:
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.zipunzip orders.zip -d ordersThe 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
-javaagentinstead of self-attaching. testLoggingwithpassed,skippedandfailed, so the console lists every test.- The plain
jartask disabled, which theCOPY build/libs/*.jarin article 41'sDockerfilerelies on.
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:
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.pemprintf '\n### Local secrets ###\nsecrets/\n.env\n' >> .gitignoreThe 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
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.javaOrdersApplication 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.
| Method | Path | Who | Success | Errors |
|---|---|---|---|---|
POST | /api/auth/register | anyone | 201 | 400, 409 username or email taken, 422 |
POST | /api/auth/login | anyone | 200 with the token | 400, 401 bad credentials, 422 |
GET | /api/auth/me | authenticated | 200 | 401 |
GET | /api/products | anyone | 200, a page; ?page=, ?size=, ?sort= | 400 unknown sort property |
GET | /api/products/{id} | anyone | 200 | 400, 404 |
POST | /api/products | ADMIN | 201 + Location | 400, 401, 403, 409 duplicate SKU, 415, 422 |
POST | /api/orders | authenticated | 201 + Location: /api/orders/{orderId} | 400, 401, 404 unknown product, 409 not enough stock, 415, 422 |
GET | /api/orders | authenticated: own orders; ADMIN: all | 200, a page | 400, 401 |
GET | /api/orders/{id} | the owner or ADMIN | 200 | 400, 401, 403 someone else's order, 404 |
POST | /api/orders/{id}/cancel | the owner or ADMIN | 200 | 400, 401, 403, 404, 409 not PLACED |
POST | /api/orders/{id}/ship | ADMIN | 200 | 400, 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
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.comopen-in-view=falseas in every article since 26, so each endpoint fetches what it maps.validate-migration-naming=truefrom article 31, so a misnamed migration stops startup instead of being skipped.max-page-size=100from article 29.- The key locations use
file:instead of article 35'sclasspath:, for the reason given above. app.jwt.issuerreplaces the constant in article 35'sTokenService; 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:
spring.datasource.url=jdbc:postgresql://localhost:5442/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validateThree 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:
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)
);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)
);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:
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:
package com.example.orders.common;
public abstract class NotFoundException extends RuntimeException {
protected NotFoundException(String message) {
super(message);
}
}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:
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:
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:
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:
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/healthfor 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
ProblemDetailSecurityHandleras 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:
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:
SingleSecurityConfigControllerTest > placeWithAnInvalidTokenIs401() FAILED
java.lang.StackOverflowError at ReflectiveOperationException.java:90java.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:
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:
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:
| Class | As in |
|---|---|
Role | article 34: USER, ADMIN |
AppUserRepository | article 34: findByUsername, existsByUsername, existsByEmail |
JpaUserDetailsService | article 34, including updatePassword |
RegisterRequest | article 34, with the @AssertTrue 72-byte check |
LoginRequest, UserResponse | article 34 |
UserService | article 34: register and findByUsername |
TokenResponse | article 35 |
AppUser gains a constructor that takes the role, for the administrator:
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:
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:
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:
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:
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:
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;
}
}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.
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);
}
}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);
}
}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());
}
}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:
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");
}
}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");
}
}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:
package com.example.orders.order;
public enum OrderStatus {
PLACED, SHIPPED, CANCELLED
}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;
}
}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:
package com.example.orders.order;
public record OrderItem(Long productId, int quantity) {
}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) {
}
}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.
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);
}findWithLinesByIdand the pagedfindByCustomerUsernameandfindAllfetch the lines with an entity graph, as articles 28 and 29 did.findForUpdateByIdlocks the order row forcancelandship, with the lines.findCustomerUsernameByIdanswers one question for the ownership rule: who owns this order.
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
}
}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");
}
}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
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;
}
}placeruns in one transaction (article 30). It loads the customer named by the token, then for each item locks the product row, letsProduct.decreaseStockapply the rule and adds a line with the current price. If any line is short,InsufficientStockExceptionrolls 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).cancellocks the order, letsOrder.cancelrefuse anything that is notPLACED, and puts each line's quantity back on its product, again under a row lock.shiplocks the order and letsOrder.shipapply the same state rule.- Reads inherit
readOnly = truefrom 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.

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:
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 lock | 201 | 409 | stock afterwards |
|---|---|---|---|
| 1 | 20 | 0 | 2 |
| 2 | 18 | 2 | 0 |
| 3 | 20 | 0 | 0 |
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:
5 201
15 409 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:
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_WRITEasfor no key update of p1_0on PostgreSQL: a row lock on the selected product only. - A concurrent request waits at its own
select … for no key updateuntil 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.
placesorts 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:
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_0The 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:
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 tblTen 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.
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:
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:
curl -s http://localhost:8142/v3/api-docs | jq '{securitySchemes: .components.securitySchemes, placeOrder: .paths["/api/orders"].post}'{
"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:
{"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:
curl -s http://localhost:8142/v3/api-docs | jq -c '.paths["/api/orders"].get.parameters'[{"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:
- The page lists the tags
Products,OrdersandAuthentication; a lock icon marksPOST /api/products, every order operation andGET /api/auth/me. - Authorize opens a dialog headed
bearer-jwt (http, Bearer)with one Value field. Paste theaccessTokenfromPOST /api/auth/login, without the wordBearer, and press Authorize; the dialog then showsAuthorizedandValue: ******. Close it. - 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:
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:
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
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.placeWithAnInvalidTokenIs401sends a realAuthorizationheader through the resource server; withAuthConfigimported as well, this is the test that died withStackOverflowError.jwt().jwt(...).authorities(...)gives each request aJwtAuthenticationToken; as article 38 found, the post-processor does not applyauthorities-claim-name, so the role is set directly.methodSecurityDenialIs403NotA500throws the exception a@PreAuthorizedenial produces from the mocked service and proves that the advice's rethrow and the access denied handler turn it into a 403ProblemDetail, without the method security proxy in the slice.shippingNeedsTheAdminRoleis decided by the URL rule before the controller runs, so the service is never called.
@DataJpaTest of the order queries
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:
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:
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
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
./gradlew testOrdersApplicationTests > 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 5sThe 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 class | Kind | Tests | Time |
|---|---|---|---|
OrdersApplicationTests | @SpringBootTest, the generated context test | 1 | 2.485 s |
OrderApiTest | @SpringBootTest(webEnvironment = RANDOM_PORT) | 1 | 1.141 s |
OrderControllerTest | @WebMvcTest with the security chain | 7 | 0.301 s |
OrderRepositoryTest | @DataJpaTest | 2 | 0.229 s |
OrderServiceTest | plain JUnit and Mockito | 5 | 0.131 s |
| Total | 16 | 4.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:
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:
.git
.gradle
.idea
build/*
!build/libs/
secrets
.envcompose.yaml adapted from article 41
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'ssecretslist under its name. Relaxed binding mapsSPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATIONonto the property that both Boot's decoder and the@ValueinAuthConfigread. ${APP_ADMIN_PASSWORD:?...}makes Compose refuse to start without the variable, which it reads from a.envfile next tocompose.yaml; the runs below used the value shown, and.gitignorekeeps 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.
APP_ADMIN_PASSWORD=Admin-2026-secretWithout the file, up stopped before building anything:
Error while interpolating services.app.environment.APP_ADMIN_PASSWORD: required variable APP_ADMIN_PASSWORD is missing a value: set APP_ADMIN_PASSWORD in .envProviding 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:
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'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:
head: cannot open '/run/secrets/private.pem' for reading: Permission deniedOn 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
docker compose -p sb-a42 up --build -dAfter the BuildKit steps, which took 55 seconds from an empty build cache at a load average of 3.3:
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.
docker compose -p sb-a42 psNAME 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/tcpdocker compose -p sb-a42 logs appThe lines that show the profile, the migrations and the administrator:
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.

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
curl -s -i -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8142/api/auth/registerHTTP/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:
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login{"accessToken":"eyJraWQiOiJ1cWU1bnBBb1ZCYVhDYnBfVHZ0MVZYV3VuSzhSQ0I4QTFGUXVWMElPRjNNIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29yZGVycy5leGFtcGxlLmNvbSIs…","tokenType":"Bearer","expiresIn":900}The shell keeps one token per user; the admin password is the one from .env:
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:
{"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:
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/productsHTTP/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:
curl -s -i "http://localhost:8142/api/products?size=2&sort=price,desc"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
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/ordersHTTP/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:
curl -s "http://localhost:8142/api/products?sort=id" | jq -c '[.content[] | {sku, stock}]'[{"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:
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/ordersHTTP/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:
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":0},{"quantity":2}]}' http://localhost:8142/api/ordersHTTP/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:
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1}]}' http://localhost:8142/api/ordersHTTP/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:
curl -s -i -H "Authorization: Bearer $BOB" http://localhost:8142/api/orders/1HTTP/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:
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/1/cancelHTTP/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:
curl -s -i -X POST -H "Authorization: Bearer $ADMIN" http://localhost:8142/api/orders/2/shipHTTP/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:
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/2/cancelHTTP/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.