Spring Boot Course · Lesson 7

Validation & Error Handling

Your API trusts whatever it's sent. Post a book with a blank title, a negative year, or an authorIds entry that matches no one, and it either stores garbage or blows up with a raw 500. In this lesson you'll make it reject bad input at the door and report every failure the same way — one clean, structured error response across the whole API.

The win

Two upgrades that together make the API feel professional. Bean Validation rejects malformed requests with a 400 that names exactly which fields are wrong. And a single @RestControllerAdvice turns every error — validation failures, missing resources, bad references — into one consistent Problem Detail body. Along the way both rough edges from Lesson 6 get fixed — the 500 for a bad authorIds entry becomes a clean 404, the 500 for deleting an author who still has books becomes a 409 Conflict — and the Optional-juggling controllers from Lessons 4–6 get noticeably thinner.

Two jobs, one place they meet

"Error handling" really splits into two questions:

They meet in one component — a global handler — because a validation failure is just another exception to translate. You'll build validation first, then the handler that gives both halves a single, uniform voice.

Step 1 — Add the validation starter

Bean Validation isn't pulled in by the web starter, so add it explicitly. It brings Hibernate Validator (the reference implementation of the Jakarta Bean Validation spec).

pom.xml
37 collapsed lines
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>library</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>library</name>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Bean Validation (@Valid + constraints); brings Hibernate Validator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
58 collapsed lines
<!-- Spring Data JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver (used at runtime to talk to the database) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Liquibase migrations. The autoconfiguration lives in this module (it brings
liquibase-core transitively); the bare org.liquibase:liquibase-core won't activate it. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-liquibase</artifactId>
</dependency>
<!-- Starts/stops the Postgres container from compose.yaml when the app runs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<optional>true</optional>
</dependency>
<!-- Lombok: generates getters/constructors/equals at compile time -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- uuid-creator: time-ordered UUIDv7 generation -->
<dependency>
<groupId>com.github.f4b6a3</groupId>
<artifactId>uuid-creator</artifactId>
<version>6.0.0</version> <!-- or the latest on Maven Central -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

Step 2 — Constrain the request DTOs

The constraints go on the request DTOs — the shapes you accept at the boundary (Lesson 6) — not on the entity. Annotate each field with what "valid" means:

src/main/java/com/example/library/book/CreateBookRequest.java
package com.example.library.book;
import java.util.Set;
import java.util.UUID;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
public record CreateBookRequest(
@NotBlank String title, // not null, and not just whitespace
@Positive int year, // must be > 0
@NotEmpty Set<UUID> authorIds // at least one author
) {}
src/main/java/com/example/library/author/CreateAuthorRequest.java
package com.example.library.author;
import jakarta.validation.constraints.NotBlank;
public record CreateAuthorRequest(@NotBlank String name) {}
Why constraints belong on the DTO, not the entity

Validation is a property of the request you accept, so it lives on the request DTO — right where the boundary is. Putting @NotBlank on the entity instead would scatter input rules into your persistence model, validate at the wrong moment (on save, not on receipt), and tie your accepted-input rules to your storage. The DTO already exists to define "what we accept"; the constraints simply make that definition enforceable. (Common constraints: @NotNull, @NotBlank, @Size(min,max), @Positive, @Min/@Max, @Email, @Pattern.) @NotEmpty is the right constraint for a collection: it rejects both a missing field (null) and an empty array. (@NotNull alone would let [] through.)

Step 3 — Turn validation on with @Valid

Constraints do nothing until you ask Spring to check them. Add @Valid before each validated @RequestBody — that's the switch. Four methods take one: two in the book controller, two in the author controller.

