PrepZone Logo
PrepZone

@ControllerAdvice and Problem Details

Centralised error handling with @ExceptionHandler and RFC 7807 Problem Details.

Why this matters

  • Scattered try-catch in controllers duplicates error handling and produces inconsistent JSON error shapes.
  • RFC 7807 Problem Details give clients a standard error format with type, title, status, and detail.
  • Global exception handling is a production requirement — unhandled exceptions should never leak stack traces.

Domain exceptions

Java
public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) {
        super("Book not found with id: " + id);
    }
}

public class DuplicateIsbnException extends RuntimeException {
    public DuplicateIsbnException(String isbn) {
        super("Book with ISBN " + isbn + " already exists");
    }
}

Services throw domain exceptions; controllers stay clean.

Global exception handler

Java
@RestControllerAdvice
public class BookStoreExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleNotFound(BookNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Book Not Found");
        problem.setType(URI.create("https://bookstore.example.com/errors/not-found"));
        return problem;
    }

    @ExceptionHandler(DuplicateIsbnException.class)
    public ProblemDetail handleDuplicate(DuplicateIsbnException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Duplicate ISBN");
        return problem;
    }
}

ProblemDetail (Spring 6+) implements RFC 7807 natively.

Controller throwsBookNotFoundException
@ControllerAdviceGlobalExceptionHandler
ProblemDetailRFC 7807 JSON response
@ControllerAdvice catches exceptions from any controller and returns a consistent error shape.

Validation errors

Java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("Validation Failed");

    Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
        .collect(Collectors.toMap(
            FieldError::getField,
            FieldError::getDefaultMessage,
            (a, b) -> a));

    problem.setProperty("errors", errors);
    return problem;
}

Clients receive field-level errors:

Java
{
  "type": "about:blank",
  "title": "Validation Failed",
  "status": 400,
  "errors": {
    "isbn": "ISBN must be 13 digits",
    "price": "must be greater than 0"
  }
}

Catch-all handler

Java
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
    log.error("Unhandled exception", ex);
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problem.setTitle("Internal Server Error");
    problem.setDetail("An unexpected error occurred");
    return problem;
}

Never include stack traces in production responses. Log them server-side.

HTTP status mapping for BookStore

  • 400 Bad Request — Validation failures, malformed JSON.
  • 404 Not Found — BookNotFoundException, unknown URL.
  • 409 Conflict — DuplicateIsbnException, optimistic lock failure.
  • 500 Internal Server Error — Unexpected exceptions (logged, not detailed).

Controller stays clean

Java
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
    return BookResponse.from(bookService.findById(id));
    // BookNotFoundException propagates to @RestControllerAdvice
}

No try-catch needed. The advice converts exceptions to HTTP responses automatically.

ResponseEntity alternative

For per-endpoint control without global advice:

Java
@GetMapping("/{id}")
public ResponseEntity<BookResponse> get(@PathVariable Long id) {
    try {
        return ResponseEntity.ok(BookResponse.from(bookService.findById(id)));
    } catch (BookNotFoundException ex) {
        return ResponseEntity.notFound().build();
    }
}

Prefer @RestControllerAdvice — one place to maintain error contracts.

Testing exception handling

Java
@WebMvcTest(BookController.class)
class BookControllerExceptionTest {
    @Autowired MockMvc mockMvc;
    @MockBean BookService bookService;

    @Test
    void notFoundReturns404() throws Exception {
        when(bookService.findById(99L)).thenThrow(new BookNotFoundException(99L));

        mockMvc.perform(get("/api/books/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.title").value("Book Not Found"));
    }
}

Quick recall

Everything you need if you only revisit this box.

  • @RestControllerAdvice applies exception handlers across all controllers.
  • Domain exceptions (BookNotFoundException) carry business meaning; the advice maps them to HTTP status.
  • ProblemDetail provides RFC 7807 structured error responses.
  • MethodArgumentNotValidException handler returns field-level validation errors.
  • Controllers stay free of try-catch — exceptions propagate to the advice.
  • Log unexpected exceptions server-side; never expose stack traces to clients.

Test yourself

Answer these before moving on — recall is what makes it stick.