Global Exception Handling with @ControllerAdvice and ProblemDetail

Without global exception handling, Spring returns raw stack traces or inconsistent error shapes. This article shows how to centralize all error handling in one place and return structured, RFC 7807-compliant responses.

The Problem Without Global Handling

Default Spring Boot error responses are inconsistent:

// Validation failure (MethodArgumentNotValidException)
{
  "timestamp": "2026-05-03T10:00:00.000+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/orders"
}
// Details of which fields failed? Not included.

// Custom exception not handled
// → 500 Internal Server Error with a stack trace in the body (in dev mode)

What clients actually need:

{
  "type": "https://devopsmonk.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "3 fields failed validation",
  "instance": "/api/orders",
  "errors": [
    {"field": "customerId", "message": "Customer ID is required"},
    {"field": "items[0].quantity", "message": "Quantity must be positive"},
    {"field": "shippingAddress.country", "message": "Country must be a 2-letter ISO code"}
  ]
}

RFC 7807: ProblemDetail

Spring Boot 3.0+ ships built-in support for RFC 7807 Problem Details via ProblemDetail. This is the standard for structured HTTP error responses.

Standard fields:
  type      URI identifying the error type (links to documentation)
  title     Short human-readable summary
  status    HTTP status code
  detail    Human-readable explanation for this specific error
  instance  URI of the specific request that failed

Custom extensions:
  Any additional properties you add

Enable it:

spring:
  mvc:
    problemdetails:
      enabled: true

With this flag, Spring’s built-in exceptions (MethodArgumentNotValidException, NoHandlerFoundException, etc.) automatically return ProblemDetail responses.

@ControllerAdvice — The Global Handler

@ControllerAdvice is a special @Component that intercepts exceptions thrown by any controller:

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // Handles validation failures (@Valid on @RequestBody)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "Request validation failed"
        );
        problem.setType(URI.create("https://devopsmonk.com/errors/validation-failed"));
        problem.setTitle("Validation Failed");
        problem.setInstance(URI.create(request.getRequestURI()));

        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors()
            .stream()
            .map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
            .toList();

        problem.setProperty("errors", fieldErrors);
        problem.setProperty("errorCount", fieldErrors.size());

        return ResponseEntity.badRequest().body(problem);
    }

    // Handles @PathVariable / @RequestParam constraint violations
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ProblemDetail> handleConstraintViolation(
            ConstraintViolationException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Constraint violation"
        );
        problem.setType(URI.create("https://devopsmonk.com/errors/constraint-violation"));
        problem.setTitle("Constraint Violation");
        problem.setInstance(URI.create(request.getRequestURI()));

        List<FieldError> violations = ex.getConstraintViolations().stream()
            .map(v -> new FieldError(
                v.getPropertyPath().toString(),
                v.getMessage()
            ))
            .toList();
        problem.setProperty("errors", violations);

        return ResponseEntity.badRequest().body(problem);
    }

    // Catch-all for unexpected errors
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleGenericException(
            Exception ex,
            HttpServletRequest request) {

        log.error("Unhandled exception for request {}", request.getRequestURI(), ex);

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred"
        );
        problem.setType(URI.create("https://devopsmonk.com/errors/internal-error"));
        problem.setTitle("Internal Server Error");
        problem.setInstance(URI.create(request.getRequestURI()));

        return ResponseEntity.internalServerError().body(problem);
    }

    // DTO for field-level errors
    public record FieldError(String field, String message) {}
}

Domain Exceptions → HTTP Responses

Define your domain exceptions and map them to status codes in the handler:

// Base exception
public abstract class OrderServiceException extends RuntimeException {
    protected OrderServiceException(String message) { super(message); }
    protected OrderServiceException(String message, Throwable cause) { super(message, cause); }
}

// Specific exceptions
public class OrderNotFoundException extends OrderServiceException {
    private final UUID orderId;

    public OrderNotFoundException(UUID orderId) {
        super("Order not found: " + orderId);
        this.orderId = orderId;
    }

    public UUID getOrderId() { return orderId; }
}

public class OrderAlreadyCancelledException extends OrderServiceException {
    public OrderAlreadyCancelledException(UUID orderId) {
        super("Order " + orderId + " is already cancelled");
    }
}

public class InsufficientInventoryException extends OrderServiceException {
    private final UUID productId;
    private final int requested;
    private final int available;

    public InsufficientInventoryException(UUID productId, int requested, int available) {
        super("Insufficient inventory for product " + productId);
        this.productId = productId;
        this.requested = requested;
        this.available = available;
    }

    // getters
}

Handle them in GlobalExceptionHandler:

@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ProblemDetail> handleOrderNotFound(
        OrderNotFoundException ex, HttpServletRequest request) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND, ex.getMessage()
    );
    problem.setType(URI.create("https://devopsmonk.com/errors/order-not-found"));
    problem.setTitle("Order Not Found");
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("orderId", ex.getOrderId());

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

@ExceptionHandler(OrderAlreadyCancelledException.class)
public ResponseEntity<ProblemDetail> handleAlreadyCancelled(
        OrderAlreadyCancelledException ex, HttpServletRequest request) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT, ex.getMessage()
    );
    problem.setType(URI.create("https://devopsmonk.com/errors/order-already-cancelled"));
    problem.setTitle("Order Already Cancelled");
    problem.setInstance(URI.create(request.getRequestURI()));

    return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
}

