intermediate~2h

Exception Handling & Validation

A REST API that returns a raw stack trace as a 500 response is unfinished. This module is how to make failure a first-class, designed part of your API contract.

Learning objectives

  • Beginner: A single @RestControllerAdvice handling one or two custom exception types plus a catch-all, for a small API.
  • Intermediate: A full exception hierarchy (e.g. a base ApiException with subclasses per error category) mapped consistently to the right HTTP status, with structured field-level validation errors.
  • Advanced: Error responses that include a machine-readable error code (for client-side logic to branch on) separate from the human-readable message, plus correlation/trace IDs for tying a client-reported error back to server logs.
public record CreateBookRequest( @NotBlank(message = "title is required") String title, @NotBlank String author, @Min(value = 1, message = "pages must be positive") int pages ) {}

@PostMapping public ResponseEntity createBook(@Valid @RequestBody CreateBookRequest request) { // @Valid triggers validation BEFORE this method body ever runs return ResponseEntity.status(HttpStatus.CREATED).body(bookService.create(request)); }

◆ Under the hood

@Valid is processed by an argument resolver that runs before your controller method body executes — a failed validation never reaches your business logic at all, throwing a MethodArgumentNotValidException that Spring converts to a 400 response automatically (or that you intercept for a custom shape, §10.3).

💻 Code example

public record CreateBookRequest( @NotBlank(message = "title is required") String title, @NotBlank String author, @Min(value = 1, message = "pages must be positive") int pages ) {}
public class BookNotFoundException extends RuntimeException { public BookNotFoundException(Long id) { super("No book found with id " + id); } }

public BookResponse findById(Long id) { Book book = bookRepository.findById(id) .orElseThrow(() -> new BookNotFoundException(id)); return bookMapper.toResponse(book); }

A custom, domain-named exception (BookNotFoundException rather than a generic RuntimeException) is what lets a centralized handler (§10.3) map it to a specific, correct HTTP status — mapping by exception type rather than parsing an error message string.

💻 Code example

public class BookNotFoundException extends RuntimeException { public BookNotFoundException(Long id) { super("No book found with id " + id); } }

◆ The problem

Wrapping every controller method in try/catch to convert exceptions into proper HTTP responses is repetitive and easy to forget in a new endpoint — exception handling logic needs to live in exactly one place, applied consistently across every controller.

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BookNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse("NOT_FOUND", ex.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String,String>> handleValidation(MethodArgumentNotValidException ex) { Map<String,String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage())); return ResponseEntity.badRequest().body(errors); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) { log.error("unhandled exception", ex); // log full detail server-side return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong")); // generic message to client } }

▲ Pitfall

The catch-all Exception handler above deliberately logs the full exception server-side but returns a generic message to the client — never return ex.getMessage() or a stack trace directly for unexpected exceptions, since internal error messages can leak implementation details (SQL fragments, internal class names, file paths) to an external caller.

💻 Code example

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BookNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse("NOT_FOUND", ex.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String,String>> handleValidation(MethodArgumentNotValidException ex) { Map<String,String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage())); return ResponseEntity.badRequest().body(errors); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) { log.error("unhandled exception", ex); // log full detail server-side return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong")); // generic message to client } }
public record ErrorResponse(String code, String message, Instant timestamp) { public ErrorResponse(String code, String message) { this(code, message, Instant.now()); } }

A single, predictable error shape across every endpoint (rather than each controller inventing its own) is what lets a frontend or API consumer write one generic error-handling path instead of special-casing every different error format your API might return.

✓ Quick recap

When does @Valid's validation run relative to your controller method body? Before it — a failed validation never reaches your method's code at all. Why map exceptions to HTTP status by exception type rather than parsing an error message? It's structurally reliable — a custom exception type is a stable contract a centralized handler can match on, unlike a string that might change. Why shouldn't a catch-all handler return the raw exception message to the client? It can leak internal implementation details — log the full detail server-side, return a generic message externally.

💻 Code example

public record ErrorResponse(String code, String message, Instant timestamp) { public ErrorResponse(String code, String message) { this(code, message, Instant.now()); } }

Want a visual for this concept?

Generate a diagram tailored to “Exception Handling & Validation” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Testing APIs & Dockerized Postgres← Back to all Spring Boot chapters