Validation & Exception Handling

Exception Handling with @ExceptionHandler

18 min Lesson 6 of 13

Exception Handling with @ExceptionHandler

Every real API throws exceptions. A requested resource does not exist, a caller supplies a bad identifier, a downstream service is unreachable. The question is not whether exceptions will occur but where you want to deal with them, and what the caller receives when they do. Spring MVC's @ExceptionHandler mechanism gives you a clean, declarative answer: annotate a method with the exception type(s) you want to catch, and Spring dispatches to that method whenever one of those exceptions escapes a controller action.

The Problem with Try-Catch in Controller Methods

Before @ExceptionHandler existed, developers wrapped every action body in a try-catch, mapped the exception to a status code by hand, and returned an error response. That approach produces repetitive, hard-to-maintain code and scatter the error-handling logic everywhere instead of centralising it.

// ❌ The old way — repeated in every method @GetMapping("/orders/{id}") public ResponseEntity<Order> getOrder(@PathVariable Long id) { try { return ResponseEntity.ok(orderService.findById(id)); } catch (OrderNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } catch (Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } }

With @ExceptionHandler you define that mapping once, and every action in the controller automatically benefits.

Declaring a @ExceptionHandler Method

Place a method annotated with @ExceptionHandler inside a @RestController (or @Controller). Spring intercepts any exception of the declared type that bubbles out of any @RequestMapping method in the same class and routes it to your handler instead of propagating it.

import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/{id}") public Order getOrder(@PathVariable Long id) { // throws OrderNotFoundException if not found — no try-catch needed here return orderService.findById(id); } @PostMapping public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) { Order saved = orderService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(saved); } // ✅ Handles OrderNotFoundException thrown by any method in this controller @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) { ErrorResponse body = new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); } }

Notice that getOrder no longer contains any try-catch. It simply delegates the happy-path work to the service and lets the exception propagate. Spring intercepts it before it reaches the servlet container and calls handleNotFound instead.

The Handler Method Signature

Handler methods are flexible. Spring resolves several useful parameters automatically:

  • The exception itself — typed as the exception class (or a supertype).
  • HttpServletRequest / HttpServletResponse — raw servlet objects if you need them.
  • WebRequest — a portable abstraction over the request.
  • Locale, TimeZone — for localised error messages.
  • Model — for MVC (non-REST) controllers that return a view name.

The return type is equally flexible: a ResponseEntity<T> (recommended for REST), a plain object (Spring uses the normal message converters), a ModelAndView (for MVC), or void if you write directly to the response.

Handling Multiple Exception Types in One Method

You can list several exception classes in the annotation value and receive the base type in the parameter:

@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) public ResponseEntity<ErrorResponse> handleBadRequest(RuntimeException ex) { return ResponseEntity .badRequest() .body(new ErrorResponse("BAD_REQUEST", ex.getMessage())); }
Class hierarchy matters. If you annotate a handler with a base type such as RuntimeException.class, it will catch every unchecked exception not already claimed by a more specific handler in the same class. Spring always dispatches to the most specific matching handler first.

Accessing Request Information Inside the Handler

Sometimes you need to log the request path alongside the error, or include it in the error body so clients can correlate responses to their requests. Inject HttpServletRequest:

import jakarta.servlet.http.HttpServletRequest; @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( OrderNotFoundException ex, HttpServletRequest request) { ErrorResponse body = ErrorResponse.builder() .code("ORDER_NOT_FOUND") .message(ex.getMessage()) .path(request.getRequestURI()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); }

A Realistic ErrorResponse Record

Keep your error DTO consistent and serialisation-friendly. A Java record works perfectly — it is immutable, concise, and Jackson serialises it out of the box:

import java.time.Instant; public record ErrorResponse( String code, String message, String path, Instant timestamp ) { // convenience factory for the common case public static ErrorResponse of(String code, String message, String path) { return new ErrorResponse(code, message, path, Instant.now()); } }

With this record an error response body looks like:

{ "code": "ORDER_NOT_FOUND", "message": "Order 42 does not exist", "path": "/api/orders/42", "timestamp": "2025-09-15T10:23:44.817Z" }

The Scope Limitation — and When to Move On

The key constraint of a controller-local @ExceptionHandler is its scope: it only catches exceptions thrown by methods in that same controller class. If you have twenty controllers and each one can throw OrderNotFoundException, you would need to copy the handler into every one of them.

Rule of thumb: Use controller-local @ExceptionHandler when the handling logic is genuinely specific to one controller — for example when error messages reference controller-level context or when you intentionally want different status codes for the same exception type in different parts of the API. For application-wide, cross-cutting error handling, move to @ControllerAdvice, which is the subject of the next lesson.
Do not suppress the exception and return a 200 OK. A handler that swallows an error and returns a success response is a common mistake. Always map exceptions to an appropriate HTTP status code (4xx for client errors, 5xx for server errors) so that consumers and monitoring tools can react correctly.

Putting It Together — A Complete Example

@RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping("/{id}") public Product findById(@PathVariable Long id) { return productService.findById(id); // throws ResourceNotFoundException } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { productService.delete(id); // throws ResourceNotFoundException or AccessDeniedException } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( ResourceNotFoundException ex, HttpServletRequest req) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(ErrorResponse.of(ex.getCode(), ex.getMessage(), req.getRequestURI())); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleForbidden( AccessDeniedException ex, HttpServletRequest req) { return ResponseEntity .status(HttpStatus.FORBIDDEN) .body(ErrorResponse.of("FORBIDDEN", ex.getMessage(), req.getRequestURI())); } }

Both handler methods share the same ErrorResponse shape, map to appropriate HTTP status codes, and include the request path — giving API consumers everything they need to understand and act on the error without exposing internal stack traces.

Summary

@ExceptionHandler eliminates try-catch boilerplate from controller action methods by centralising error mapping in dedicated handler methods within the same controller. You declare which exception type(s) to intercept, choose the right HTTP status code, build a structured error body, and Spring does the dispatching. The trade-off is scope: handlers live in one class. In the next lesson you will see how @ControllerAdvice lifts that restriction and lets a single class handle exceptions across the entire application.