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+@ExceptionHandlercentralizes all error handlingProblemDetail(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.classhandler to prevent leaking stack traces - Test exception handling with
@WebMvcTestandMockMvc
Next: Article 13 — Pagination and Sorting — returning large datasets page by page with Spring Data’s Pageable and Page.