When multiple @ExceptionHandler methods could catch a thrown exception, how does Spring choose which one runs?
answer
- ExceptionHandlerMethodResolver + ExceptionDepthComparator
- smallest hierarchy depth wins (exact = 0)
- specific beats RuntimeException/Exception
- falls back to matching the cause chain
- tie at same depth = IllegalStateException ambiguous
basics
~20 sSpring picks the handler whose declared exception type is the closest match in the class hierarchy to the thrown exception — the most specific one. A handler for the exact type wins over one for a superclass like RuntimeException.
solid answer
~40 sMatching is by exception type using most-specific resolution. ExceptionHandlerMethodResolver collects the controller's handlers and, for a thrown exception, uses ExceptionDepthComparator to measure the inheritance distance between each handler's declared type(s) and the actual exception class. The handler with the smallest depth (closest ancestor, or an exact match at depth 0) wins. So a handler declaring the concrete IllegalArgumentException beats one declaring RuntimeException. If no handler matches the exception directly, Spring also checks the exception's cause chain and can match on a cause. If two handlers tie at the same closest depth, Spring throws an IllegalStateException for an ambiguous mapping. If nothing matches at all, the exception propagates to the next HandlerExceptionResolver.
code
java · 16 lines@RestController
class FileController {
// depth 0 for IllegalArgumentException -> wins over the RuntimeException handler
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<String> badArg(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body("bad arg: " + ex.getMessage());
}
// catch-all: only used when nothing more specific matches
@ExceptionHandler(RuntimeException.class)
ResponseEntity<String> generic(RuntimeException ex) {
return ResponseEntity.status(500).body("error: " + ex.getMessage());
}
}
// Throwing IllegalArgumentException -> badArg() runs (depth 0 beats depth 1).go deeper
Know that the most specific matching exception type is chosen, not just any superclass handler.
Explain depth-based selection and that a catch-all (Exception) only fires when nothing more specific matches.
Name ExceptionDepthComparator, describe cause-chain fallback, and the ambiguous-mapping IllegalStateException.
Discuss determinism guarantees, why cause traversal can be surprising, and how to design handler hierarchies to avoid ambiguity.
## The problem Given several `@ExceptionHandler` methods in a controller, and one thrown exception, Spring must deterministically pick exactly one handler. ## How resolution works 1. **Registry:** For each controller, `ExceptionHandlerMethodResolver` builds a map from exception type → handler method (reading both the `@ExceptionHandler` value array and inferred parameter types). It rejects duplicate mappings for the **same** exception type at startup. 2. **Lookup for a thrown exception:** When exception `E` is thrown, the resolver finds all declared types that are **assignable from** `E` (i.e. `E` or its supertypes that have handlers). 3. **Most-specific selection:** Among those candidates it uses **`ExceptionDepthComparator`**, which counts the number of hops up `E`'s superclass hierarchy to reach each candidate type. **Smaller depth = more specific = wins.** An exact type match is depth 0. ### Example of depth Hierarchy: `IllegalArgumentException` → `RuntimeException` → `Exception`. Thrown: `IllegalArgumentException`. Handlers declared for `RuntimeException` (depth 1) and `IllegalArgumentException` (depth 0). The **`IllegalArgumentException` handler wins**. ## Cause traversal If **no** handler matches the thrown exception directly, `ExceptionHandlerMethodResolver` inspects the exception's **cause** (`getCause()`) and tries to match that — walking the cause chain. So a thrown `RuntimeException` wrapping a `SQLException`, with only a `SQLException` handler, can still be routed to that handler via the cause. **Direct-exception matching is preferred over cause matching.** ## Ambiguity If two handlers match at the **same** closest depth (a genuine tie), Spring cannot choose and throws: > `IllegalStateException: Ambiguous @ExceptionHandler method mapped for ...` This usually means you declared handlers for two unrelated types that are both at equal distance for the thrown exception — restructure so one is clearly more specific, or split the handling. ## No match If nothing matches (neither exception nor any cause), the `ExceptionHandlerExceptionResolver` returns `null` for this controller, and `DispatcherServlet` moves to the next resolver in the chain (e.g. `ResponseStatusExceptionResolver`, `DefaultHandlerExceptionResolver`), and ultimately the Servlet container `/error` path. ## Multiple types on one handler `@ExceptionHandler({FileNotFoundException.class, EOFException.class})` registers the **same** method for both types; matching still uses depth per type. ## Gotchas - A broad `@ExceptionHandler(Exception.class)` acts as a catch-all **but only when no more specific handler matches** — it won't shadow specific ones. - Cause matching can surprise you: a generic wrapper exception may be routed to a handler for its inner cause. - Ambiguity is a **runtime** failure for that request, not a startup failure (except duplicate same-type registration, which fails earlier).
- A method throws a RuntimeException whose cause is a SQLException, and only a handler for SQLException exists. Does it get handled?Yes. When no handler matches the thrown exception directly, ExceptionHandlerMethodResolver walks the cause chain and can match the SQLException handler. Direct matches are still preferred over cause matches.
- What happens if two handlers match at the exact same hierarchy depth?Spring cannot disambiguate and throws IllegalStateException: 'Ambiguous @ExceptionHandler method mapped'. You must restructure so one handler is more specific or handle the types separately.
saying these in an interview costs you the question
- Saying the first-declared handler wins (order in source doesn't decide it)
- Claiming a RuntimeException handler always shadows a specific one
- Not knowing Spring falls back to the exception's cause
- Thinking ambiguity is caught at startup rather than at request time