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:

Java
@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:

Java
@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:

Java
@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;
    }
}
JSON
{
  "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:

Java
@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 {};
}
Java
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();
    }
}
Java
public record CreateBookRequest(
    @NotBlank String title,
    @NotBlank String author,
    @ValidIsbn String isbn
) {}
Java
@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 @ExceptionHandler methods 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 ConstraintValidator needs to handle null explicitly if the field is optional — @NotNull/@NotBlank should be declared separately for "required," rather than baking a null-check into every custom validator.