@ExceptionHandler(InsufficientInventoryException.class)
public ResponseEntity<ProblemDetail> handleInsufficientInventory(
        InsufficientInventoryException ex, HttpServletRequest request) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()
    );
    problem.setType(URI.create("https://devopsmonk.com/errors/insufficient-inventory"));
    problem.setTitle("Insufficient Inventory");
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("productId", ex.getProductId());
    problem.setProperty("requested", ex.getRequested());
    problem.setProperty("available", ex.getAvailable());

    return ResponseEntity.unprocessableEntity().body(problem);
}

@ResponseStatus on Exception Classes

For simple mappings, annotate the exception class directly — no handler method needed:

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) { super(message); }
}

Spring returns 404 when this exception is thrown. The body is the default Spring error response. For ProblemDetail-formatted responses, use the explicit @ExceptionHandler approach.

Handling Multiple Exceptions with One Handler

@ExceptionHandler({
    OrderNotFoundException.class,
    CustomerNotFoundException.class,
    ProductNotFoundException.class
})
public ResponseEntity<ProblemDetail> handleNotFound(
        RuntimeException ex, HttpServletRequest request) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND, ex.getMessage()
    );
    problem.setType(URI.create("https://devopsmonk.com/errors/not-found"));
    problem.setTitle("Resource Not Found");
    problem.setInstance(URI.create(request.getRequestURI()));

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

The Complete GlobalExceptionHandler

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    private static final String ERROR_BASE = "https://devopsmonk.com/errors/";

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest req) {

        var errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
            .toList();

        ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "validation-failed",
            "Validation Failed", "Request body validation failed", req);
        p.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(p);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ProblemDetail> handleConstraintViolation(
            ConstraintViolationException ex, HttpServletRequest req) {

        var errors = ex.getConstraintViolations().stream()
            .map(v -> new FieldError(v.getPropertyPath().toString(), v.getMessage()))
            .toList();

        ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "constraint-violation",
            "Constraint Violation", "Parameter validation failed", req);
        p.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(p);
    }

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleOrderNotFound(
            OrderNotFoundException ex, HttpServletRequest req) {

        ProblemDetail p = problem(HttpStatus.NOT_FOUND, "order-not-found",
            "Order Not Found", ex.getMessage(), req);
        p.setProperty("orderId", ex.getOrderId());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(p);
    }

    @ExceptionHandler(InsufficientInventoryException.class)
    public ResponseEntity<ProblemDetail> handleInventory(
            InsufficientInventoryException ex, HttpServletRequest req) {

        ProblemDetail p = problem(HttpStatus.UNPROCESSABLE_ENTITY,
            "insufficient-inventory", "Insufficient Inventory", ex.getMessage(), req);
        p.setProperty("productId", ex.getProductId());
        p.setProperty("requested", ex.getRequested());
        p.setProperty("available", ex.getAvailable());
        return ResponseEntity.unprocessableEntity().body(p);
    }

    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ProblemDetail> handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException ex, HttpServletRequest req) {

        ProblemDetail p = problem(HttpStatus.METHOD_NOT_ALLOWED, "method-not-allowed",
            "Method Not Allowed", ex.getMessage(), req);
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(p);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleGeneric(
            Exception ex, HttpServletRequest req) {

        log.error("Unhandled exception [{}] {}", req.getMethod(), req.getRequestURI(), ex);
        ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error",
            "Internal Server Error", "An unexpected error occurred", req);
        return ResponseEntity.internalServerError().body(p);
    }

    private ProblemDetail problem(HttpStatus status, String type,
                                  String title, String detail,
                                  HttpServletRequest req) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(status, detail);
        p.setType(URI.create(ERROR_BASE + type));
        p.setTitle(title);
        p.setInstance(URI.create(req.getRequestURI()));
        p.setProperty("timestamp", Instant.now());
        return p;
    }

    public record FieldError(String field, String message) {}
}

Testing Exception Handling

@WebMvcTest(OrderController.class)
class OrderControllerExceptionTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        UUID id = UUID.randomUUID();
        when(orderService.findById(id))
            .thenThrow(new OrderNotFoundException(id));

        mockMvc.perform(get("/api/orders/{id}", id))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.title").value("Order Not Found"))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.orderId").value(id.toString()));
    }

    @Test
    void shouldReturn400WithFieldErrorsOnValidationFailure() throws Exception {
        var invalidRequest = Map.of("items", List.of());  // no customerId, empty items

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(invalidRequest)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.title").value("Validation Failed"))
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors.length()").value(greaterThan(0)));
    }
}

What You’ve Learned

  • @ControllerAdvice + @ExceptionHandler centralizes all error handling
  • ProblemDetail (Spring Boot 3+) provides RFC 7807-compliant structured error responses
  • Map domain exceptions to appropriate HTTP status codes in handler methods
  • Include structured field-level errors in validation failure responses
  • Use the catch-all Exception.class handler to prevent leaking stack traces
  • Test exception handling with @WebMvcTest and MockMvc

Next: Article 13 — Pagination and Sorting — returning large datasets page by page with Spring Data’s Pageable and Page.