Errors are part of your API contract
Clients spend at least as much code handling your error responses as your success ones. An API that returns a 500 with a stack trace for a missing record, or a different error shape from every endpoint, is painful to integrate against. Spring MVC gives you a clean, centralized way to map any exception to a proper status code and a consistent body — once you know the pieces.
@ExceptionHandler: exception in, response out
A method annotated with @ExceptionHandler handles exceptions thrown by handler methods. Inside a
controller it's local to that controller:
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ApiError> handle(OrderNotFoundException ex) {
return ResponseEntity.status(404).body(new ApiError("NOT_FOUND", ex.getMessage()));
}
It can accept the exception (plus the request/response) and return any normal handler type. But repeating it in every controller is exactly the duplication we want to avoid.
@RestControllerAdvice: handle it once, globally
Move those handlers into a @RestControllerAdvice and they apply to every controller:
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
ResponseEntity<ApiError> notFound(EntityNotFoundException ex) {
log.warn("Not found: {}", ex.getMessage());
return ResponseEntity.status(404).body(new ApiError("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(Exception.class) // catch-all fallback
ResponseEntity<ApiError> generic(Exception ex) {
log.error("Unhandled", ex);
return ResponseEntity.status(500).body(new ApiError("INTERNAL", "Unexpected error"));
}
}
One advice class gives every endpoint the same error envelope, strips try/catch noise out of controllers, and is the natural place to log unexpected failures.
The quick path: @ResponseStatus on the exception
For trivial "this exception means that status" mappings, annotate the exception class itself:
@ResponseStatus(HttpStatus.NOT_FOUND)
class OrderNotFoundException extends RuntimeException {
OrderNotFoundException(Long id) { super("Order " + id + " not found"); }
}
Now throwing it anywhere yields a 404 — no handler needed. The catch is that you don't control the body shape, so it's best for the simplest cases.
What Boot does if you handle nothing
Anything that reaches the container hits Boot's BasicErrorController, which renders a JSON error
body for API clients (or the Whitelabel HTML page for browsers):
{ "timestamp": "...", "status": 500, "error": "Internal Server Error", "path": "/orders/999" }
That's a safety net, not a destination. Override it with a global advice, and never enable
server.error.include-stacktrace in production — it leaks internals.
Which handler wins?
When several handlers could match, Spring chooses the most specific exception type, and a
controller-local handler beats a global one for the same type. A thrown
IllegalStateException goes to a handler for it before one for RuntimeException. Order overlapping
catch-alls across advice beans with @Order.
Reshaping Spring's built-in exceptions
Validation failures, unreadable bodies, missing params, and wrong methods throw Spring's own MVC
exceptions. Extend ResponseEntityExceptionHandler to reshape them all consistently:
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders h,
HttpStatusCode status, WebRequest req) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage()).toList();
return ResponseEntity.badRequest().body(new ApiError("VALIDATION", errors));
}
}
You inherit handlers for every framework exception and override only the ones whose body you care about, then add your own domain handlers on top.
ProblemDetail: the standard error envelope
Spring 6 / Boot 3 ship ProblemDetail, an implementation of RFC 7807. Instead of inventing your
own JSON shape, return the standard one:
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handle(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
pd.setProperty("orderId", ex.getId()); // custom extension member
return pd; // application/problem+json
}
For new APIs this is the best default: standardized, minimal code, still extensible. You can even
turn it on for Spring's built-in exceptions with spring.mvc.problemdetails.enabled=true.
Designing a consistent error model
Whatever envelope you choose, give clients a stable machine-readable code separate from the human message, the right status, and a list of field errors when relevant:
record ApiError(String code, String message, Instant timestamp, List<String> details) {}
Return all validation errors at once rather than failing on the first, and keep internal messages and stack traces out of the response.
Log by severity
Don't log a stack trace for every 404 — that buries the real problems. Log expected client errors at
WARN (no trace) and unexpected server errors at ERROR (with trace), and attach a correlation id
so a log line can be tied to the response a client actually saw.
Know the boundaries
@ControllerAdvice only catches exceptions thrown during MVC handler invocation. Errors in @Async
methods need an AsyncUncaughtExceptionHandler; errors in filters happen before dispatch and must be
handled there. And to catch unknown-URL 404s in your advice, enable
spring.mvc.throw-exception-if-no-handler-found and handle NoHandlerFoundException.
Recap
Map exceptions to responses with @ExceptionHandler, centralize them in one @RestControllerAdvice,
use @ResponseStatus for trivial cases and ProblemDetail as your standard envelope, extend
ResponseEntityExceptionHandler to reshape framework exceptions, and design a stable error model
with machine-readable codes. Translate low-level exceptions into domain ones at the service boundary,
log by severity, and remember the advice only covers MVC handler execution. Do that and your API's
failures become as predictable as its successes.