The previous lesson's CLI solved automation and technical people. It does not let Marta Ruiz browse the catalogue from her browser, nor Nexus Software's future mobile app create loans, nor the HR system tell BiblioTech when somebody leaves the company.

For that you need a web API: an inbound adapter that speaks HTTP, which any client —a browser, a phone, another service, a curl in a script— can consume. It is the bibliotech-web module, and it will be the second adapter plugged into exactly the same use cases from the bibliotech-application module. Not one new line of business logic.

Before we start, it is worth saying something that changes how you read this lesson: you already know how this works. In module 9 you built CatalogServer: a ServerSocket that accepted connections, read bytes, parsed a text protocol, decided what to do, composed a response and wrote it out. A web server is exactly that, with the protocol already written by other people. Spring MVC is not magic: it is your socket server with thirty years of edge cases solved.

By the end of this lesson you will understand the request-response cycle and each piece's role, you will design a REST API with the right resources, verbs and status codes, you will write controllers with validation and global error handling in a standard format, you will paginate and filter, you will document the API automatically, and you will test the web layer at the two levels that make sense.

Two things that are not here: security (authentication, authorisation, JWT) is lesson 12-07, and deployment is 12-06. Here we build the API; we will protect it and put it into production later.

Contents

  1. How a Java web application works
  2. The embedded server and the DispatcherServlet
  3. The internal flow of a request
  4. From the socket-based CatalogServer to Spring MVC
  5. REST: resources and representations
  6. HTTP verbs and their semantics
  7. URI design
  8. Status codes per operation
  9. @RestController: mapping requests
  10. Parameters: path, query and body
  11. ResponseEntity and when to use it
  12. Inbound and outbound DTOs
  13. Validation with jakarta.validation
  14. A custom validator for the ISBN
  15. Global error handling and Problem Details
  16. Pagination and sorting
  17. Filters and search
  18. BiblioTech's complete API
  19. Automatic documentation with OpenAPI
  20. CORS
  21. The service layer and transactions
  22. Testing the web layer
  23. User interface: Thymeleaf or a separate front end
  24. Virtual threads in Spring Boot 3.2
  25. Common Mistakes and Tips
  26. Exercises
  27. Conclusion

  1. How a Java web application works

At its core, everything boils down to this: a client opens a TCP connection, sends text in an agreed format, the server interprets it, does something and returns more text. The agreed format is HTTP.

A raw HTTP request, exactly as it travels over the socket:

GET /api/materials?type=BOOK&page=0&size=20 HTTP/1.1
Host: bibliotech.nexussoftware.com
Accept: application/json
User-Agent: curl/8.5.0

And the response:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 284

{"content":[{"isbn":"978-0000000001","title":"Effective Java","type":"BOOK"}],
 "page":{"size":20,"number":0,"totalElements":3,"totalPages":1}}

Four elements in the request (method, path, version, headers, and an optional body) and four in the response (version, status code, headers, body). Nothing else. All Spring MVC does is spare you from handling that text by hand.

The full stack of a Spring Boot application:

Layer What it does Who implements it
TCP socket Accepts connections, moves bytes The operating system + the JVM
HTTP server Parses HTTP, manages connections and threads Tomcat (embedded)
Servlet API The standard request and response abstraction jakarta.servlet
DispatcherServlet Routes to your code, converts types Spring MVC
Your controllers Application logic You

  1. The embedded server and the DispatcherServlet

An embedded server. Until Spring Boot, deploying a Java web application meant: build a .war, install a Tomcat, copy the war into webapps/, restart. Spring Boot inverted the model: the server goes inside the application, and the result is a jar you run with java -jar.

@SpringBootApplication
public class BiblioTechApplication {
    public static void main(String[] args) {
        SpringApplication.run(BiblioTechApplication.class, args);
    }
}

Those three lines start a Tomcat on port 8080. The advantages: a single deployable unit, the same server version in development and production, configuration in the same place as everything else, and a perfect fit with containers (12-06).

The alternatives, all interchangeable by swapping one dependency:

Server Model When
Tomcat One thread per request The default; the best known
Jetty One thread per request Lower footprint; embedded in tooling
Undertow Non-blocking on demand Maximum throughput with many connections
Netty Reactive Only with Spring WebFlux

The DispatcherServlet is the front controller: a single servlet mapped to / through which every request passes, and which is responsible for deciding who serves it. It is the Facade pattern and the Command pattern (12-02) applied to the web.

  1. The internal flow of a request

This is the full journey of GET /api/materials/978-0000000001:

sequenceDiagram
    autonumber
    participant N as Browser
    participant T as Tomcat
    participant F as Filter chain
    participant D as DispatcherServlet
    participant HM as HandlerMapping
    participant HA as HandlerAdapter
    participant C as CatalogController
    participant S as CatalogService
    participant R as JPA repository
    participant MC as HttpMessageConverter

    N->>T: GET /api/materials/978-0000000001
    T->>T: parses HTTP, takes a thread from the pool
    T->>F: HttpServletRequest
    F->>F: filters (CORS, MDC, security in 12-07)
    F->>D: request
    D->>HM: who serves this route?
    HM-->>D: CatalogController.byIsbn
    D->>HA: invokes the method
    HA->>HA: converts the PathVariable to Isbn
    HA->>C: byIsbn(Isbn)
    C->>S: findByIsbn(isbn)
    S->>R: findByIsbn(isbn)
    R-->>S: Optional~Material~
    S-->>C: Material
    C-->>HA: MaterialResponse (DTO)
    HA->>MC: serialises to JSON (Jackson)
    MC-->>D: bytes
    D-->>T: HttpServletResponse 200
    T-->>N: HTTP/1.1 200 OK + JSON

The Spring MVC pieces involved:

Component Responsibility
Filter Cross-cutting work before and after: CORS, correlation (MDC), security
DispatcherServlet Orchestrates the whole process
HandlerMapping Finds the method that serves the URL and the verb
HandlerAdapter Invokes the method, resolving its arguments
HandlerMethodArgumentResolver Converts @PathVariable, @RequestParam, @RequestBody
HttpMessageConverter Serialises and deserialises the body (Jackson for JSON)
HandlerExceptionResolver Turns exceptions into HTTP responses

  1. From the socket-based CatalogServer to Spring MVC

It is worth putting the two side by side, because this is the same thing, which you solved yourself three modules ago.

Your server from module 9:

// CatalogServer, module 9: you wrote it, all of it
public void serve(Socket client) throws IOException {
    try (var in = new BufferedReader(new InputStreamReader(client.getInputStream(), UTF_8));
         var out = new PrintWriter(client.getOutputStream(), true)) {

        String request = in.readLine();                // "FIND 978-0000000001"
        String[] parts = request.split(" ", 2);        // protocol parsing

        switch (parts[0]) {                            // routing
            case "FIND" -> {
                Optional<Material> m = catalog.byIsbn(parts[1]);
                if (m.isPresent()) {
                    out.println("OK " + serialise(m.get()));        // serialisation
                } else {
                    out.println("ERROR 404 Not found");             // error code
                }
            }
            case "LIST" -> out.println("OK " + serialise(catalog.all()));
            default -> out.println("ERROR 400 Unknown command");
        }
    }
}

The same thing with Spring MVC:

@RestController
@RequestMapping("/api/materials")
public class CatalogController {

    private final QueryCatalog catalog;

    @GetMapping("/{isbn}")
    public MaterialResponse byIsbn(@PathVariable Isbn isbn) {
        return catalog.byIsbn(isbn)
                .map(MaterialResponse::from)
                .orElseThrow(() -> new MaterialNotFoundException(isbn));
    }
}

The correspondence, piece by piece:

What you did by hand in module 9 Who does it now
ServerSocket.accept() in a loop Tomcat
One thread per client (ExecutorService) Tomcat's thread pool
readLine() and split(" ") Tomcat's HTTP parser
The switch over the command HandlerMapping with @GetMapping
Integer.parseInt(parts[1]) HandlerMethodArgumentResolver
serialise(...) by hand Jackson via HttpMessageConverter
"ERROR 404 ..." @RestControllerAdvice and HTTP codes
Closing the socket in finally Tomcat
Timeouts and half-open connections Tomcat
Encoding, Content-Length, keep-alive Tomcat

The conclusion that matters: you are not learning a magic framework. You are delegating to battle-tested code exactly the work you already know how to do, so that you can spend your attention on what only you can write: BiblioTech's rules. And because you know what is underneath, when something fails —a request that hangs, a strange encoding, a Content-Type that does not add up— you will know where to look.

  1. REST: resources and representations

REST (Representational State Transfer) is an architectural style defined by Roy Fielding in 2000. Its core ideas, applied to BiblioTech:

Everything is a resource, identified by a URI. A resource is a noun, not an action:

Correct Incorrect
/api/materials /api/getMaterials
/api/materials/978-0000000001 /api/getMaterialByIsbn?isbn=…
/api/loans/42 /api/viewLoan/42

A resource is not its representation. Loan 42 is a concept; its representation can be JSON, XML or HTML depending on what the client asks for in Accept.

Stateless. Every request carries everything needed to serve it. The server does not remember "which step" a client is on. This property is what allows horizontal scaling (12-06): any instance can serve any request.

A uniform interface. The same verbs with the same semantics for every resource. Whoever knows how to use /api/materials knows how to use /api/loans.

Note: Richardson's maturity model. Leonard Richardson classified APIs into four levels. Level 0: a single endpoint that receives everything (RPC over HTTP). Level 1: resources with their own URIs, but a single verb. Level 2: resources + HTTP verbs + correct status codes. Level 3: on top of that, HATEOAS — responses include links to the possible actions. The vast majority of APIs in the industry sit at level 2, and that is a perfectly reasonable goal: level 3 adds discoverability, but also complexity that few clients exploit. BiblioTech will be level 2, with a nod to HATEOAS where it helps.

  1. HTTP verbs and their semantics

Two properties govern the correct use of the verbs:

  • Safe: it does not modify the server's state. A crawler can invoke it freely.
  • Idempotent: running it N times has the same effect as running it once. It is what makes retrying safe.
