🧠 The big idea in one line
- When your controller code throws, something has to turn that exception into an HTTP response — a status code and a body — instead of leaking a stack trace.
- Spring gives you a small set of hooks to say "when this exception happens, send back this status and this body."
Why this exists:
- A REST endpoint can't just crash. The caller is a program that needs a clear status code (404, 400, 409…) and a predictable error body to react to.
- Without a plan, every controller writes its own
try/catch, and error responses come out inconsistent.
When you meet it:
- The moment you build a real API. As soon as "the user wasn't found" or "that input was invalid" needs to become a proper HTTP response.
🌊 What Spring does when you do nothing
Before adding any error handling, it helps to see the default path.
- In Spring MVC, one servlet receives every incoming request and routes it to the right controller method. That single entry point is the DispatcherServlet — think of it as the front door that dispatches each request to a handler.
- If your controller method throws and nothing catches it, the exception travels back up to that front door.
HTTP request
│
▼
DispatcherServlet ──► your @Controller method ──► throws RuntimeException
│ │
│ ◄────────────── exception bubbles back up ───────┘
▼
Exception resolvers ──► default: "Whitelabel Error Page" / generic 500 JSON
- The DispatcherServlet hands the exception to a chain of exception resolvers — objects whose job is to convert an exception into a response.
- With no configuration, the default resolver produces a generic 500 Internal Server Error (the plain "Whitelabel Error Page" in a browser, or a bare JSON error).
The problem:
- A
500is wrong for most errors. "User not found" should be 404; "bad input" should be 400. - The body is generic. Your API client gets nothing useful to branch on.
So the whole topic is really: how do I plug into that resolver step and return the right status and body?
🎯 Tool 1 — @ExceptionHandler on a controller
The first hook lives right inside a controller. You write a method and mark it as the handler for a given exception type.
- Suppose a lookup fails and you throw a custom
UserNotFoundException.
@RestController
class UserController {
@GetMapping("/users/{id}")
User getUser(@PathVariable String id) {
return repo.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
@ExceptionHandler(UserNotFoundException.class)
ResponseEntity<String> handleNotFound(UserNotFoundException ex) {
return ResponseEntity.status(404).body(ex.getMessage());
}
}
What happened here:
-
@ExceptionHandler(UserNotFoundException.class)marks a method as the catch point for that exception within this controller. - When any method in
UserControllerthrowsUserNotFoundException, Spring skips the normal return path and callshandleNotFoundinstead. - The method returns a
ResponseEntity— a full HTTP response: status line, headers, and body, all under your control. Here: status404, body = the message. - The exception object is passed in, so you can read its details to build the response.
⚠️ Easy to confuse: throwing vs. returning. The controller method throws; the handler method returns. The handler is not in the call stack of the failing method — Spring catches the exception and invokes the handler separately.
A shortcut for simple cases: @ResponseStatus
- If all you want is "this exception means this status code," you can skip the handler method entirely.
@ResponseStatus(HttpStatus.NOT_FOUND) // 404
class UserNotFoundException extends RuntimeException {
UserNotFoundException(String id) {
super("No user with id " + id);
}
}
-
@ResponseStatuson the exception class tells Spring: whenever this exception reaches the resolver, respond with this status. - No handler method needed. Good for simple, body-less cases.
✅ Use @ResponseStatus when the status is the whole response.
❌ Avoid it when you need a structured body or to add headers — reach for a handler method instead.
🌍 Tool 2 — @ControllerAdvice for the whole app
@ExceptionHandler inside one controller only helps that controller. Real apps have many controllers that throw the same errors. Copying handlers everywhere is the duplication we wanted to avoid.
- The fix is a class that holds handlers shared by every controller. In Spring, a class of cross-cutting controller logic is called an advice.
@RestControllerAdvice
class GlobalErrorHandler {
@ExceptionHandler(UserNotFoundException.class)
ResponseEntity<String> handleNotFound(UserNotFoundException ex) {
return ResponseEntity.status(404).body(ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<String> handleBadInput(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(ex.getMessage()); // 400
}
}
Walking through it:
-
@ControllerAdvicemarks a class whose@ExceptionHandlermethods apply to all controllers, not just one. -
@RestControllerAdviceis the same thing plus@ResponseBodybehavior baked in — the returned object becomes the JSON body directly. Use it for REST APIs. - One class now owns the mapping from exception → response for the entire application.
⚠️ Easy to confuse — the two advice annotations.
| Annotation | Applies to all controllers? | Return value becomes response body? |
|---|---|---|
@ControllerAdvice |
Yes | Only if you add @ResponseBody
|
@RestControllerAdvice |
Yes | Yes, automatically |
For JSON APIs, @RestControllerAdvice is almost always what you want.
How Spring picks which handler runs
With handlers in both a controller and a global advice, Spring needs a rule.
Exception thrown
│
▼
1. Look in the SAME controller for a matching @ExceptionHandler ──► found? use it
│ (none)
▼
2. Look in @ControllerAdvice classes ──► found? use it
│ (none)
▼
3. Fall back to default resolver (generic 500)
- Controller-local handlers win over global ones. A controller can override the app-wide behavior for its own errors.
- Among matching handlers, Spring prefers the one whose exception type is most specific (closest in the class hierarchy). A handler for
UserNotFoundExceptionbeats a handler forRuntimeExceptionwhen aUserNotFoundExceptionis thrown.
This "most specific wins" rule is why a broad @ExceptionHandler(Exception.class) is a safe catch-all and not a trap — narrower handlers still take priority.
📦 Tool 3 — ProblemDetail for a standard error body
Returning a bare string works, but every endpoint can shape its errors differently, and clients hate guessing. There is a standard for this.
-
ProblemDetailis Spring's built-in model for RFC 9457 ("Problem Details for HTTP APIs") — an agreed-upon JSON shape for errors, sent with the media typeapplication/problem+json. (Available from Spring Framework 6 / Spring Boot 3.)
The standard fields:
| Field | Meaning |
|---|---|
type |
URI identifying the error kind (a stable id, optionally a doc link) |
title |
Short human-readable summary of the error kind |
status |
The HTTP status code, repeated in the body |
detail |
Human-readable explanation of this occurrence |
instance |
URI for this specific occurrence (e.g. the request path) |
Here is a handler that returns one:
@ExceptionHandler(UserNotFoundException.class)
ProblemDetail handleNotFound(UserNotFoundException ex) {
ProblemDetail pd =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("User not found");
pd.setType(URI.create("https://api.example.com/errors/user-not-found"));
pd.setProperty("userId", ex.getUserId()); // custom extension field
return pd;
}
What each line does:
-
ProblemDetail.forStatusAndDetail(...)builds the object with the status and the per-occurrencedetailmessage set. -
setTitle/setTypefill in the stable, error-kind fields. -
setPropertyadds a custom field beyond the standard ones — the spec allows these extensions, so you can attach domain data likeuserId. - Returning a
ProblemDetailfrom a@RestControllerAdvicemakes Spring serialize it asapplication/problem+json.
The response body looks like this:
{"type":"https://api.example.com/errors/user-not-found","title":"User not found","status":404,"detail":"No user with id 42","instance":"/users/42","userId":"42"}
Why this is worth it:
- Every error across the API has the same shape, so clients write one parser.
- It's a public standard, so tools and other teams already understand it.
Handling Spring's own errors: ResponseEntityExceptionHandler
Not every exception is yours. Spring MVC itself throws for things like a malformed body or a missing parameter — and by default those become plain responses that don't match your ProblemDetail style.
- Extend
ResponseEntityExceptionHandlerin your advice. It already has@ExceptionHandlermethods for the built-in MVC exceptions (bad JSON, unsupported media type, validation failures, and more), and in Spring 6 it returnsProblemDetailbodies too.
@RestControllerAdvice
class GlobalErrorHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
ProblemDetail handleNotFound(UserNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
// framework exceptions are handled by the parent class, as ProblemDetail
}
- Your custom handlers sit alongside the inherited ones.
- Now both your errors and Spring's framework errors come back in the same standard format. One consistent contract for the whole API.
🚧 Gotchas the mechanism creates
These follow directly from how the resolver works.
-
Errors before the controller are invisible here. Requests pass through filters (a layer that runs before the DispatcherServlet) for things like authentication. An exception thrown in a filter never reaches
@ControllerAdvice— the request hasn't entered the dispatch machinery yet. Security errors (401/403) are usually handled by a separate mechanism, not your advice.
request ─► Filters ─► DispatcherServlet ─► Controller
▲ │
throws here └─ throws here → @ControllerAdvice CAN handle
→ advice CANNOT
- An advice can't handle an exception thrown by another advice. If your handler method itself throws, that new exception is not re-fed into the resolver chain — you'll fall back to a generic 500. Keep handler methods simple and safe.
-
Order among advices isn't guaranteed unless you set it. If two
@ControllerAdviceclasses both match an exception, use@Orderto make the winner explicit rather than relying on chance. -
Don't over-catch. A handler for
Exception.classwill also swallow bugs you'd rather see as a loud 500. Handle the exceptions you understand; let truly unexpected ones fall through to a generic 500 (or a deliberate catch-all that still logs the stack trace). -
A returned status beats the annotation. If a handler returns a
ResponseEntitywith an explicit status, that status is used — the@ResponseStatuson the exception class is ignored for that path. Pick one source of truth per exception.
📊 Quick summary
| Tool | Scope | Use it for |
|---|---|---|
@ResponseStatus on exception |
That exception, everywhere | Status-only errors, no body |
@ExceptionHandler in a controller |
One controller | Errors special to that controller |
@ControllerAdvice / @RestControllerAdvice
|
All controllers | App-wide error mapping (the default home) |
ProblemDetail |
The response body | A standard, consistent error shape |
ResponseEntityExceptionHandler |
Spring's own MVC errors | Making framework errors match your style |
Selection order Spring uses: controller-local handler → global advice → default 500, and within each, most specific exception type wins.
🎯 Decision rule
- Just need a status code, no body →
@ResponseStatuson the exception. - Need a custom body or headers → an
@ExceptionHandlermethod. - The same error appears across many controllers → move the handler into
@RestControllerAdvice. - Building a real API clients depend on → return
ProblemDetailso every error has one shape. - Want Spring's built-in errors to match → extend
ResponseEntityExceptionHandler. - Error looks like it's being ignored → check whether it's thrown in a filter (before dispatch) rather than a controller.
💡 Remember this
- Every uncaught controller exception flows to the DispatcherServlet, which asks exception resolvers to turn it into a response — the default is a generic 500.
-
@ExceptionHandlermaps an exception type to a response; put it in a@RestControllerAdviceto share it across the whole app. - Controller-local beats global, and most-specific exception type beats broader ones.
-
ProblemDetail(RFC 9457) gives every error one standard JSON shape; extendResponseEntityExceptionHandlerso Spring's own errors match it too. - The chain only covers exceptions thrown inside dispatch — anything from a filter needs handling elsewhere.