src/main/java/com/example/library/book/BookController.java
5 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
34 collapsed lines
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books or ?authorId=...
public List<BookResponse> all(@RequestParam(required = false) UUID authorId) {
return books.findAll(authorId).stream().map(BookResponse::from).toList();
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> byId(@PathVariable UUID id) {
return books.findById(id)
.map(BookResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}") // PUT /api/books/{id}
public ResponseEntity<BookResponse> replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return books.replace(id, req.title(), req.year(), req.authorIds())
.map(BookResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
7 collapsed lines
@DeleteMapping("/{id}") // DELETE /api/books/{id}
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return books.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
src/main/java/com/example/library/author/AuthorController.java
5 collapsed lines
package com.example.library.author;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
33 collapsed lines
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/authors")
public class AuthorController {
private final AuthorService authors;
public AuthorController(AuthorService authors) {
this.authors = authors;
}
@GetMapping
public List<AuthorResponse> all() {
return authors.findAll().stream().map(AuthorResponse::from).toList();
}
@GetMapping("/{id}")
public ResponseEntity<AuthorResponse> byId(@PathVariable UUID id) {
return authors.findById(id)
.map(AuthorResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<AuthorResponse> create(@Valid @RequestBody CreateAuthorRequest req) {
Author saved = authors.create(req.name());
URI location = URI.create("/api/authors/" + saved.getId());
return ResponseEntity.created(location).body(AuthorResponse.from(saved));
}
@PutMapping("/{id}")
public ResponseEntity<AuthorResponse> replace(@PathVariable UUID id, @Valid @RequestBody CreateAuthorRequest req) {
return authors.replace(id, req.name())
.map(AuthorResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
7 collapsed lines
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return authors.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}

@Valid is the only change in all four methods — leave the method bodies and return types exactly as they are. (Those get simplified in Step 6, once the service throws; doing it now would be jumping ahead.) When a constraint fails, Spring aborts the method before your code runs and throws a MethodArgumentNotValidException. Out of the box that produces a 400 — but a generic one. Shaping it into something clean is the next step.

No @Valid, no validation

The constraint annotations are inert decoration without @Valid at the call site. If bad input is sailing through untouched, a missing @Valid is the first thing to check.

Step 4 — One error model: a global handler + Problem Detail

Now the centerpiece. Instead of each controller turning failures into responses, one class does it for the whole API. First, two small domain exceptions — one for each way a well-formed request can still be refused. "Not found":

src/main/java/com/example/library/error/NotFoundException.java
package com.example.library.error;
// Thrown when an addressed (or referenced) resource doesn't exist. Handler maps it to 404.
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}

And "conflict" — the request is valid and the thing exists, but the current state of the data forbids the operation. You met this at the end of Lesson 6: deleting an author who still has books.

src/main/java/com/example/library/error/ConflictException.java
package com.example.library.error;
// Thrown when a request is valid but clashes with the current state of the data:
// deleting an author who still has books, or re-creating something that already exists. Handler maps it to 409.
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}

Then the handler. It's a @RestControllerAdvice — a class whose @ExceptionHandler methods apply across every controller — and it extends ResponseEntityExceptionHandler, Spring's base class that already maps all the standard MVC failures (malformed JSON, type mismatches, wrong method or media type, …) to ProblemDetail responses. So you inherit consistent handling for the framework's exceptions, and add just four things of your own: a handler for each of your two exceptions, a tweak to the validation response, and a last-resort catch-all.

src/main/java/com/example/library/error/ApiExceptionHandler.java
package com.example.library.error;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@Slf4j // generates the private static final Logger log field
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
// Our own "missing resource" -> 404
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handleNotFound(NotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource not found");
return problem;
}
// A valid request that the current state of the data forbids -> 409
@ExceptionHandler(ConflictException.class)
public ProblemDetail handleConflict(ConflictException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Conflict");
return problem;
}
// The parent already turns a validation failure into a 400 ProblemDetail;
// we OVERRIDE its handler only to attach a field -> message map.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problem = ex.getBody(); // the ProblemDetail Spring already built
problem.setTitle("Validation failed");
problem.setDetail("One or more fields are invalid.");
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
// Last resort: anything not matched above -> 500. Log the real cause; tell the client nothing.
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
}
}
What @RestControllerAdvice and ProblemDetail buy you

@RestControllerAdvice is a @ControllerAdvice whose handler return values are written to the response body (the same way @RestController works). Its @ExceptionHandler methods catch exceptions from every controller, so error formatting lives in exactly one place — change it once, the whole API changes. ProblemDetail is Spring's representation of RFC 9457: a standard error body with type, title, status, detail, and instance, plus any custom members you add (our errors map). Returning it sets the application/problem+json content type and the status automatically.

Why extend ResponseEntityExceptionHandler (and override, not re-add, validation)

Your own handlers only cover the exceptions you name. Everything else — a malformed JSON body, /api/books/not-a-uuid, a PATCH on a route that has none, a wrong Content-Type — is thrown by Spring before your code runs. Left alone, those fall through to Spring Boot's default error page (a different body shape, breaking your one-contract goal). Extending ResponseEntityExceptionHandler gives you correct, ProblemDetail-shaped responses for all of them for free:

  • a malformed JSON body → 400
  • a path or query value of the wrong type, such as an id that isn't a UUID → 400
  • a method the route doesn't support → 405
  • a Content-Type the endpoint can't read → 415

That's also why validation is an @Override of handleMethodArgumentNotValid, not a fresh @ExceptionHandler: the parent already handles that exception, so you hook its existing ProblemDetail (ex.getBody()), attach your errors map, and hand it back — rather than duplicating (and fighting) the parent's handler.

The catch-all: two rules that matter

The @ExceptionHandler(Exception.class) is the safety net for the genuinely unexpected — a database constraint violation, an NullPointerException — so nothing ever escapes as a raw stack trace.

  • Log the cause, leak nothing. Record the real exception server-side (log.error), but return a generic message. Echoing ex.getMessage() or a stack trace to clients exposes internals — a real security mistake.
  • It's greedy — order saves you. Exception is the supertype of everything, so on its own this handler would swallow the framework's 4xx and turn a bad-UUID 400 into a 500. Because you extend ResponseEntityExceptionHandler, its specific handlers out-rank Exception.class (Spring picks the most specific match), so the catch-all only fires for what nothing else claimed.

Step 5 — Make the services throw

With a handler in place, the layers below stop returning "absence" and start throwing when something's missing. The service becomes the single source of the not-found decision:

src/main/java/com/example/library/book/BookService.java
8 collapsed lines
package com.example.library.book;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.example.library.author.Author;
import com.example.library.author.AuthorRepository;
import com.example.library.error.NotFoundException; // new; Optional and NoSuchElementException are no longer needed
17 collapsed lines
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository books;
private final AuthorRepository authors; // new: to resolve author ids
public BookService(BookRepository books, AuthorRepository authors) {
this.books = books;
this.authors = authors;
}
public List<Book> findAll(UUID authorId) {
return (authorId == null) ? books.findAll() : books.findByAuthorsId(authorId);
}
// Replaces findById: the service now decides "not found" and throws instead of returning an Optional.
public Book getById(UUID id) {
return books.findById(id)
.orElseThrow(() -> new NotFoundException("No book with id " + id));
}
6 collapsed lines
public Book create(String title, int year, Set<UUID> authorIds) {
Book book = new Book(title, year, requireAuthors(authorIds));
return books.save(book);
}
public Book replace(UUID id, String title, int year, Set<UUID> authorIds) {
if (!books.existsById(id)) {
throw new NotFoundException("No book with id " + id);
}
Book book = new Book(title, year, requireAuthors(authorIds));
book.setId(id);
return books.save(book);
}
public void delete(UUID id) {
if (!books.existsById(id)) {
throw new NotFoundException("No book with id " + id);
}
books.deleteById(id);
}
// Every id must resolve to a real author, or the book isn't created at all.
private Set<Author> requireAuthors(Set<UUID> authorIds) {
List<Author> found = authors.findAllById(authorIds);
if (found.size() != authorIds.size()) {
Set<UUID> missing = new HashSet<>(authorIds);
found.forEach(a -> missing.remove(a.getId()));
throw new NotFoundException("No author with id " + missing);
}
return new HashSet<>(found);
}
}

That last method is the fix for Lesson 6's deliberate rough edge: requireAuthors already threw, but as an unhandled exception it surfaced as a blunt 500. Now it throws NotFoundException — and because it names every missing id in the message, a client that sent three ids and mistyped one is told exactly which.

Why findById became getById

A naming convention worth adopting: find… means "might not be there" (returns an Optional, caller handles absence), while get… means "should be there" (returns the value directly, or throws). The contract just flipped — from an empty Optional to throwing — so the name flips too; leaving it findById would mislead callers into expecting an Optional. It's an established idiom, not an official Java rule — the clearest statement is Stephen Colebourne's Naming Optional query methods (he led java.time/JSR-310). You already use the find half: the repository's findById returns Optional — and the service's getById wraps it with .orElseThrow(...), marking the exact point where "maybe absent" becomes "here, or fail."

Bad authorIds: why 404 (and what else it could be)

Posting a book where any entry in authorIds matches no author returns 404 — "the author you referenced doesn't exist." That's a defensible, common choice. Some teams argue a referenced-entity problem in the request body is better expressed as 422 Unprocessable Entity (the request was well-formed but can't be fulfilled) or 400 Bad Request. Any of the three is reasonable; what matters is that it's a deliberate 4xx with a clear message, not an accidental 500. We use one NotFoundException for both missing-path and missing-reference to keep the model simple.

The author service gets the same treatment — and it's where Lesson 6's other rough edge gets fixed. Deleting an author who still has books ran into the foreign key and came back as a 500. The service should ask first. That takes one more derived query on the book repository; existsBy… makes Spring Data generate an EXISTS check instead of loading anything:

src/main/java/com/example/library/book/BookRepository.java
package com.example.library.book;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, UUID> {
// Traverses the collection: WHERE any of the book's authors has this id
List<Book> findByAuthorsId(UUID authorId);
// Is any book still linked to this author? (Spring Data derives an EXISTS query.)
boolean existsByAuthorsId(UUID authorId);
}
src/main/java/com/example/library/author/AuthorService.java
package com.example.library.author;
import java.util.List;
import java.util.UUID;
import com.example.library.book.BookRepository;
import com.example.library.error.ConflictException;
import com.example.library.error.NotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AuthorService {
private final AuthorRepository authors;
private final BookRepository books; // to ask: does this author still have books?
public AuthorService(AuthorRepository authors, BookRepository books) {
this.authors = authors;
this.books = books;
}
public List<Author> findAll() {
return authors.findAll();
}
public Author getById(UUID id) {
return authors.findById(id)
.orElseThrow(() -> new NotFoundException("No author with id " + id));
}
public Author create(String name) {
return authors.save(new Author(name));
}
public Author replace(UUID id, String name) {
if (!authors.existsById(id)) {
throw new NotFoundException("No author with id " + id);
}
Author author = new Author(name);
author.setId(id);
return authors.save(author);
}
public void delete(UUID id) {
if (!authors.existsById(id)) {
throw new NotFoundException("No author with id " + id);
}
if (books.existsByAuthorsId(id)) {
throw new ConflictException("Author " + id + " still has books; delete or re-author them first");
}
authors.deleteById(id);
}
}
404 vs. 409: two different refusals

Both are the client's problem, but they say different things. 404: the thing you named isn't there — nothing the client does to the rest of the data will change that. 409 Conflict: it's there, and your request is fine, but the data's current state won't allow it — change that state (delete or re-author the books) and the very same request succeeds. Picking the right one tells the client whether to give up or to fix something and retry.

Note what did not change: the foreign key is still in the database. The service's check makes the common case polite; the constraint remains the guarantee. If a book were linked to the author in the instant between the check and the delete, the key would still refuse — and the catch-all would report a 500, which for a genuine race is an honest answer.

Step 6 — Thin out the controllers

Because the service throws and the advice handles, the controllers shed all their Optional-to-status plumbing. Compare the byId handler before and after:

// Before (Lessons 4-6): the controller handled "not found"
@GetMapping("/{id}")
public ResponseEntity<BookResponse> byId(@PathVariable UUID id) {
return books.findById(id)
.map(BookResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// After: the service throws, the global handler answers. The controller just maps.
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}

replace collapses the same way. Its return type drops from ResponseEntity<BookResponse> to BookResponse, the .map().orElse(...) chain disappears, and it keeps the @Valid you added to its body in Step 3:

src/main/java/com/example/library/book/BookController.java
32 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books or ?authorId=...
public List<BookResponse> all(@RequestParam(required = false) UUID authorId) {
return books.findAll(authorId).stream().map(BookResponse::from).toList();
}
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
8 collapsed lines
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
7 collapsed lines
@DeleteMapping("/{id}") // DELETE /api/books/{id}
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return books.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}

Delete becomes equally direct — no body, so declare the success status and let a missing id throw:

src/main/java/com/example/library/book/BookController.java
49 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books or ?authorId=...
public List<BookResponse> all(@RequestParam(required = false) UUID authorId) {
return books.findAll(authorId).stream().map(BookResponse::from).toList();
}
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
books.delete(id); // throws NotFoundException -> 404
}
}

