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
- How a Java web application works
- The embedded server and the
DispatcherServlet - The internal flow of a request
- From the socket-based
CatalogServerto Spring MVC - REST: resources and representations
- HTTP verbs and their semantics
- URI design
- Status codes per operation
@RestController: mapping requests- Parameters: path, query and body
ResponseEntityand when to use it- Inbound and outbound DTOs
- Validation with
jakarta.validation - A custom validator for the ISBN
- Global error handling and Problem Details
- Pagination and sorting
- Filters and search
- BiblioTech's complete API
- Automatic documentation with OpenAPI
- CORS
- The service layer and transactions
- Testing the web layer
- User interface: Thymeleaf or a separate front end
- Virtual threads in Spring Boot 3.2
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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 |
- The embedded server and the
DispatcherServlet
DispatcherServletAn 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.
- 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 |
- From the socket-based
CatalogServer to Spring MVC
CatalogServer to Spring MVCIt 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.
- 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.
- 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.
- 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/returnLoanBiblioTech 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.
- 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 |
@RestController: mapping requests
@RestController: mapping requestspackage 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
- 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.
ResponseEntity and when to use it
ResponseEntity and when to use itReturning the DTO directly is the cleanest option, and it is what you should do by default:
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); }
- 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.
- Validation with
jakarta.validation
jakarta.validationThe 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.
- 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).
- 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 formatAnd 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.
- 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,ascCap 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: falseAnd 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.
- 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));
}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).
- 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:
- 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 JSONhttp://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):
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.
- 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-typeHTTP/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: 3600Global 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 withallowCredentials(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.
- 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:
- The transaction would stay open during JSON serialisation, dragging it out for no reason and keeping a pool connection busy.
- The CLI (12-03) calls the same use case without going through the controller: it would end up with no transaction at all.
- 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:
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.
- 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.
- 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.
- 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:
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:
- 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.
synchronizedpins them. Asynchronizedblock that does I/O inside pins the virtual thread to its carrier, cancelling out the advantage. Replace it withReentrantLock. In Java 24 this limitation goes away, but in Java 21 you have to watch for it.- Never put them in a pool. Their whole point is creating one per task. A pool of virtual threads makes no sense.
- 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.
- 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()));
}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.yearFromandyearTo, validating thatfrom <= towith 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
Locationheader and anIdempotent-Replay: trueheader, 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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
