@ControllerAdvice and @ExceptionHandler — return consistent error responses.
Published September 21, 2026
When something goes wrong in an API (a resource isn't found, input is invalid, a dependency is down), the client needs two things: the right HTTP status and an error body in a predictable shape it can parse. Without a deliberate strategy, each controller handles errors its own way, and unexpected exceptions leak Spring's default error page, or worse, internal details like SQL or class names.
Global exception handling puts all of that in one place. Controllers and services simply throw, and a single @RestControllerAdvice class decides how each kind of exception becomes an HTTP response.
@ExceptionHandler(SomeException.class): a method that handles that exception type (and its subclasses). Placed inside one controller, it only covers that controller.@RestControllerAdvice: a class whose @ExceptionHandler methods apply to all controllers. It's @ControllerAdvice + @ResponseBody.@ResponseStatus: sets the status code for a handler method (or directly on an exception class).Start by giving your application its own exceptions, each carrying the meaning of the failure, not an HTTP detail:
public abstract class AppException extends RuntimeException {
protected AppException(String message) { super(message); }
}
public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String resource, String id) {
super(resource + " not found: " + id);
}
}
public class BusinessRuleException extends AppException { // e.g. "cannot cancel a shipped order"
public BusinessRuleException(String message) { super(message); }
}
The service layer throws these without knowing anything about HTTP. The mapping to status codes happens in exactly one place.
Spring Boot 3 supports ProblemDetail, the standard error format from RFC 9457 (formerly RFC 7807). It has fixed fields (type, title, status, detail, instance) and room for your own. Using a standard means clients and tools already know how to read your errors.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(BusinessRuleException.class)
public ProblemDetail handleBusinessRule(BusinessRuleException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
// Bean Validation failures on @Valid request bodies
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "message", String.valueOf(e.getDefaultMessage())))
.toList());
return pd;
}
// Malformed JSON, or a path/query value of the wrong type (e.g. /orders/abc for a numeric id)
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class})
public ProblemDetail handleBadInput(Exception ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Malformed request");
}
// Safety net: everything else is a bug or an outage — log it fully, reveal nothing
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
String errorId = UUID.randomUUID().toString();
log.error("Unhandled error {} on {} {}", errorId, request.getMethod(), request.getRequestURI(), ex);
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
pd.setProperty("errorId", errorId); // the client can quote this; you can find it in the logs
return pd;
}
}
A client now receives, for example:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Order not found: 9f2c",
"instance": "/api/v1/orders/9f2c"
}
When an exception is thrown, Spring looks for the most specific matching handler: an exact type match beats a handler for its superclass. So ResourceNotFoundException goes to handleNotFound, not to the catch-all Exception handler, regardless of the order of the methods in the class. Handlers inside a controller take priority over global advice. If you have several advice classes, @Order decides which is consulted first, and @RestControllerAdvice(basePackages = "...") can limit one to part of the app.
Spring MVC already knows how to map its own exceptions: unsupported HTTP method → 405, unsupported media type → 415, missing request parameter → 400. Extending ResponseEntityExceptionHandler gives you all of those in ProblemDetail format. You then add handlers only for your own exceptions:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// your @ExceptionHandler methods for domain exceptions go here
}
Setting spring.mvc.problemdetails.enabled=true achieves a similar result without a custom class.
@ControllerAdvice does not catchIt only sees exceptions thrown inside Spring MVC's handling of a request, from controllers, argument resolution or validation. It does not see:
DispatcherServlet. Customize those responses with an AuthenticationEntryPoint (401) and an AccessDeniedHandler (403).@Async methods or scheduled jobs. There's no HTTP request to answer. Use an AsyncUncaughtExceptionHandler and logging.DEBUG or WARN, without stack traces, or your logs fill with "user typed a bad ID".ERROR with the stack trace and a correlation ID, and alert on their rate.catch (Exception e) { return null; }). The client gets a misleading 200 or 404, and the real failure is invisible.RuntimeException("not found") everywhere, then trying to parse messages in the handler. Use typed exceptions.500. That pollutes your error metrics.Q: @ControllerAdvice vs @RestControllerAdvice?
A: @RestControllerAdvice = @ControllerAdvice + @ResponseBody. The handler methods' return values are written as the response body (JSON). With plain @ControllerAdvice, returned values are treated as view names unless each method adds @ResponseBody. For REST APIs use @RestControllerAdvice.
Q: Should the service layer throw exceptions that know about HTTP status codes?
A: Preferably not. Services can be called from message consumers, schedulers or other services where HTTP doesn't exist. Throw exceptions that describe what went wrong in domain terms (ResourceNotFoundException, InsufficientStockException) and map them to statuses in the web layer's advice. ResponseStatusException is fine for quick cases inside controllers themselves.
Q: Why return ProblemDetail instead of your own error class?
A: It's an IETF standard (RFC 9457) with a fixed set of fields that clients, API gateways and documentation tools already understand, and it's built into Spring 6 / Boot 3. You can still add your own properties (an errors list, an errorId), so you lose nothing.
Q: How do you customize the response when an unauthenticated user calls a protected endpoint?
A: That rejection happens in the Spring Security filter chain, before any controller or @ControllerAdvice runs. Configure an AuthenticationEntryPoint (for 401) and an AccessDeniedHandler (for 403) in your security configuration, and have them write the same ProblemDetail shape so all errors still look alike.
Q: How do you test exception handling?
A: With @WebMvcTest and MockMvc: make the mocked service throw the exception, then assert the status and body, for example .andExpect(status().isNotFound()).andExpect(jsonPath("$.detail").value("Order not found: 9f2c")). This checks the mapping end to end without starting the full application.