Exception Handling & Validation
@ExceptionHandler and @ControllerAdvice for global exception handling, ProblemDetail, and @Valid with a custom validator.
@ExceptionHandler: local exception handling
A method annotated @ExceptionHandler inside a controller catches exceptions thrown by that controller's own handler methods, converting them into a controlled response instead of letting the default error page/500 response through:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/{id}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id); // throws BookNotFoundException if missing
}
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("BOOK_NOT_FOUND", ex.getMessage()));
}
}
record ErrorResponse(String code, String message) {}
This scopes the handling to just this one controller — fine for something genuinely specific to it, but most applications have a handful of exception types (validation failures, "not found," access denied) that should be handled the same way everywhere, which is what @ControllerAdvice is for.
@ControllerAdvice: global exception handling
A class annotated @ControllerAdvice (or @RestControllerAdvice, which adds @ResponseBody) applies its @ExceptionHandler methods across every controller in the application, in one central place:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("BOOK_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_FAILED", message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
// a catch-all, last resort -- never leak the raw exception message to the client
return ResponseEntity.internalServerError()
.body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong"));
}
}
Spring matches the most specific applicable @ExceptionHandler for a thrown exception's type — a handler for BookNotFoundException wins over a broader Exception handler when both could technically apply, so a general catch-all doesn't swallow more specific handling.
ProblemDetail: a standard error body (RFC 7807)
Rather than a custom ErrorResponse shape per application, Spring Framework 6 has built-in support for the RFC 7807 "Problem Details" standard, via ProblemDetail:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Book Not Found");
problem.setProperty("bookId", ex.getBookId());
return problem;
}
}
{
"type": "about:blank",
"title": "Book Not Found",
"status": 404,
"detail": "No book with id 42",
"bookId": 42
}
ProblemDetail gives every error response a consistent, standardized shape without hand-rolling one — useful for a public API consumed by clients who benefit from a well-known error format.
@Valid with a custom validator
Beyond Bean Validation's built-in constraints (@NotBlank, @Size, @Email), a custom constraint annotation validates domain-specific rules:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IsbnValidator.class)
public @interface ValidIsbn {
String message() default "Invalid ISBN format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class IsbnValidator implements ConstraintValidator<ValidIsbn, String> {
private static final Pattern ISBN_13 = Pattern.compile("^97[89]\\d{10}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value != null && ISBN_13.matcher(value.replace("-", "")).matches();
}
}
public record CreateBookRequest(
@NotBlank String title,
@NotBlank String author,
@ValidIsbn String isbn
) {}
@PostMapping
public ResponseEntity<Book> createBook(@Valid @RequestBody CreateBookRequest request) {
return ResponseEntity.ok(bookService.create(request));
}
A validation failure on a @RequestBody throws MethodArgumentNotValidException automatically — no BindingResult check needed for a JSON API, since there's no form to re-render; the @ControllerAdvice handler above catches it centrally instead.
Common mistakes
- Writing per-controller
@ExceptionHandlermethods for exception types that are actually common across the whole application, duplicating the same handling logic in every controller instead of centralizing it in one@ControllerAdvice. - Registering a broad
@ExceptionHandler(Exception.class)and forgetting it will also swallow validation and "not found" exceptions unless more specific handlers for those types are also registered — Spring resolves to the most specific match, but only if that more specific handler actually exists. - Leaking a raw exception's message or stack trace directly into an API response — the catch-all handler for unexpected exceptions should return a generic, safe message and log the real detail server-side instead.
- Forgetting that a custom
ConstraintValidatorneeds to handlenullexplicitly if the field is optional —@NotNull/@NotBlankshould be declared separately for "required," rather than baking a null-check into every custom validator.