That delete needs two imports the file didn't have before; ResponseEntity stays, because create still returns a 201 with a Location header:

src/main/java/com/example/library/book/BookController.java
6 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
9 collapsed lines
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
39 collapsed lines
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books or ?authorId=...
public List<BookResponse> all(@RequestParam(required = false) UUID authorId) {
return books.findAll(authorId).stream().map(BookResponse::from).toList();
}
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
books.delete(id); // throws NotFoundException -> 404
}
}

The author controller thins out the same way — it keeps both @Valids from Step 3, returns DTOs directly, and has a delete that can now end in three different statuses without a single branch in the controller:

src/main/java/com/example/library/author/AuthorController.java
package com.example.library.author;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/authors")
public class AuthorController {
private final AuthorService authors;
public AuthorController(AuthorService authors) {
this.authors = authors;
}
@GetMapping
public List<AuthorResponse> all() {
return authors.findAll().stream().map(AuthorResponse::from).toList();
}
@GetMapping("/{id}")
public AuthorResponse byId(@PathVariable UUID id) {
return AuthorResponse.from(authors.getById(id));
}
@PostMapping
public ResponseEntity<AuthorResponse> create(@Valid @RequestBody CreateAuthorRequest req) {
Author saved = authors.create(req.name());
URI location = URI.create("/api/authors/" + saved.getId());
return ResponseEntity.created(location).body(AuthorResponse.from(saved));
}
@PutMapping("/{id}")
public AuthorResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateAuthorRequest req) {
return AuthorResponse.from(authors.replace(id, req.name()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
authors.delete(id); // NotFoundException -> 404, ConflictException -> 409
}
}

The same pairing holds: once AuthorService.getById throws, byId must shed its Optional chain or it won't compile.

The payoff from Lesson 4's aside

Back in Lesson 4 we noted the Optional-in-the-controller pattern was a stepping stone, and that the production approach is "throw + one global handler." This is that step. Every controller method is now a thin translator with no error branches; the not-found logic exists once, in the service; the response formatting exists once, in the advice. One concern, one place — three times over.

Step 7 — Run it and watch the errors

Restart the app and probe the failure paths. Each one now comes back as application/problem+json:

Terminal window
# Blank title, negative year, missing authorIds -> 400 with a field map
curl -s -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":" ","year":-5}'
{
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/books",
"errors": {
"title": "must not be blank",
"year": "must be greater than 0",
"authorIds": "must not be empty"
}
}
Terminal window
# Unknown book id -> 404 problem+json
curl -s http://localhost:8080/api/books/00000000-0000-0000-0000-000000000000
{
"title": "Resource not found",
"status": 404,
"detail": "No book with id 00000000-0000-0000-0000-000000000000",
"instance": "/api/books/00000000-0000-0000-0000-000000000000"
}
Terminal window
# Valid fields but one authorIds entry matches no author -> 404 (no more 500!)
curl -s -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Refactoring","year":2018,"authorIds":["<existing-author-id>","00000000-0000-0000-0000-000000000000"]}'
{
"title": "Resource not found",
"status": 404,
"detail": "No author with id [00000000-0000-0000-0000-000000000000]",
"instance": "/api/books"
}
Terminal window
# Delete an author who still has a book (David Thomas's id, from GET /api/authors) -> 409 (no more 500!)
curl -s -X DELETE http://localhost:8080/api/authors/01a0c863-2be1-77cd-8cba-b8492f994901
{
"title": "Conflict",
"status": 409,
"detail": "Author 01a0c863-2be1-77cd-8cba-b8492f994901 still has books; delete or re-author them first",
"instance": "/api/authors/01a0c863-2be1-77cd-8cba-b8492f994901"
}

He's still there afterwards, and this time nothing reached the database: the service refused before the foreign key had to. Delete an author with no books and you get the quiet 204; name an id that doesn't exist and it's the 404 above.

And the failures you never wrote a line for — handled by the inherited base class — come back in the very same shape:

Terminal window
# Path id that isn't a UUID -> 400 (type mismatch)
curl -s http://localhost:8080/api/books/not-a-uuid
# Wrong method on a real route -> 405
curl -s -X PATCH http://localhost:8080/api/books/<some-id>
# Wrong Content-Type -> 415
curl -s -X POST http://localhost:8080/api/books -H "Content-Type: text/plain" -d 'hi'
// GET /api/books/not-a-uuid
{ "title": "Bad Request", "status": 400,
"detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" }
// PATCH ... -> { "title": "Method Not Allowed", "status": 405, "detail": "Method 'PATCH' is not supported." }
// text/plain -> { "title": "Unsupported Media Type", "status": 415, "detail": "Content-Type 'text/plain;charset=UTF-8' is not supported." }

And anything truly unforeseen — say a title too long for its column — trips the catch-all: a generic 500 to the client, with the real cause logged server-side and never sent out.

{
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred.",
"instance": "/api/books"
}

Every failure now leaves the API in one consistent application/problem+json shape with the right status — your validation (400), not-found (404) and conflict (409), the framework's 4xx, and the unforeseen 500. That uniformity is what makes an API pleasant to consume.

If it doesn't behave
  • Bad input returns 200/201 instead of 400? A constraint annotation with no @Valid at the @RequestBody, or the validation starter isn't on the classpath. Both are required.
  • Validation fails but the body is Spring's default, not your Problem Detail? The advice isn't being found — make sure ApiExceptionHandler is under com.example.library so component scanning picks it up, and that it's annotated @RestControllerAdvice.
  • Still getting a 500 for a bad authorIds entry? requireAuthors is throwing something other than NotFoundException (e.g. the old NoSuchElementException), so no handler matches. Throw the domain exception.
  • @NotNull on year does nothing? int is a primitive — it can't be null. Use @Positive/@Min for numbers; use @NotEmpty for a collection like Set<UUID> authorIds, since @NotNull alone wouldn't catch an empty set.

Check yourself

Answer from memory before revealing.

A CreateBookRequest field has @NotBlank, but blank titles still get through. What's the most likely cause?
What does a single @RestControllerAdvice give your API?
After the refactor, what happens in byId when the book id is unknown?
Why does @NotNull on an int year field accomplish nothing?

Read this next

Primary source: Baeldung's Error Handling for REST with Spring — the canonical walkthrough of @RestControllerAdvice, @ExceptionHandler, and ProblemDetail. Pair it with Validation in Spring Boot for the constraint side. For the standard your errors now follow, skim RFC 9457 (Problem Details for HTTP APIs).

Glossary: the terms this lesson introduces are in the Glossary, under Lesson 7 — your quick-reference for every term we use.

Architecture decisions: the 8 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 7.

Code: the project at the end of this lesson · what changed in this lesson, exercises included.

💬 Discuss