Verb Safe Idempotent Use In BiblioTech
GET Yes Yes Read Query the catalogue, loans
POST No No Create, or non-idempotent actions Create a loan
PUT No Yes Full replacement Update all of a material's data
PATCH No Not necessarily Partial modification Change only the copies
DELETE No Yes Delete Cancel a reservation
HEAD Yes Yes Like GET, without a body Check existence
OPTIONS Yes Yes Allowed verbs The CORS preflight request

Why idempotency really matters. A mobile client sends POST /api/loans, the response is lost to a network glitch, and the client retries. With a non-idempotent POST, two loans are created. With PUT, they are not.

Ways to make a creation idempotent:

POST /api/loans HTTP/1.1
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
Content-Type: application/json

{"isbn":"978-0000000001","employeeId":1,"days":15}

The server stores the key alongside its response; if it arrives again, it returns the original response without creating anything. It is what payment gateways do, and for good reasons.

The case of DELETE and idempotency. Deleting the same resource twice: the first returns 204, the second 404. Is it still idempotent? Yes: the server's state is the same. Idempotency is about the effect, not the response code.

  1. URI design

The rules, with BiblioTech examples:

Rule Good Bad
Plural nouns /api/materials /api/material, /api/getMaterials
Lower case and hyphens /api/material-types /api/materialTypes, /api/Material_Types
Hierarchy for relationships /api/employees/1/loans /api/loansOfEmployee?id=1
No extension /api/materials/978-… /api/materials/978-….json
No verb in the path POST /api/loans POST /api/createLoan
Filters in the query string /api/materials?type=BOOK /api/materials/type/BOOK
Explicit version /api/v1/materials (no version; picked up again in 12-07)
No trailing slash /api/materials /api/materials/

The hard case: actions that are not CRUD. How do you express "return a loan" in REST? Three options:

# Option A: a subresource that represents the event. The preferred one.
POST /api/loans/42/return

# Option B: PATCH on the status
PATCH /api/loans/42
{"status": "RETURNED"}

# Option C: a verb in the path. Pragmatic, and acceptable used sparingly.
POST /api/loans/42/returnLoan

BiblioTech uses A: POST /api/loans/42/return creates the "return" event inside loan 42. It is conceptually clean and lets the return carry its own data (date, notes, condition of the material).

Nesting: two levels at most. /api/employees/1/loans is fine; /api/sites/2/departments/5/employees/1/loans/42/fines is unmanageable. Beyond that, a top-level resource with filters: /api/fines?employee=1.

  1. Status codes per operation

Using the right codes is not purism: it is what lets a client react without parsing messages.

Code Name When, in BiblioTech
200 OK GET with a result; a PUT/PATCH that returns the resource
201 Created A POST that creates. With a Location header
202 Accepted Accepted for asynchronous processing (bulk import)
204 No Content A successful DELETE; a PUT with no response body
400 Bad Request Malformed JSON, wrong type, failed validation
401 Unauthorized No credentials, or invalid ones (12-07)
403 Forbidden Authenticated, but without permission (12-07)
404 Not Found The resource does not exist
405 Method Not Allowed DELETE on a resource that does not support it
409 Conflict Business rule violated: already has 3 loans; version conflict (@Version)
410 Gone It existed and was permanently removed
415 Unsupported Media Type A Content-Type that cannot be read
422 Unprocessable Entity Correct syntax, invalid semantics
429 Too Many Requests Rate limit exceeded (12-07)
500 Internal Server Error An unforeseen error. Never because of user input
503 Service Unavailable A dependency is down; with Retry-After

Classic mistakes worth not making:

Mistake Why it is wrong
Returning 200 with {"error": "..."} The client has to parse the body to know whether it worked
Returning 500 because a field is missing A 500 means "I failed"; a missing field is a 400
Returning 404 when a list has no results An empty list is a valid result: 200 with []
Returning 401 instead of 403 401 = "I do not know who you are"; 403 = "I know who you are and you may not"
Returning 200 after a POST that creates It must be 201 with Location

  1. @RestController: mapping requests

package com.nexussoftware.bibliotech.web.catalog;

@RestController                              // = @Controller + @ResponseBody
@RequestMapping("/api/materials")            // prefix shared by every method
public class CatalogController {

    private final QueryCatalog catalog;
    private final ManageCatalog management;

    public CatalogController(QueryCatalog catalog, ManageCatalog management) {
        this.catalog = catalog;
        this.management = management;
    }

    @GetMapping
    public PageResponse<MaterialResponse> list(@Valid MaterialCriteriaRequest criteria,
                                               Pageable pageable) { … }

    @GetMapping("/{isbn}")
    public MaterialResponse byIsbn(@PathVariable Isbn isbn) { … }

    @PostMapping
    public ResponseEntity<MaterialResponse> create(@Valid @RequestBody CreateMaterialRequest request) { … }

    @PutMapping("/{isbn}")
    public MaterialResponse replace(@PathVariable Isbn isbn,
                                    @Valid @RequestBody UpdateMaterialRequest request) { … }

    @PatchMapping("/{isbn}/copies")
    public MaterialResponse adjustCopies(@PathVariable Isbn isbn,
                                         @Valid @RequestBody AdjustCopiesRequest request) { … }

    @DeleteMapping("/{isbn}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Isbn isbn) { … }
}

@RestController is equivalent to @Controller + @ResponseBody on every method: the returned value is the response body, serialised by Jackson. Without it, Spring would interpret a returned String as the name of a view.

Useful restrictions in the mapping:

@GetMapping(value = "/{isbn}", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@GetMapping(value = "/{isbn}", produces = "text/csv")          // content negotiation
@GetMapping(params = "format=summary")                         // based on a parameter
@GetMapping(headers = "X-Api-Version=2")                       // based on a header

  1. Parameters: path, query and body

@GetMapping("/{isbn}/loans")
public List<LoanResponse> history(

        // Part of the path: identifies the resource
        @PathVariable Isbn isbn,

        // A required query parameter
        @RequestParam LoanStatus status,

        // Optional with a default value
        @RequestParam(defaultValue = "10") int limit,

        // Genuinely optional: Optional (10-04)
        @RequestParam Optional<LocalDate> from,

        // A name different from the Java parameter's
        @RequestParam(name = "sort_by", defaultValue = "DATE") SortCriterion sort,

        // Repeatable: ?tag=java&tag=design
        @RequestParam(required = false) List<String> tag,

        // A header
        @RequestHeader(value = "Accept-Language", defaultValue = "en") Locale language) { … }

And for the body:

@PostMapping
public ResponseEntity<LoanResponse> create(@Valid @RequestBody CreateLoanRequest request) { … }

Converting your own types. Making @PathVariable Isbn isbn work means telling Spring about it, and it is exactly Picocli's ITypeConverter behind a different interface:

@Component
public class StringToIsbnConverter implements Converter<String, Isbn> {

    @Override
    public Isbn convert(String text) {
        try {
            return Isbn.of(text);
        } catch (InvalidIsbnException e) {
            // IllegalArgumentException → Spring translates it into a 400, not a 500
            throw new IllegalArgumentException("Invalid ISBN: " + text, e);
        }
    }
}

The gain is the same as in the CLI: the controller works with the domain type, and invalid input is rejected before it reaches your code.

One detail about parameter names: since Java 21 it is best to compile with -parameters (Spring Boot configures it by default) so that Spring can work out the names. Without it, a @PathVariable with no explicit name fails at runtime.

  1. ResponseEntity and when to use it

Returning the DTO directly is the cleanest option, and it is what you should do by default:

@GetMapping("/{isbn}")
public MaterialResponse byIsbn(@PathVariable Isbn isbn) { … }     // 200 + JSON

ResponseEntity gives full control over status and headers, and it is justified in three cases:

Case 1: creation with Location (mandatory for a properly done 201).

@PostMapping
public ResponseEntity<LoanResponse> create(@Valid @RequestBody CreateLoanRequest request) {
    Loan created = manager.lend(request.isbn(), request.employeeId(), request.days());

    URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()          // http://host/api/loans
            .path("/{id}")
            .buildAndExpand(created.getId())
            .toUri();                      // http://host/api/loans/42

    return ResponseEntity.created(location).body(LoanResponse.from(created));
}

Case 2: the code depends on the outcome.

@PutMapping("/{isbn}")
public ResponseEntity<MaterialResponse> replace(@PathVariable Isbn isbn,
                                                @Valid @RequestBody UpdateMaterialRequest r) {
    UpdateResult result = management.createOrUpdate(isbn, r);

    return result.wasCreated()
            ? ResponseEntity.status(HttpStatus.CREATED).body(MaterialResponse.from(result.material()))
            : ResponseEntity.ok(MaterialResponse.from(result.material()));
}

Case 3: specific headers, such as conditional caching.

@GetMapping("/{isbn}")
public ResponseEntity<MaterialResponse> byIsbn(@PathVariable Isbn isbn) {
    Material material = catalog.byIsbn(isbn).orElseThrow(() -> new MaterialNotFoundException(isbn));

    return ResponseEntity.ok()
            .eTag("\"" + material.getVersion() + "\"")     // JPA's @Version used as the ETag
            .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
            .body(MaterialResponse.from(material));
}

With the ETag, a client that sends If-None-Match back gets a 304 Not Modified with no body. It is the cheapest bandwidth optimisation there is.

An alternative for the simple case of fixing the code: @ResponseStatus.

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)      // 204
public void cancel(@PathVariable Long id) { reservations.cancel(id); }

