@ExceptionHandler
@ExceptionHandler marks a method that handles exceptions thrown by handler methods in the same controller. It is the most direct way to translate an exception into a specific HTTP status and body, keeping the mapping right next to the code that throws. This page covers method signatures, mapping to status codes, and handling several exception types at once.
A controller-local handler
Declare a method in your @RestController and annotate it with @ExceptionHandler, naming the exception type it handles. Any matching exception thrown by another method in that controller is routed to it.
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@GetMapping("/{id}")
public Product one(@PathVariable Long id) {
return service.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<String> handleNotFound(ProductNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
}
Output:
HTTP/1.1 404 Not Found
Content-Type: text/plain;charset=UTF-8
Product 999 not found
The handler only fires for exceptions originating in ProductController. For app-wide behavior, move it to a @RestControllerAdvice.
Setting the status
There are two ways to attach a status to a handler’s response.
Option A — @ResponseStatus when the status is fixed and you return a plain body:
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError handleNotFound(ProductNotFoundException ex) {
return new ApiError(404, "PRODUCT_NOT_FOUND", ex.getMessage());
}
Option B — ResponseEntity when the status (or headers) varies:
@ExceptionHandler(OrderException.class)
public ResponseEntity<ApiError> handleOrder(OrderException ex) {
HttpStatus status = ex.isRetryable() ? HttpStatus.SERVICE_UNAVAILABLE
: HttpStatus.CONFLICT;
return ResponseEntity.status(status)
.body(new ApiError(status.value(), ex.getCode(), ex.getMessage()));
}
Note: If a handler returns a
ResponseEntity, its status wins and any@ResponseStatuson the method is ignored. See ResponseEntity & Status Codes.
Handling multiple exception types
One handler can cover several related exceptions by listing them in the annotation. The parameter type must be a common supertype (often the exception base class or Exception).
@ExceptionHandler({ IllegalArgumentException.class, IllegalStateException.class })
public ResponseEntity<ApiError> handleBadRequest(RuntimeException ex) {
var body = new ApiError(400, "BAD_REQUEST", ex.getMessage());
return ResponseEntity.badRequest().body(body);
}
When several handlers could match, Spring picks the most specific one. A handler for ProductNotFoundException is preferred over a handler for RuntimeException.
| If you declare | It catches |
|---|---|
IllegalArgumentException.class | exactly that type and its subtypes |
{ A.class, B.class } | either A or B (param = common supertype) |
RuntimeException.class | any unchecked exception not matched more specifically |
Exception.class | a catch-all fallback |
Handler method signatures
@ExceptionHandler methods are flexible: Spring resolves their arguments by type, so you request only what you need. Supported parameters include the exception itself plus request context.
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ApiError> handle(
ValidationException ex, // the thrown exception
HttpServletRequest request, // jakarta.servlet request
WebRequest webRequest, // Spring's request abstraction
Locale locale) { // resolved locale
var body = new ApiError(400, "VALIDATION", ex.getMessage());
body.setPath(request.getRequestURI());
return ResponseEntity.badRequest().body(body);
}
Common parameter and return types:
| Parameter type | Provides |
|---|---|
| The exception type | The thrown exception instance |
HttpServletRequest | Raw jakarta.servlet request (URI, headers) |
WebRequest | Portable request access |
HttpHeaders | Request headers |
Locale / TimeZone | Resolved locale info |
| Return type | Behavior |
|---|---|
ResponseEntity<T> | Full control of status, headers, body |
A DTO (+ @ResponseStatus) | Serialized body with the declared status |
ProblemDetail | RFC 7807 body — see ProblemDetail |
void | You write the response yourself |
Accessing the original request
Because you can inject HttpServletRequest, you can enrich the error body with the path or correlation headers — handy for tracing.
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handle(ProductNotFoundException ex,
HttpServletRequest req) {
var body = new ApiError(404, "PRODUCT_NOT_FOUND", ex.getMessage());
body.setPath(req.getRequestURI());
body.setTimestamp(Instant.now());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
Output:
{
"status": 404,
"code": "PRODUCT_NOT_FOUND",
"message": "Product 999 not found",
"path": "/api/products/999",
"timestamp": "2026-06-13T10:15:42.123Z"
}
Pitfalls
- A controller-local handler does not see exceptions from other controllers — duplicate it or move it to a global advice.
- Avoid catching
Exception.classin a controller; reserve broad catch-alls for the global advice so you do not accidentally swallow framework exceptions. - Throwing a new exception from inside an
@ExceptionHandlerwill propagate to the default error handling — handlers should not throw.