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 authorId 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 the 500 from Lesson 6's bad authorId becomes a clean 404, 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 — inside <dependencies>

<!-- Bean Validation (@Valid + constraints); brings Hibernate Validator -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

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.UUID;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record CreateBookRequest(
    @NotBlank String title,     // not null, and not just whitespace
    @Positive int year,         // must be > 0
    @NotNull UUID authorId      // must be present
) {}

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.)

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:

@PostMapping
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) { ... }

@PutMapping("/{id}")
public ResponseEntity<BookResponse> replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) { ... }

@Valid is the only change here — 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.) Do the same on AuthorController.create. 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, a domain exception to signal "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);
    }
}

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 three things of your own: a handler for your NotFoundException, 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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    // 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;
    }

    // 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:

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.

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 — the changed methods

public Book getById(UUID id) {
    return books.findById(id)
        .orElseThrow(() -> new NotFoundException("No book with id " + id));
}

public Book replace(UUID id, String title, int year, UUID authorId) {
    if (!books.existsById(id)) {
        throw new NotFoundException("No book with id " + id);
    }
    Book book = new Book(title, year);
    book.setId(id);
    book.setAuthor(requireAuthor(authorId));
    return books.save(book);
}

public void delete(UUID id) {
    if (!books.existsById(id)) {
        throw new NotFoundException("No book with id " + id);
    }
    books.deleteById(id);
}

private Author requireAuthor(UUID authorId) {
    return authors.findById(authorId)
        .orElseThrow(() -> new NotFoundException("No author with id " + authorId));
}

That last method is the fix for Lesson 6's deliberate rough edge: requireAuthor already threw, but as an unhandled exception it surfaced as a blunt 500. Now it throws NotFoundException, and the handler turns it into a clean 404. (Give AuthorService the same getById treatment.)

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 authorId: why 404 (and what else it could be) Posting a book whose authorId 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.

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:

@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
    return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorId()));
}

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

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)     // 204 on success
public void delete(@PathVariable UUID id) {
    books.delete(id);                      // throws NotFoundException -> 404
}

Now mirror all of this on the author side — the changes are identical, just fewer (AuthorController has no replace/delete):

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:

# Blank title, negative year, missing authorId -> 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",
    "authorId": "must not be null"
  }
}
# 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"
}
# Valid fields but an authorId that 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,"authorId":"00000000-0000-0000-0000-000000000000"}'
# -> { "title":"Resource not found", "status":404, "detail":"No author with id 0000..." }

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

# 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) and not-found (404), the framework's 4xx, and the unforeseen 500. That uniformity is what makes an API pleasant to consume.

If it doesn't behave

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).

New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.