  1. Inbound and outbound DTOs

This picks up 12-01, now with the full detail. The rule: JPA entities do not cross the API boundary, in either direction.

An outbound DTO:

package com.nexussoftware.bibliotech.web.loans;

@Schema(description = "Information about a loan")            // OpenAPI documentation
public record LoanResponse(

        @Schema(example = "42") Long id,
        @Schema(example = "978-0000000001") String isbn,
        @Schema(example = "Effective Java") String materialTitle,
        @Schema(example = "Marta Ruiz") String employeeName,
        LocalDate loanDate,
        LocalDate dueDate,

        @Schema(description = "Null if the material is still on loan")
        LocalDate returnDate,

        @Schema(example = "ACTIVE") String status,

        @Schema(description = "Negative if it is overdue", example = "5")
        long daysRemaining,

        @Schema(example = "2.50") BigDecimal accruedFine) {

    public static LoanResponse from(Loan l, LocalDate today) {
        return new LoanResponse(
                l.getId(),
                l.getIsbn().value(),
                l.materialTitle(),
                l.employeeName(),
                l.getLoanDate(),
                l.getDueDate(),
                l.getReturnDate().orElse(null),          // JSON has no Optional
                l.getStatus().name(),
                ChronoUnit.DAYS.between(today, l.getDueDate()),
                l.accruedFine(today).amount());
    }
}

An inbound DTO:

public record CreateLoanRequest(

        @NotBlank(message = "The ISBN is required")
        @ValidIsbn
        @Schema(example = "978-0000000001")
        String isbn,

        @NotNull(message = "The employee identifier is required")
        @Positive
        Long employeeId,

        @Positive @Max(value = 90, message = "The maximum duration is 90 days")
        @Schema(description = "Loan days. If omitted, the standard one for the material type")
        Integer days) {
}

Notice what it does not have: no id, no version, no status, no returnDate. A malicious client cannot send them because the object they are deserialised into has no such components. The defence is structural, not a list of fields somebody has to remember to ignore.

A concrete warning about record and Jackson: records deserialise without trouble from Jackson 2.12, but they need -parameters at compile time or @JsonProperty on every component. Spring Boot enables it by default; if your build is home-grown, check it.

  1. Validation with jakarta.validation

The dependency:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

The most-used annotations:

Annotation Validates Example
@NotNull Not null (an empty string passes) Long employeeId
@NotBlank String not null, not empty, not only spaces String title
@NotEmpty A collection or string that is not empty List<String> authors
@Size(min, max) Length @Size(max = 200) String title
@Min / @Max Numeric range @Max(90) Integer days
@Positive / @PositiveOrZero Sign @Positive int copies
@Email Email address String email
@Pattern(regexp) Regular expression @Pattern(regexp = "97[89]-\\d{10}")
@Past / @Future Dates @PastOrPresent LocalDate date
@Valid Cascades into nested objects @Valid AddressRequest address

@Valid on the parameter is what triggers the validation:

@PostMapping
public ResponseEntity<LoanResponse> create(@Valid @RequestBody CreateLoanRequest request) {
    // If we get here, the request is syntactically valid.
    // If not, Spring has already thrown MethodArgumentNotValidException.
}

Validating query parameters requires @Validated on the class:

@RestController
@Validated                                   // enables validation of standalone parameters
@RequestMapping("/api/materials")
public class CatalogController {

    @GetMapping
    public List<MaterialResponse> list(
            @RequestParam @Size(min = 2, message = "At least 2 characters") String title,
            @RequestParam @Max(100) int limit) { … }
}

Cross-field validation with a class-level annotation:

@ValidDateRange          // a custom validator: from <= to
public record FinesQueryRequest(
        @NotNull @PastOrPresent LocalDate from,
        @NotNull @PastOrPresent LocalDate to,
        Long employeeId) {
}

An important limit to be clear about: jakarta.validation validates shape, not business rules. That the ISBN has the right format is validation; that the ISBN exists in the catalogue and has free copies is a business rule, and it lives in the domain. Confusing the two leads to putting database queries inside a validator, which is exactly where they must not be.

  1. A custom validator for the ISBN

An ISBN-13 is not just a format: it carries a computed check digit. Verifying it is shape validation, and therefore it does belong here.

package com.nexussoftware.bibliotech.web.validation;

@Documented
@Constraint(validatedBy = IsbnValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidIsbn {

    String message() default "Invalid ISBN-13: check the format and the check digit";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /** If true, accepts the ISBN with hyphens and spaces. */
    boolean allowSeparators() default true;
}
public class IsbnValidator implements ConstraintValidator<ValidIsbn, String> {

    private static final Pattern WITH_SEPARATORS = Pattern.compile("^97[89][- ]?\\d{1,5}[- ]?\\d+[- ]?\\d+[- ]?\\d$");
    private static final Pattern WITHOUT_SEPARATORS = Pattern.compile("^97[89]\\d{10}$");

    private boolean allowSeparators;

    @Override
    public void initialize(ValidIsbn annotation) {
        this.allowSeparators = annotation.allowSeparators();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // A null value counts as valid: it is @NotNull that makes a field required.
        // Separating responsibilities between validators is the right thing to do.
        if (value == null) return true;

        String normalised = allowSeparators ? value.replaceAll("[- ]", "") : value;

        if (!WITHOUT_SEPARATORS.matcher(normalised).matches()) {
            message(context, "The ISBN must start with 978 or 979 and have 13 digits");
            return false;
        }
        if (!checkDigitIsCorrect(normalised)) {
            message(context, "The ISBN check digit is not correct");
            return false;
        }
        return true;
    }

    /**
     * The official ISBN-13 algorithm: the first 12 digits are multiplied
     * alternately by 1 and 3 and added up, and the check digit is whatever
     * is missing to reach the next multiple of 10.
     */
    private boolean checkDigitIsCorrect(String isbn) {
        int sum = 0;
        for (int i = 0; i < 12; i++) {
            int digit = isbn.charAt(i) - '0';
            sum += (i % 2 == 0) ? digit : digit * 3;
        }
        int check = (10 - (sum % 10)) % 10;
        return check == (isbn.charAt(12) - '0');
    }

    /** Replaces the generic message with one specific to the actual failure. */
    private void message(ConstraintValidatorContext context, String text) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(text).addConstraintViolation();
    }
}

That specific message is the difference between "invalid ISBN" (which does not help) and "the check digit is not correct" (which tells whoever is integrating where the problem is).

  1. Global error handling and Problem Details

Without global handling, an exception produces a generic response with a stack trace included, which is at once useless to the client and an information leak (12-07).

RFC 7807 (Problem Details) defines a standard format for HTTP errors, and Spring 6 supports it out of the box with the ProblemDetail class:

{
  "type": "https://bibliotech.nexussoftware.com/errors/loan-limit",
  "title": "Loan limit exceeded",
  "status": 409,
  "detail": "Employee Diego Alonso already has 3 active loans (maximum: 3).",
  "instance": "/api/loans",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "activeLoans": [12, 27, 38]
}

The five standard fields are type, title, status, detail and instance; the rest are our own extensions.

Enabling Spring's default format:

spring:
  mvc:
    problemdetails:
      enabled: true      # Spring's standard exceptions already come out in RFC 7807 format

And the handler for our own exceptions:

package com.nexussoftware.bibliotech.web.error;

@RestControllerAdvice
public class GlobalErrorHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalErrorHandler.class);
    private static final String TYPE_BASE = "https://bibliotech.nexussoftware.com/errors/";

    // ---------- 404 ----------
    @ExceptionHandler({MaterialNotFoundException.class,
                       LoanNotFoundException.class,
                       EmployeeNotFoundException.class})
    public ProblemDetail notFound(ResourceNotFoundException e, HttpServletRequest request) {
        log.info("Resource not found: {}", e.getMessage());        // INFO: not a system failure
        return problem(HttpStatus.NOT_FOUND, "resource-not-found",
                       "Resource not found", e.getMessage(), request);
    }

    // ---------- 409: business rules ----------
    @ExceptionHandler(LoanLimitExceededException.class)
    public ProblemDetail limitExceeded(LoanLimitExceededException e, HttpServletRequest r) {
        ProblemDetail detail = problem(HttpStatus.CONFLICT, "loan-limit",
                "Loan limit exceeded", e.getMessage(), r);
        detail.setProperty("activeLoans", e.getActiveLoanIds());
        detail.setProperty("maxAllowed", e.getMax());
        return detail;
    }

    @ExceptionHandler(MaterialNotAvailableException.class)
    public ProblemDetail notAvailable(MaterialNotAvailableException e, HttpServletRequest r) {
        ProblemDetail detail = problem(HttpStatus.CONFLICT, "material-unavailable",
                "Material not available", e.getMessage(), r);
        e.getExpectedAvailabilityDate()
         .ifPresent(d -> detail.setProperty("expectedAvailableOn", d.toString()));
        return detail;
    }

    // ---------- 409: concurrency conflict (the @Version from 11-03) ----------
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ProblemDetail versionConflict(ObjectOptimisticLockingFailureException e,
                                         HttpServletRequest r) {
        log.warn("Optimistic locking conflict on {}", r.getRequestURI());
        return problem(HttpStatus.CONFLICT, "concurrency-conflict",
                "Concurrency conflict",
                "Another user modified this resource while you were editing it. Reload and retry.", r);
    }

    // ---------- 400: body validation ----------
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail validation(MethodArgumentNotValidException e, HttpServletRequest r) {
        List<FieldErrorDto> errors = e.getBindingResult().getFieldErrors().stream()
                .map(f -> new FieldErrorDto(f.getField(), f.getDefaultMessage(), f.getRejectedValue()))
                .toList();

        ProblemDetail detail = problem(HttpStatus.BAD_REQUEST, "validation",
                "Validation error",
                "The request contains %d invalid field(s).".formatted(errors.size()), r);
        detail.setProperty("errors", errors);        // THIS is what makes a 400 useful
        return detail;
    }

    // ---------- 400: parameters and types ----------
    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail invalidParameters(ConstraintViolationException e, HttpServletRequest r) {
        List<FieldErrorDto> errors = e.getConstraintViolations().stream()
                .map(v -> new FieldErrorDto(v.getPropertyPath().toString(), v.getMessage(), v.getInvalidValue()))
                .toList();
        ProblemDetail detail = problem(HttpStatus.BAD_REQUEST, "invalid-parameters",
                "Invalid parameters", "Check the request parameters.", r);
        detail.setProperty("errors", errors);
        return detail;
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ProblemDetail wrongType(MethodArgumentTypeMismatchException e, HttpServletRequest r) {
        String expected = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "valid";
        return problem(HttpStatus.BAD_REQUEST, "wrong-type", "Wrong data type",
                "The parameter '%s' with value '%s' is not a %s."
                        .formatted(e.getName(), e.getValue(), expected), r);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ProblemDetail unreadableBody(HttpMessageNotReadableException e, HttpServletRequest r) {
        // We do NOT expose e.getMessage(): it reveals the internal structure of the classes
        return problem(HttpStatus.BAD_REQUEST, "invalid-body", "Invalid request body",
                "The body is not valid JSON or does not match the expected format.", r);
    }

    // ---------- 503: external dependency ----------
    @ExceptionHandler(GatewayUnavailableException.class)
    public ResponseEntity<ProblemDetail> serviceDown(GatewayUnavailableException e,
                                                     HttpServletRequest r) {
        log.error("External service unavailable: {}", e.getService(), e);
        ProblemDetail detail = problem(HttpStatus.SERVICE_UNAVAILABLE, "service-unavailable",
                "Service temporarily unavailable",
                "The operation could not be completed. Try again in a few minutes.", r);
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, "60")     // the client knows when to retry
                .body(detail);
    }

    // ---------- 500: the safety net ----------
    @ExceptionHandler(Exception.class)
    public ProblemDetail unexpectedError(Exception e, HttpServletRequest r) {
        String incidentId = UUID.randomUUID().toString().substring(0, 12);
        // The FULL stack trace to the log; to the client, only the identifier (06-07 and 12-07)
        log.error("Unexpected error [id={}] on {} {}", incidentId, r.getMethod(), r.getRequestURI(), e);

        ProblemDetail detail = problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error",
                "Internal error",
                "An unexpected error has occurred. If the problem persists, "
                + "contact support quoting the incident identifier.", r);
        detail.setProperty("incidentId", incidentId);
        return detail;
    }

    // ---------- Shared builder ----------
    private ProblemDetail problem(HttpStatus status, String type, String title,
                                  String detailText, HttpServletRequest request) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(status, detailText);
        detail.setType(URI.create(TYPE_BASE + type));
        detail.setTitle(title);
        detail.setInstance(URI.create(request.getRequestURI()));
        detail.setProperty("timestamp", Instant.now().toString());
        // The correlation identifier MDC set in 11-07: it ties the response to the log
        detail.setProperty("traceId", MDC.get("traceId"));
        return detail;
    }

    public record FieldErrorDto(String field, String message, Object rejectedValue) { }
}

