Exception handling: @ExceptionHandler / @ControllerAdvice / ProblemDetail

java dev.to

🧠 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
Enter fullscreen mode Exit fullscreen mode
  • 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 500 is 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());
    }
}
Enter fullscreen mode Exit fullscreen mode

What happened here:

  • @ExceptionHandler(UserNotFoundException.class) marks a method as the catch point for that exception within this controller.
  • When any method in UserController throws UserNotFoundException, Spring skips the normal return path and calls handleNotFound instead.
  • The method returns a ResponseEntity — a full HTTP response: status line, headers, and body, all under your control. Here: status 404, 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • @ResponseStatus on 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
    }
}
Enter fullscreen mode Exit fullscreen mode

Walking through it:

  • @ControllerAdvice marks a class whose @ExceptionHandler methods apply to all controllers, not just one.
  • @RestControllerAdvice is the same thing plus @ResponseBody behavior 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)
Enter fullscreen mode Exit fullscreen mode
  • 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 UserNotFoundException beats a handler for RuntimeException when a UserNotFoundException is 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.

  • ProblemDetail is Spring's built-in model for RFC 9457 ("Problem Details for HTTP APIs") — an agreed-upon JSON shape for errors, sent with the media type application/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;
}
Enter fullscreen mode Exit fullscreen mode

What each line does:

  • ProblemDetail.forStatusAndDetail(...) builds the object with the status and the per-occurrence detail message set.
  • setTitle / setType fill in the stable, error-kind fields.
  • setProperty adds a custom field beyond the standard ones — the spec allows these extensions, so you can attach domain data like userId.
  • Returning a ProblemDetail from a @RestControllerAdvice makes Spring serialize it as application/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"}
Enter fullscreen mode Exit fullscreen mode

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 ResponseEntityExceptionHandler in your advice. It already has @ExceptionHandler methods for the built-in MVC exceptions (bad JSON, unsupported media type, validation failures, and more), and in Spring 6 it returns ProblemDetail bodies 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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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 @ControllerAdvice classes both match an exception, use @Order to make the winner explicit rather than relying on chance.
  • Don't over-catch. A handler for Exception.class will 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 ResponseEntity with an explicit status, that status is used — the @ResponseStatus on 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 → @ResponseStatus on the exception.
  • Need a custom body or headers → an @ExceptionHandler method.
  • The same error appears across many controllers → move the handler into @RestControllerAdvice.
  • Building a real API clients depend on → return ProblemDetail so 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.
  • @ExceptionHandler maps an exception type to a response; put it in a @RestControllerAdvice to 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; extend ResponseEntityExceptionHandler so Spring's own errors match it too.
  • The chain only covers exceptions thrown inside dispatch — anything from a filter needs handling elsewhere.

Source: dev.to

arrow_back Back to Tutorials