The complete translation table for module 6's hierarchy:

BiblioTech exception HTTP Problem type Logged as
MaterialNotFoundException 404 resource-not-found INFO
LoanNotFoundException 404 resource-not-found INFO
LoanLimitExceededException 409 loan-limit INFO
MaterialNotAvailableException 409 material-unavailable INFO
LoanAlreadyReturnedException 409 loan-already-returned INFO
ObjectOptimisticLockingFailureException 409 concurrency-conflict WARN
MethodArgumentNotValidException 400 validation DEBUG
InvalidIsbnException 400 invalid-isbn DEBUG
GatewayUnavailableException 503 service-unavailable ERROR
Exception (anything else) 500 internal-error ERROR

The log-level criterion matters and almost nobody applies it: a 404 is not a system error, it is a client asking for something that does not exist. If you log it as ERROR, your alerting dashboard will fill up with noise and you will stop looking at it.

  1. Pagination and sorting

Returning a List<Material> with 50,000 items is a memory problem, a network problem and a response-time problem. Spring Data solves pagination out of the box:

@GetMapping
public PageResponse<MaterialResponse> list(
        @PageableDefault(size = 20, sort = "title", direction = Sort.Direction.ASC)
        Pageable pageable) {

    Page<Material> page = catalog.search(pageable);
    return PageResponse.from(page.map(MaterialResponse::from));
}
GET /api/materials?page=0&size=20&sort=title,asc
GET /api/materials?page=2&size=50&sort=publicationYear,desc&sort=title,asc

Cap the maximum page size, or somebody will ask for size=1000000:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100          # Spring silently trims anything above this
        one-indexed-parameters: false

And your own paged response DTO, so as not to expose Page's internal structure (which has changed between Spring Data versions, breaking clients):

public record PageResponse<T>(List<T> content, PageMetadata page) {

    public static <T> PageResponse<T> from(Page<T> page) {
        return new PageResponse<>(page.getContent(), new PageMetadata(
                page.getNumber(), page.getSize(), page.getTotalElements(),
                page.getTotalPages(), page.isFirst(), page.isLast()));
    }

    public record PageMetadata(int number, int size, long totalElements,
                               int totalPages, boolean first, boolean last) { }
}
{
  "content": [ { "isbn": "978-0000000001", "title": "Effective Java", "…": "…" } ],
  "page": { "number": 0, "size": 20, "totalElements": 3,
            "totalPages": 1, "first": true, "last": true }
}

A performance warning. Offset pagination (OFFSET) degrades on high page numbers: OFFSET 100000 forces the database to walk and discard a hundred thousand rows. For large catalogues there is cursor pagination (WHERE id > :lastId ORDER BY id LIMIT 20), which is constant in time. For BiblioTech, with a few thousand materials, offset is more than enough.

  1. Filters and search

A grouped criteria object beats eight loose @RequestParams:

public record MaterialCriteriaRequest(
        @Size(min = 2, max = 100) String title,
        @Size(min = 2, max = 100) String author,
        MaterialType type,
        @Min(1450) Integer yearFrom,           // the year of the printing press: a sensible floor
        @Max(2100) Integer yearTo,
        Boolean onlyAvailable) {

    public SearchCriteria toDomain() {
        return SearchCriteria.builder()        // the Builder from 12-02
                .title(title).author(author).type(type)
                .between(yearFrom, yearTo)
                .onlyAvailable(Boolean.TRUE.equals(onlyAvailable))
                .build();
    }
}
@GetMapping
public PageResponse<MaterialResponse> list(@Valid MaterialCriteriaRequest criteria,
                                           @PageableDefault(size = 20) Pageable pageable) {
    Page<Material> results = catalog.search(criteria.toDomain(), pageable);
    return PageResponse.from(results.map(MaterialResponse::from));
}
GET /api/materials?title=java&type=BOOK&onlyAvailable=true&page=0&size=10

In the implementation, Spring Data's Specification composes the criteria dynamically. It is the Specification pattern (12-02):

public Page<Material> search(SearchCriteria criteria, Pageable pageable) {
    Specification<Material> spec = Specification.where(null);

    if (criteria.title() != null) {
        spec = spec.and((root, query, cb) ->
                cb.like(cb.lower(root.get("title")), "%" + criteria.title().toLowerCase() + "%"));
    }
    if (criteria.type() != null) {
        spec = spec.and((root, query, cb) -> cb.equal(root.get("type"), criteria.type()));
    }
    if (criteria.onlyAvailable()) {
        spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("availableCopies"), 0));
    }
    return repository.findAll(spec, pageable);
}

This is not SQL concatenation: CriteriaBuilder generates parameterised queries, immune to injection (picked up again in 12-07).

  1. BiblioTech's complete API

Method Path Description Success Errors
GET /api/materials Paged list with filters 200 400
GET /api/materials/{isbn} Detail of one material 200 400, 404
POST /api/materials Register a material 201 400, 409
PUT /api/materials/{isbn} Full replacement 200, 201 400, 404
PATCH /api/materials/{isbn}/copies Adjust the copies 200 400, 404, 409
DELETE /api/materials/{isbn} Remove a material 204 404, 409
GET /api/loans Paged list 200 400
GET /api/loans/{id} Detail 200 404
POST /api/loans Create a loan 201 400, 404, 409
POST /api/loans/{id}/return Record a return 200 404, 409
POST /api/loans/{id}/renewal Renew 200 404, 409
GET /api/employees/{id}/loans An employee's loans 200 404
GET /api/employees/{id}/fines An employee's fines 200 404
POST /api/reservations Create a reservation 201 400, 404, 409
DELETE /api/reservations/{id} Cancel a reservation 204 404, 409
GET /api/statistics/usage Usage report 200 400
GET /actuator/health Service status 200 503

Examples with curl and their responses.

Creating a loan:

curl -i -X POST http://localhost:8080/api/loans \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000001","employeeId":1,"days":15}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/loans/42
Content-Type: application/json

{
  "id": 42,
  "isbn": "978-0000000001",
  "materialTitle": "Effective Java",
  "employeeName": "Marta Ruiz",
  "loanDate": "2026-08-05",
  "dueDate": "2026-08-20",
  "returnDate": null,
  "status": "ACTIVE",
  "daysRemaining": 15,
  "accruedFine": 0.00
}

Exceeding the loan limit:

curl -i -X POST http://localhost:8080/api/loans \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000003","employeeId":2,"days":15}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errors/loan-limit",
  "title": "Loan limit exceeded",
  "status": 409,
  "detail": "Employee Diego Alonso already has 3 active loans (maximum: 3).",
  "instance": "/api/loans",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "activeLoans": [12, 27, 38],
  "maxAllowed": 3
}

A request with several validation errors:

curl -i -X POST http://localhost:8080/api/loans \
  -H "Content-Type: application/json" \
  -d '{"isbn":"1234","employeeId":null,"days":365}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errors/validation",
  "title": "Validation error",
  "status": 400,
  "detail": "The request contains 3 invalid field(s).",
  "instance": "/api/loans",
  "errors": [
    {"field": "isbn", "message": "The ISBN must start with 978 or 979 and have 13 digits",
     "rejectedValue": "1234"},
    {"field": "employeeId", "message": "The employee identifier is required",
     "rejectedValue": null},
    {"field": "days", "message": "The maximum duration is 90 days", "rejectedValue": 365}
  ]
}

Returning all three errors at once —rather than just the first— is what saves the client three round trips.

Recording the return:

curl -i -X POST http://localhost:8080/api/loans/42/return \
  -H "Content-Type: application/json" -d '{"date":"2026-08-25"}'
HTTP/1.1 200 OK

{
  "loanId": 42,
  "returnDate": "2026-08-25",
  "daysLate": 5,
  "fine": 2.50,
  "status": "RETURNED"
}

A paged search:

curl "http://localhost:8080/api/materials?title=des&type=BOOK&page=0&size=5&sort=title,asc"

  1. Automatic documentation with OpenAPI

OpenAPI (formerly Swagger) describes an API in a machine-readable JSON or YAML document. From it you can generate browsable documentation, clients in any language and contract tests.

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>

With just that you already have:

  • http://localhost:8080/v3/api-docs — the OpenAPI document as JSON
  • http://localhost:8080/swagger-ui.html — the browsable interface, with a "Try it out" button

And it is enriched with annotations:

@RestController
@RequestMapping("/api/loans")
@Tag(name = "Loans", description = "Management of material loans to employees")
public class LoanController {

    @Operation(
        summary = "Creates a loan",
        description = """
                Lends a material to an employee. Checks that there are copies available
                and that the employee does not exceed the active-loan limit.

                If no duration is given, the standard one for the material type is used:
                15 days for books, 7 for magazines and 3 for DVDs.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Loan created",
            content = @Content(schema = @Schema(implementation = LoanResponse.class))),
        @ApiResponse(responseCode = "400", description = "Invalid request",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "404", description = "Material or employee does not exist",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "409", description = "No free copies or limit exceeded",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
    })
    @PostMapping
    public ResponseEntity<LoanResponse> create(@Valid @RequestBody CreateLoanRequest request) { … }
}

General information about the API:

@Configuration
public class OpenApiConfiguration {

    @Bean
    OpenAPI biblioTechApi(@Value("${bibliotech.version}") String version) {
        return new OpenAPI()
                .info(new Info()
                        .title("BiblioTech API")
                        .version(version)
                        .description("Management of Nexus Software's internal technical library.")
                        .contact(new Contact().name("Platform team")
                                              .email("[email protected]")))
                .servers(List.of(
                        new Server().url("http://localhost:8080").description("Development"),
                        new Server().url("https://bibliotech.nexussoftware.com").description("Production")));
    }
}

And in production, the interface is switched off but the document is kept (or protected, 12-07):

springdoc:
  swagger-ui:
    enabled: false        # in prod
  api-docs:
    path: /v3/api-docs

OpenAPI's real value shows up in integration: a TypeScript, Java or Python client is generated from the document with a single command, and you never have to hand-write a single DTO.

  1. CORS

Browsers enforce the same-origin policy: JavaScript served from https://intranet.nexussoftware.com cannot call https://bibliotech.nexussoftware.com unless the server explicitly authorises it. CORS (Cross-Origin Resource Sharing) is that authorisation mechanism.

The flow for "non-simple" requests (with Content-Type: application/json, for instance) includes a preflight request:

OPTIONS /api/loans HTTP/1.1
Origin: https://intranet.nexussoftware.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://intranet.nexussoftware.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600

Global configuration:

@Configuration
public class WebCorsConfiguration implements WebMvcConfigurer {

    private final List<String> allowedOrigins;        // from application.yml, per environment

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins(allowedOrigins.toArray(String[]::new))
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("Content-Type", "Authorization")
                .exposedHeaders("Location")            // so the client can read it after a 201
                .allowCredentials(true)
                .maxAge(3600);                         // cache the preflight response for 1 hour
    }
}
# dev
bibliotech:
  cors:
    origins: ["http://localhost:5173", "http://localhost:3000"]
# prod
bibliotech:
  cors:
    origins: ["https://intranet.nexussoftware.com"]

Warning. allowedOrigins("*") together with allowCredentials(true) is a combination the specification forbids and that Spring rejects at runtime. And a bare "*" on an internal API opens the door for any web page in the world to make requests from your users' browsers. An explicit list of origins, always.

And one clarification that saves hours of debugging: CORS protects the browser, not the server. A curl or a Java client ignores CORS entirely. It is not a security mechanism for your API; security is 12-07.

  1. The service layer and transactions

The controller does not carry @Transactional. The transaction belongs to the use case, and this is not an aesthetic preference:

// BAD: the transaction in the controller
@RestController
public class LoanController {
    @PostMapping
    @Transactional                          // ← no
    public LoanResponse create(@RequestBody CreateLoanRequest r) { … }
}

Concrete reasons:

  1. The transaction would stay open during JSON serialisation, dragging it out for no reason and keeping a pool connection busy.
  2. The CLI (12-03) calls the same use case without going through the controller: it would end up with no transaction at all.
  3. It mixes a data-infrastructure decision with the presentation layer.
// GOOD: the transaction, in the use case
@Service
public class LoanManager implements ManageLoans {

    @Override
    @Transactional                                  // write
    public Loan lend(Isbn isbn, Long employeeId, Integer days) { … }

    @Override
    @Transactional(readOnly = true)                 // read: Hibernate skips dirty checking
    public Optional<Loan> find(Long id) { … }
}
// And the controller only translates HTTP
@PostMapping
public ResponseEntity<LoanResponse> create(@Valid @RequestBody CreateLoanRequest r) {
    Loan created = manager.lend(r.isbn(), r.employeeId(), r.days());
    return ResponseEntity.created(uriOf(created)).body(LoanResponse.from(created, today()));
}

Remember from 12-01 the property that makes this mandatory:

spring:
  jpa:
    open-in-view: false

With open-in-view: true (Spring Boot's default), the Hibernate session stays open during rendering, which means lazy associations get resolved from the controller, generating invisible N+1s. With false, if your DTO touches an association that was not loaded, you get a LazyInitializationException in development, which is exactly what you want: a loud error instead of a silent performance problem.

  1. Testing the web layer

Two levels, with different purposes:

Level Annotation What it starts Speed What it tests
Web slice @WebMvcTest Only the MVC layer ~1 s Mapping, validation, serialisation, status codes
End to end @SpringBootTest(RANDOM_PORT) Everything, with a real server ~5-15 s The whole flow, database included

Level 1: @WebMvcTest with MockMvc. There is no database and no server: just the controller and the MVC infrastructure.

@WebMvcTest(LoanController.class)
class LoanControllerTest {

    @Autowired MockMvc mvc;
    @Autowired ObjectMapper json;

    @MockitoBean ManageLoans manager;            // Spring Boot 3.4+; it used to be @MockBean

    @Test
    void returns201AndLocationWhenCreatingALoan() throws Exception {
        var created = aLoan(42L, "978-0000000001", "Marta Ruiz");
        when(manager.lend(any(), eq(1L), eq(15))).thenReturn(created);

        mvc.perform(post("/api/loans")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000001","employeeId":1,"days":15}"""))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", endsWith("/api/loans/42")))
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.materialTitle").value("Effective Java"))
                .andExpect(jsonPath("$.status").value("ACTIVE"));
    }

    @Test
    void returns400WithTheDetailOfEveryInvalidField() throws Exception {
        mvc.perform(post("/api/loans")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"1234","employeeId":null,"days":365}"""))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
                .andExpect(jsonPath("$.title").value("Validation error"))
                .andExpect(jsonPath("$.errors", hasSize(3)))
                .andExpect(jsonPath("$.errors[*].field",
                        containsInAnyOrder("isbn", "employeeId", "days")));

        // With invalid data, the use case must NOT have been invoked
        verifyNoInteractions(manager);
    }

    @Test
    void returns409WithDetailWhenTheLimitIsExceeded() throws Exception {
        when(manager.lend(any(), eq(2L), any()))
                .thenThrow(new LoanLimitExceededException(2L, "Diego Alonso", 3,
                                                          List.of(12L, 27L, 38L)));

        mvc.perform(post("/api/loans")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000003","employeeId":2,"days":15}"""))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.type").value(endsWith("/errors/loan-limit")))
                .andExpect(jsonPath("$.detail").value(containsString("Diego Alonso")))
                .andExpect(jsonPath("$.activeLoans", hasSize(3)));
    }

    @Test
    void returns404WhenTheLoanDoesNotExist() throws Exception {
        when(manager.find(9999L)).thenReturn(Optional.empty());

        mvc.perform(get("/api/loans/9999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    void returns400WhenTheIsbnInThePathIsInvalid() throws Exception {
        mvc.perform(get("/api/materials/not-an-isbn"))
                .andExpect(status().isBadRequest());
    }
}

Level 2: @SpringBootTest with TestRestTemplate. A real server on a random port, a real database, the whole flow:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class LoanApiIT {

    @Autowired TestRestTemplate client;
    @Autowired LoanRepository repository;

    @Test
    void fullLoanAndReturnCycle() {
        // 1. Create
        var request = new CreateLoanRequest("978-0000000001", 1L, 15);
        ResponseEntity<LoanResponse> creation =
                client.postForEntity("/api/loans", request, LoanResponse.class);

        assertThat(creation.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(creation.getHeaders().getLocation()).isNotNull();
        Long id = creation.getBody().id();

        // 2. Query it through the URI that Location returned
        ResponseEntity<LoanResponse> query =
                client.getForEntity(creation.getHeaders().getLocation(), LoanResponse.class);
        assertThat(query.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(query.getBody().status()).isEqualTo("ACTIVE");

        // 3. Return it
        ResponseEntity<ReturnResponse> returning = client.postForEntity(
                "/api/loans/{id}/return", new ReturnRequest(null),
                ReturnResponse.class, id);
        assertThat(returning.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(returning.getBody().fine()).isEqualByComparingTo("0.00");

        // 4. Check that persistence reflects the change
        assertThat(repository.findById(id))
                .get()
                .extracting(Loan::getStatus)
                .isEqualTo(LoanStatus.RETURNED);
    }

    @Test
    void returningTwiceGives409() {
        Long id = createAndReturn();

        ResponseEntity<ProblemDetail> second = client.postForEntity(
                "/api/loans/{id}/return", new ReturnRequest(null),
                ProblemDetail.class, id);

        assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
        assertThat(second.getBody().getDetail()).contains("has already been returned");
    }
}

What is tested at each level (the complete strategy is lesson 12-05):

Aspect @WebMvcTest @SpringBootTest
Correct path and verb Yes Yes
Parameter conversion Yes Yes
Input validation Yes, here Redundant
Status codes and errors Yes, here The main ones
Shape of the JSON Yes, here Not needed
Business rules No (mocked) Yes
Real persistence No Yes, here
Transactions and rollback No Yes, here

A practical rule: many slice tests (fast, exhaustive on edge cases) and few end-to-end ones (slow, for the main flows). Inverting that ratio is the most common way to end up with a test suite that takes twenty minutes.

  1. User interface: Thymeleaf or a separate front end

The API is done. What remains is deciding what Marta Ruiz sees in her browser. Two roads:

Option A: HTML served from Spring, with Thymeleaf.

@Controller                                   // NOT @RestController!
@RequestMapping("/catalog")
public class CatalogViewController {

    @GetMapping
    public String list(@RequestParam(required = false) String title, Model model) {
        model.addAttribute("materials", catalog.search(title));
        model.addAttribute("filter", title);
        return "catalog/list";                // resolves to templates/catalog/list.html
    }
}
<!-- src/main/resources/templates/catalog/list.html -->
<table>
  <tr th:each="m : ${materials}">
    <td th:text="${m.isbn}">978-…</td>
    <td th:text="${m.title}">Title</td>
    <td th:text="${m.availableCopies}">0</td>
  </tr>
</table>

Option B: a separate front end (React, Vue, Angular) that consumes the REST API, deployed as static files on a CDN or web server.

Criterion Thymeleaf (server-rendered HTML) Separate front end
Deployment complexity One unit Two projects, two deployments
SEO Excellent Requires server-side rendering
Rich interactivity Limited Excellent
Team needed Java only Java + JavaScript
API reuse The view does not use the API The same API serves web and mobile
Time to the first version Shorter Longer
CORS, token authentication Not needed Have to be dealt with

The recommendation for BiblioTech: the REST API is the main interface, because it also has to serve the CLI, the planned mobile app and the HR integration. For the employees' interface, Thymeleaf is the pragmatic choice: the Nexus Software team is a Java team, the interactivity required is low (search, list, a reserve button), and it avoids the cost of maintaining a separate front-end project with its own deployment cycle.

And it is not an irreversible decision: the API is untouched if a modern front end is added later. That is precisely the advantage of having the API as the central piece.

  1. Virtual threads in Spring Boot 3.2

Here the circle closes with 10-06.

Tomcat's traditional model is one platform thread per request, with a pool of 200 by default. Each thread costs ~1 MB of stack. When a request waits on the database or on the metadata API, its thread is blocked doing nothing, and with 200 concurrent slow requests the pool runs dry: the rest queue up even though the CPU is at 5%.

Java 21's virtual threads (Project Loom) solve this: they are threads managed by the JVM, they cost a few hundred bytes, and when they block on I/O they release the underlying platform thread.

Enabling them in Spring Boot 3.2+ is one line:

spring:
  threads:
    virtual:
      enabled: true

That makes Tomcat serve each request on a virtual thread, and makes @Async and scheduled tasks use them too.

Aspect Platform threads Virtual threads
Memory cost ~1 MB of stack Hundreds of bytes
Practical maximum Thousands Millions
When blocking on I/O The OS thread stays busy The carrier is released
Creation cost High (hence the pools) Very low
Changes to your code None
Benefit Large if there is a lot of I/O

What you need to know before enabling them:

  1. They do not speed up the CPU. If the bottleneck is computation, they change nothing. The benefit is in I/O-blocked concurrency, which is the typical case for an API.
  2. synchronized pins them. A synchronized block that does I/O inside pins the virtual thread to its carrier, cancelling out the advantage. Replace it with ReentrantLock. In Java 24 this limitation goes away, but in Java 21 you have to watch for it.
  3. Never put them in a pool. Their whole point is creating one per task. A pool of virtual threads makes no sense.
  4. The connection pool is still the real limit. You can have a million virtual threads and 20 database connections: the bottleneck moves, it does not disappear. Tune HikariCP accordingly.
  5. Beware of ThreadLocals. Millions of virtual threads, each with its own copy, consume memory. With MDC (11-07) this is under control, but it is worth knowing.

Checking that they are active:

@GetMapping("/api/diagnostics/thread")
public Map<String, Object> currentThread() {
    Thread thread = Thread.currentThread();
    return Map.of("name", thread.getName(),
                  "virtual", thread.isVirtual(),
                  "group", String.valueOf(thread.threadId()));
}
{"name":"","virtual":true,"group":"142"}

And that closes the arc that began in module 8 with Thread, continued in 08-05 with ExecutorService, was generalised in 10-06 with virtual threads, and ends here: one line of configuration that multiplies the API's concurrency a thousandfold, without touching a single line of BiblioTech's code.

Common Mistakes and Tips

1. Exposing JPA entities in the API. Leakage of sensitive data, LazyInitializationException, infinite cycles, and the database schema turned into a public contract. DTOs always, from the very first endpoint.

2. Returning 200 with an error body. The client cannot tell success from failure without parsing the body. Use the status code: that is what it is for.

3. Putting @Transactional on the controller. It stretches the transaction all the way to serialisation, ties up an extra connection and leaves the other adapters with no transaction at all.

4. Leaving open-in-view at true. It is the default and it generates invisible N+1s executed from the view layer. Set it to false and fix whatever breaks.

5. Not paginating. It works with 50 materials and takes the server down with 50,000. Paginate from the start and cap the maximum size.

6. Returning 500 for client errors. A missing field is a 400, a non-existent resource is a 404, a violated business rule is a 409. A 500 means "I failed", and it should fire an alert.

7. Leaking internal details in error messages. Stack traces, class names, SQL queries or library versions in the HTTP response are free information for an attacker. To the client, a message and an identifier; to the log, everything else.

8. allowedOrigins("*") in production. Any page in the world could call your API from your users' browsers. An explicit list.

9. Verbs in the paths. POST /api/createLoan is not REST, it is RPC with different syntax. POST /api/loans.

10. Not documenting the API. Without OpenAPI, every integration starts with a chain of emails. The cost is one dependency and a few annotations.

11. Testing only with @SpringBootTest. Twenty-minute suites that nobody runs before pushing code. Many slice tests, few end-to-end ones.

12. Forgetting the Location header on a 201. The client does not know where the resource it just created ended up, and has to guess.

A final tip: design the API before implementing it. Write out the table of endpoints with their status codes, review it, and only then write code. Changing a published API is expensive; changing a table in a document costs nothing.

Exercises

The exercises assume the project as it stands at the end of this lesson.

Exercise 1: a renewal endpoint

Implement POST /api/loans/{id}/renewal with these rules:

  • An optional body with days (1 to 30); if omitted, the material's standard duration is used.
  • It can only be renewed once (status ACTIVE; see the State pattern from 12-02).
  • It cannot be renewed if the loan is overdue.
  • It cannot be renewed if there are pending reservations for that material.
  • Codes: 200 success, 404 does not exist, 409 for each of the three violations, 400 for days out of range.

Write the request and response DTOs, the controller method, the error handlers required and the @WebMvcTest tests for the five scenarios.

Exercise 2: advanced search with pagination

Implement GET /api/materials/search accepting:

  • q: free text searched in the title and the author (minimum 2 characters).
  • type: repeatable (?type=BOOK&type=DVD).
  • available: boolean.
  • yearFrom and yearTo, validating that from <= to with a class-level annotation.
  • Pagination and sorting, with a maximum of 50 per page.
  • Besides the results, it must return how many there are per type (facets).

Include the criteria DTO with its cross-field validation, the faceted response and the tests.

Exercise 3: idempotency when creating loans

Implement support for the Idempotency-Key header on POST /api/loans:

  • If the header is present and the key has not been seen, the request is processed normally and the response is stored against the key.
  • If the key was already processed, the original response is returned with the same Location header and an Idempotent-Replay: true header, without creating anything.
  • If the key is in flight, a 409 is returned.
  • Keys expire after 24 hours.

Use a filter or an interceptor, and explain the concurrency implications.


Solutions

Solution 1

DTOs:

public record RenewLoanRequest(
        @Min(value = 1, message = "A renewal must be at least 1 day")
        @Max(value = 30, message = "A renewal cannot exceed 30 days")
        Integer days) {
}

public record RenewalResponse(
        Long loanId,
        String materialTitle,
        LocalDate previousDueDate,
        LocalDate newDueDate,
        int daysAdded,
        boolean canBeRenewedAgain) {

    public static RenewalResponse from(Loan l, LocalDate previous) {
        return new RenewalResponse(
                l.getId(), l.materialTitle(), previous, l.getDueDate(),
                (int) ChronoUnit.DAYS.between(previous, l.getDueDate()),
                l.getStatus().allowsRenewal());
    }
}

Controller:

@PostMapping("/{id}/renewal")
@Operation(summary = "Renews a loan",
           description = "Extends the due date. Only one renewal per loan is allowed.")
@ApiResponses({
    @ApiResponse(responseCode = "200", description = "Renewed"),
    @ApiResponse(responseCode = "404", description = "The loan does not exist"),
    @ApiResponse(responseCode = "409", description = "Already renewed, overdue, or with pending reservations")
})
public RenewalResponse renew(
        @PathVariable Long id,
        @RequestBody(required = false) @Valid RenewLoanRequest request) {

    Integer days = (request != null) ? request.days() : null;
    RenewalResult result = manager.renew(id, days);
    return RenewalResponse.from(result.loan(), result.previousDueDate());
}

The use case, where the three rules really live:

@Override
@Transactional
public RenewalResult renew(Long id, Integer days) {
    Loan loan = repository.findById(id)
            .orElseThrow(() -> new LoanNotFoundException(id));

    LocalDate today = LocalDate.now(clock);

    // Rule 1: the status governs (State pattern, 12-02)
    if (!loan.getStatus().allowsRenewal()) {
        throw new RenewalNotAllowedException(id, loan.getStatus(),
                "This loan has already been renewed or does not allow renewal.");
    }

    // Rule 2: overdue
    if (loan.isOverdueOn(today)) {
        throw new RenewalNotAllowedException(id, loan.getStatus(),
                "The loan was due on %s. Return it and take it out again."
                        .formatted(loan.getDueDate()));
    }

    // Rule 3: other people's reservations
    long pending = reservations.countPendingFor(loan.getIsbn());
    if (pending > 0) {
        throw new MaterialHasReservationsException(loan.getIsbn(), pending);
    }

    LocalDate previous = loan.getDueDate();
    int effectiveDays = (days != null) ? days : loan.standardDaysForMaterial();
    loan.renew(effectiveDays);                // validates and transitions the status

    return new RenewalResult(loan, previous);
}

Handlers:

@ExceptionHandler(RenewalNotAllowedException.class)
public ProblemDetail renewalNotAllowed(RenewalNotAllowedException e, HttpServletRequest r) {
    ProblemDetail d = problem(HttpStatus.CONFLICT, "renewal-not-allowed",
            "Renewal not allowed", e.getMessage(), r);
    d.setProperty("loanId", e.getLoanId());
    d.setProperty("currentStatus", e.getStatus().name());
    return d;
}

@ExceptionHandler(MaterialHasReservationsException.class)
public ProblemDetail withReservations(MaterialHasReservationsException e, HttpServletRequest r) {
    ProblemDetail d = problem(HttpStatus.CONFLICT, "material-with-reservations",
            "Material with pending reservations",
            "Cannot renew: %d employee(s) are waiting for this material."
                    .formatted(e.getPendingReservations()), r);
    d.setProperty("pendingReservations", e.getPendingReservations());
    return d;
}

Tests for the five scenarios:

@WebMvcTest(LoanController.class)
class RenewalControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean ManageLoans manager;

    @Test
    void renewsWithTheGivenNumberOfDays() throws Exception {
        var loan = aLoan(42L).withDueDate(LocalDate.of(2026, 8, 20));
        when(manager.renew(42L, 10)).thenReturn(
                new RenewalResult(loan.renewedUntil(LocalDate.of(2026, 8, 30)),
                                  LocalDate.of(2026, 8, 20)));

        mvc.perform(post("/api/loans/42/renewal")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"days":10}"""))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.previousDueDate").value("2026-08-20"))
                .andExpect(jsonPath("$.newDueDate").value("2026-08-30"))
                .andExpect(jsonPath("$.daysAdded").value(10))
                .andExpect(jsonPath("$.canBeRenewedAgain").value(false));
    }

    @Test
    void renewsWithNoBodyUsingTheStandardDuration() throws Exception {
        when(manager.renew(42L, null)).thenReturn(aRenewalOf(15));

        mvc.perform(post("/api/loans/42/renewal"))          // no body
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.daysAdded").value(15));
    }

    @Test
    void returns404IfTheLoanDoesNotExist() throws Exception {
        when(manager.renew(eq(9999L), any())).thenThrow(new LoanNotFoundException(9999L));

        mvc.perform(post("/api/loans/9999/renewal"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.type").value(endsWith("/errors/resource-not-found")));
    }

    @Test
    void returns409IfItWasAlreadyRenewed() throws Exception {
        when(manager.renew(eq(42L), any())).thenThrow(
                new RenewalNotAllowedException(42L, LoanStatus.RENEWED,
                        "This loan has already been renewed or does not allow renewal."));

        mvc.perform(post("/api/loans/42/renewal"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.currentStatus").value("RENEWED"))
                .andExpect(jsonPath("$.detail").value(containsString("already been renewed")));
    }

    @Test
    void returns409IfThereArePendingReservations() throws Exception {
        when(manager.renew(eq(42L), any()))
                .thenThrow(new MaterialHasReservationsException(Isbn.of("978-0000000001"), 2));

        mvc.perform(post("/api/loans/42/renewal"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.pendingReservations").value(2));
    }

    @ParameterizedTest
    @ValueSource(ints = {0, -5, 31, 100})
    void returns400IfTheDaysAreOutOfRange(int days) throws Exception {
        mvc.perform(post("/api/loans/42/renewal")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"days\":%d}".formatted(days)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("days"));

        verifyNoInteractions(manager);     // the use case was not even called
    }
}

Solution 2

Cross-field validation with a class-level annotation:

@Documented
@Constraint(validatedBy = YearRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidYearRange {
    String message() default "yearFrom cannot be later than yearTo";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class YearRangeValidator implements ConstraintValidator<ValidYearRange, SearchRequest> {
    @Override
    public boolean isValid(SearchRequest r, ConstraintValidatorContext ctx) {
        if (r.yearFrom() == null || r.yearTo() == null) return true;   // @NotNull is not our job
        if (r.yearFrom() <= r.yearTo()) return true;

        ctx.disableDefaultConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(
                "yearFrom (%d) cannot be later than yearTo (%d)"
                        .formatted(r.yearFrom(), r.yearTo()))
           .addPropertyNode("yearFrom")         // the error is attached to the right field
           .addConstraintViolation();
        return false;
    }
}

The criteria DTO:

@ValidYearRange
public record SearchRequest(
        @NotBlank(message = "The search text is required")
        @Size(min = 2, max = 100, message = "Between 2 and 100 characters")
        String q,

        List<MaterialType> type,          // repeatable: ?type=BOOK&type=DVD

        Boolean available,

        @Min(1450) @Max(2100) Integer yearFrom,
        @Min(1450) @Max(2100) Integer yearTo) {

    /** Normalisation: null → empty list, so as not to repeat checks downstream. */
    public SearchRequest {
        type = (type == null) ? List.of() : List.copyOf(type);
    }

    public SearchCriteria toDomain() {
        return SearchCriteria.builder()
                .text(q)
                .types(type)
                .onlyAvailable(Boolean.TRUE.equals(available))
                .between(yearFrom, yearTo)
                .build();
    }
}

The faceted response:

public record SearchResultResponse(
        List<MaterialResponse> results,
        PageResponse.PageMetadata page,
        Map<String, Long> facetsByType,
        long overallTotal,
        String query) {

    public static SearchResultResponse from(Page<Material> page,
                                            Map<MaterialType, Long> facets,
                                            String query) {
        return new SearchResultResponse(
                page.getContent().stream().map(MaterialResponse::from).toList(),
                new PageResponse.PageMetadata(page.getNumber(), page.getSize(),
                        page.getTotalElements(), page.getTotalPages(),
                        page.isFirst(), page.isLast()),
                facets.entrySet().stream()
                        .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue,
                                                  (a, b) -> a, LinkedHashMap::new)),
                facets.values().stream().mapToLong(Long::longValue).sum(),
                query);
    }
}

Controller:

@GetMapping("/search")
@Operation(summary = "Advanced search with facets by material type")
public SearchResultResponse search(
        @Valid SearchRequest criteria,
        @PageableDefault(size = 20, sort = "title") Pageable pageable) {

    // Defence in depth: even though max-page-size is configured, do not rely on it
    if (pageable.getPageSize() > 50) {
        pageable = PageRequest.of(pageable.getPageNumber(), 50, pageable.getSort());
    }

    SearchCriteria domain = criteria.toDomain();
    Page<Material> page = catalog.search(domain, pageable);
    Map<MaterialType, Long> facets = catalog.countByType(domain);        // aggregation query

    return SearchResultResponse.from(page, facets, criteria.q());
}

The facets in the repository, with a single aggregation query instead of N queries:

@Query("""
       select m.type as type, count(m) as total
       from Material m
       where (lower(m.title) like lower(concat('%', :text, '%'))
              or lower(m.author) like lower(concat('%', :text, '%')))
         and (:onlyAvailable = false or m.availableCopies > 0)
       group by m.type
       """)
List<TypeFacet> countByType(@Param("text") String text,
                            @Param("onlyAvailable") boolean onlyAvailable);

Tests:

@Test
void returnsResultsWithFacetsByType() throws Exception {
    when(catalog.search(any(), any())).thenReturn(aPageWith(2, "Effective Java", "Refactoring"));
    when(catalog.countByType(any())).thenReturn(
            new LinkedHashMap<>(Map.of(MaterialType.BOOK, 5L, MaterialType.DVD, 2L)));

    mvc.perform(get("/api/materials/search").param("q", "java").param("type", "BOOK"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.results", hasSize(2)))
            .andExpect(jsonPath("$.facetsByType.BOOK").value(5))
            .andExpect(jsonPath("$.overallTotal").value(7))
            .andExpect(jsonPath("$.query").value("java"));
}

@Test
void rejectsQueriesThatAreTooShort() throws Exception {
    mvc.perform(get("/api/materials/search").param("q", "j"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("q"));
}

@Test
void rejectsAnInvertedYearRange() throws Exception {
    mvc.perform(get("/api/materials/search")
                    .param("q", "java").param("yearFrom", "2020").param("yearTo", "2010"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("yearFrom"))
            .andExpect(jsonPath("$.errors[0].message").value(containsString("cannot be later")));
}

@Test
void trimsThePageSizeToTheMaximum() throws Exception {
    mvc.perform(get("/api/materials/search").param("q", "java").param("size", "500"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.page.size").value(lessThanOrEqualTo(50)));
}

Solution 3

A store for idempotency keys:

@Entity
@Table(name = "idempotency_keys",
       indexes = @Index(name = "idx_expiry", columnList = "expiresAt"))
public class IdempotencyKey {

    @Id
    @Column(name = "idempotency_key", length = 100)
    private String key;

    @Column(nullable = false, length = 64)
    private String requestFingerprint;   // hash of the body: detects reuse with different data

    @Enumerated(EnumType.STRING)
    private KeyStatus status;            // IN_PROGRESS, COMPLETED

    private Integer responseStatus;

    @Column(columnDefinition = "text")
    private String responseBody;

    private String location;
    private Instant createdAt;
    private Instant expiresAt;

    public enum KeyStatus { IN_PROGRESS, COMPLETED }
}

The interceptor, which wraps the controller's execution:

@Component
public class IdempotencyInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(IdempotencyInterceptor.class);
    private static final String HEADER = "Idempotency-Key";
    private static final Duration VALIDITY = Duration.ofHours(24);

    private final IdempotencyKeyRepository repository;
    private final ObjectMapper json;
    private final Clock clock;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws IOException {

        // Only applies to methods that are not naturally idempotent
        if (!"POST".equals(request.getMethod())) return true;

        String key = request.getHeader(HEADER);
        if (key == null || key.isBlank()) return true;         // the header is optional

        String fingerprint = fingerprintOf(request);

        // Conditional INSERT: the primary-key constraint resolves the race
        // between two simultaneous requests with the same key. Do NOT use "SELECT then INSERT".
        Optional<IdempotencyKey> existing = repository.reserveIfAbsent(
                key, fingerprint, Instant.now(clock), Instant.now(clock).plus(VALIDITY));

        if (existing.isEmpty()) {
            // We reserved it ourselves: carry on with normal processing
            request.setAttribute("idempotency.key", key);
            return true;
        }

        IdempotencyKey previous = existing.get();

        // Same key, different body: the client has made a mistake
        if (!previous.getRequestFingerprint().equals(fingerprint)) {
            writeProblem(response, HttpStatus.UNPROCESSABLE_ENTITY,
                    "The idempotency key was already used with a different body.");
            return false;
        }

        if (previous.getStatus() == KeyStatus.IN_PROGRESS) {
            // Another identical request is being processed right now
            response.setHeader(HttpHeaders.RETRY_AFTER, "2");
            writeProblem(response, HttpStatus.CONFLICT,
                    "There is already a request in flight with this idempotency key.");
            return false;
        }

        // COMPLETED: we replay the original response without executing anything
        log.info("Replaying the idempotent response for key {}", key);
        response.setStatus(previous.getResponseStatus());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setHeader("Idempotent-Replay", "true");
        if (previous.getLocation() != null) {
            response.setHeader(HttpHeaders.LOCATION, previous.getLocation());
        }
        response.getWriter().write(previous.getResponseBody());
        return false;                       // the controller is NOT called
    }

    private String fingerprintOf(HttpServletRequest request) throws IOException {
        // Requires ContentCachingRequestWrapper: the body can only be read once
        byte[] body = ((ContentCachingRequestWrapper) request).getContentAsByteArray();
        return HexFormat.of().formatHex(
                MessageDigest.getInstance("SHA-256").digest(body));
    }
}

Storing the response, in a filter that wraps everything:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class IdempotencyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        var cachedRequest = new ContentCachingRequestWrapper(request);
        var cachedResponse = new ContentCachingResponseWrapper(response);

        try {
            chain.doFilter(cachedRequest, cachedResponse);

            String key = (String) request.getAttribute("idempotency.key");
            if (key != null) {
                int status = cachedResponse.getStatus();
                if (status >= 200 && status < 300) {
                    // Only success is memorised: a 500 must remain retryable
                    repository.complete(key, status,
                            new String(cachedResponse.getContentAsByteArray(), UTF_8),
                            cachedResponse.getHeader(HttpHeaders.LOCATION));
                } else {
                    repository.release(key);   // frees the key for a legitimate retry
                }
            }
        } finally {
            cachedResponse.copyBodyToResponse();      // ESSENTIAL: without this nothing reaches the client
        }
    }
}

The atomic reservation, which is the delicate part:

@Repository
public class IdempotencyKeyRepository {

    private final JdbcTemplate jdbc;

    /**
     * Returns Optional.empty() if the key has just been reserved (we got there first),
     * or the existing row if it was already there.
     *
     * ON CONFLICT DO NOTHING makes the operation ATOMIC in the database:
     * two simultaneous requests with the same key cannot both reserve it.
     * A "SELECT then INSERT" in Java would leave a race window.
     */
    public Optional<IdempotencyKey> reserveIfAbsent(String key, String fingerprint,
                                                    Instant now, Instant expires) {
        int rows = jdbc.update("""
                insert into idempotency_keys
                       (idempotency_key, request_fingerprint, status, created_at, expires_at)
                values (?, ?, 'IN_PROGRESS', ?, ?)
                on conflict (idempotency_key) do nothing
                """, key, fingerprint, Timestamp.from(now), Timestamp.from(expires));

        if (rows == 1) return Optional.empty();       // we reserved it ourselves

        return jdbc.query("select * from idempotency_keys where idempotency_key = ?",
                          this::map, key).stream().findFirst();
    }
}

Periodic cleanup:

@Scheduled(cron = "0 0 3 * * *")     // every day at 03:00
@Transactional
public void purgeExpiredKeys() {
    int deleted = repository.deleteExpiredBefore(Instant.now(clock));
    log.info("Idempotency keys purged: {}", deleted);
}

Concurrency implications, which is what the exercise assesses:

Scenario With no protection With this implementation
A retry after a network timeout Two loans created The original response is returned
Two simultaneous requests, same key Two loans One processes, the other gets a 409
Same key, different body Undefined behaviour An explicit 422
Server failure halfway through Key locked forever release() in the filter + expiry
Two application instances Local memory is useless The database is the point of agreement

The key decision is to use INSERT ... ON CONFLICT DO NOTHING rather than checking and then inserting. Between the SELECT and the INSERT of the naive version there is a window in which another request can slip in, and with two application instances (horizontal scaling, 12-06) that window opens constantly. Atomicity has to live in the database, which is the only shared resource.

Use from the client:

KEY=$(uuidgen)
curl -i -X POST http://localhost:8080/api/loans \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"isbn":"978-0000000001","employeeId":1,"days":15}'
# → 201 Created, Location: /api/loans/42

# A retry with the same key
curl -i -X POST … -H "Idempotency-Key: $KEY" -d '{…}'
# → 201 Created, Location: /api/loans/42, Idempotent-Replay: true
#   (and a second loan has NOT been created)

Conclusion

BiblioTech has an API.

You understand how a Java web application really works, not as a black box: the HTTP request is text over a socket, Tomcat's embedded server parses it, the DispatcherServlet acts as the front controller, the HandlerMappings route, the ArgumentResolvers convert the parameters, the HttpMessageConverters serialise with Jackson and the HandlerExceptionResolvers translate exceptions. And above all you have seen the table that puts it all in perspective: every Spring MVC piece corresponds to something you wrote by hand in module 9's CatalogServer. You are not using magic; you are delegating work you already know how to do.

You design REST with judgement: resources as nouns, verbs with their safety and idempotency semantics —including the practical reason idempotency matters, which is being able to retry without duplicating—, URIs with a hierarchy of at most two levels, the hard case of non-CRUD actions solved with subresources (POST /api/loans/42/return), and the table of status codes with the five classic mistakes to avoid. With Richardson's level 2 as an honest, realistic goal.

You write controllers that only translate HTTP: they receive, delegate to the use case and return. With @PathVariable, @RequestParam and @RequestBody; with your own converters that make the controller work with Isbn and not with String; with ResponseEntity only in the three cases that justify it —the Location of a 201, the code that depends on the outcome, and cache headers with ETag—; and with record DTOs inbound and outbound, whose asymmetry is a structural defence: the client cannot send id, version or status because the object does not have them.

You validate in the right place: jakarta.validation for shape —including a custom ISBN-13 validator that checks the check digit and gives specific messages— and the domain for business rules, without confusing the two. And you handle errors globally with @RestControllerAdvice and the RFC 7807 format that Spring 6 ships out of the box, with the complete translation table for module 6's BiblioTechException hierarchy, the list of invalid fields that saves the client three round trips, the traceId from 11-07's MDC that ties the response to the log, and the log-level criterion that stops a 404 from firing an alert.

You paginate with Pageable and your own DTO that does not expose Page's internal structure, with the maximum size capped and the warning about OFFSET degradation. You filter with a criteria object and Specification, which is 12-02's Specification pattern and generates parameterised queries. You have the complete API documented endpoint by endpoint, with curl examples and their real responses, automatic documentation with springdoc-openapi and Swagger UI, and CORS configured with an explicit list of origins and the clarification that CORS protects the browser, not your API.

You put the transaction where it belongs —in the use case, never in the controller— with the three concrete reasons, and open-in-view at false. And you test the web layer at the two levels with their different purposes: @WebMvcTest with MockMvc and @MockitoBean for mapping, validation, codes and JSON shape, fast and exhaustive; and @SpringBootTest(RANDOM_PORT) with TestRestTemplate for the complete flows with real persistence. Many of the first, few of the second.

You chose the user interface with judgement: the REST API as the central piece because it has to serve the CLI, the mobile app and the HR integration, and Thymeleaf for the employees' interface because the team is a Java team and the interactivity required is low — without closing the door to a separate front end later on.

And you closed the circle from 10-06 with virtual threads: one line of configuration, spring.threads.virtual.enabled=true, which turns every request into a Java 21 virtual thread and multiplies concurrency without touching a single line of BiblioTech's code. With the five caveats you need to know before enabling them, especially that synchronized pins them and that the connection pool is still the real limit.

BiblioTech now has an architecture, patterns, a CLI and an API. And an uncomfortable question: does it actually work?

There are forty-one tests inherited from module 11 and a handful of new ones from this lesson. There is no coverage measurement, and no idea whether those tests check anything or merely execute code. The repository tests run against H2, which is not the production database. Nobody has run a static analysis. There is no continuous integration: if Diego Alonso breaks the fine calculation, nobody finds out until an employee complains.

The next lesson turns "I have tests" into "I have a quality strategy": what to test at each level and with what target timings, Testcontainers with a real PostgreSQL because H2 lies, coverage with JaCoCo and its honest interpretation, mutation testing with PIT as the only measure that assesses your assertions, static analysis, safe refactoring, TDD developed step by step with a new BiblioTech rule, code review, and continuous integration with GitHub Actions that fails the PR when something breaks.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved