Centralized Exception Handling with @ControllerAdvice
1. Overview
When writing REST APIs, if each controller handles try-catch on its own and returns error responses however it likes, you’ll end up with dozens of different error JSON formats in the same system. @ControllerAdvice solves this by letting you define exception handling logic in a single place, applied to all (or some) controllers.
After reading this, you’ll know how to write a standard GlobalExceptionHandler, map exceptions to appropriate HTTP statuses, return consistent error bodies, and write tests to ensure it works correctly.
2. The Problem Without Centralized Handling
Here’s a typical controller when you’re new to Spring Boot:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<?> getOrder(@PathVariable Long id) {
try {
Order order = orderService.findById(id);
return ResponseEntity.ok(order);
} catch (OrderNotFoundException ex) {
Map<String, String> body = new HashMap<>();
body.put("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
}
Issues:
- Error body building logic (
Map<String, String>) repeats in every method, every controller. - Easy to forget
try-catchin a new method, resulting in a 500 error with stack trace exposed to clients. - No central place to handle exceptions like
MethodArgumentNotValidException(validation errors from@Valid).
Note: Not every error should be caught with try-catch in controllers. Controllers should only call services; converting exceptions to responses is the responsibility of a dedicated exception handling layer.
3. What is @ControllerAdvice
@ControllerAdvice is a class-level annotation marking a class that advises (supports) multiple controllers at once — most commonly for handling exceptions via @ExceptionHandler.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
ErrorResponse body = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
Instant.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
Explanation:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody, used for REST APIs because response bodies are automatically serialized to JSON.@ExceptionHandler(OrderNotFoundException.class)registers this method to handleOrderNotFoundExceptionspecifically — no need fortry-catchinOrderControlleranymore.ErrorResponseis a simple record defining a uniform error format for your entire system.
3.1. Define ErrorResponse and Custom Exceptions
public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long orderId) {
super("Order not found with id = " + orderId);
}
}
OrderNotFoundException extends RuntimeException, so you don’t need to declare throws everywhere. In OrderService, just throw when not found:
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
Now the controller is much cleaner:
@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) {
return orderService.findById(id);
}
}
No more try-catch, no manual ResponseEntity — GlobalExceptionHandler handles all the exception-to-response conversion.
4. Handling Multiple Exception Types
In practice, you’ll have many error types: not found (404), invalid request (400), data conflict (409), system error (500). @ExceptionHandler lets you declare multiple methods, each handling one or more exceptions.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
return buildResponse(HttpStatus.CONFLICT, "Data conflict detected, please try again");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "System error, please contact support");
}
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
return ResponseEntity.status(status).body(body);
}
}
Explanation:
- Each
@ExceptionHandlerhandles one type of error. Spring automatically picks the most specific matching method based on the exception class hierarchy. handleUnknowncatchesException.classas a final safety net — preventing the original stack trace from leaking to clients. This should be in nearly everyGlobalExceptionHandler.- Method declaration order does not determine priority; Spring chooses the handler with the most specific exception type that matches the thrown exception.
Note: If two
@ExceptionHandlermethods in the same@ControllerAdvicedeclare the same exception type, Spring will report an ambiguous mapping error on startup.
5. Handling Validation Errors with @Valid
A very common scenario: validating request bodies with Bean Validation. When validation fails, Spring throws MethodArgumentNotValidException, which you can catch and reformat for readability.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
@NotBlank(message = "customerName cannot be empty") String customerName,
@Positive(message = "amount must be greater than 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
Add a handler for MethodArgumentNotValidException:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(error.getField(), error.getDefaultMessage());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("errors", fieldErrors);
body.put("timestamp", Instant.now());
return ResponseEntity.badRequest().body(body);
}
Explanation:
ex.getBindingResult().getFieldErrors()returns a list of which fields failed and their error messages — exactly the messages you defined with@NotBlank,@Positive.- Using
LinkedHashMappreserves field order, making debugging easier.
Sending a request with missing customerName and negative amount returns:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"status": 400,
"errors": {
"customerName": "customerName cannot be empty",
"amount": "amount must be greater than 0"
},
"timestamp": "2026-10-05T10:15:30.123Z"
}
6. Narrowing Scope with basePackages
By default, @ControllerAdvice applies to all controllers in the application context. If you have multiple modules (e.g., admin and public-api) needing different error handling, you can limit scope:
@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
ErrorResponse body = new ErrorResponse(
HttpStatus.FORBIDDEN.value(), "Access denied", Instant.now());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
basePackages applies this advice only to controllers in package com.nonladev.shop.api.admin. Besides basePackages, you can also use annotations (apply to controllers with a specific annotation) or assignableTypes (apply to certain controller classes only).
7. Using ProblemDetail (RFC 7807 Standard) — Modern Approach
Starting with Spring Framework 6, ProblemDetail is available out-of-the-box, implementing the RFC 7807 standard for error responses — no need to define ErrorResponse yourself:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order Not Found");
problem.setProperty("timestamp", Instant.now());
return problem;
}
}
The response will look like:
{
"type": "about:blank",
"title": "Order Not Found",
"status": 404,
"detail": "Order not found with id = 99",
"instance": null,
"timestamp": "2026-10-05T10:20:00Z"
}
ProblemDetail is a good choice if your team wants to follow the common standard, making it easier to integrate with clients/tooling that understand RFC 7807 (e.g., some API gateways, OpenAPI tooling).
8. Testing GlobalExceptionHandler
Tests should verify correct status codes and bodies — preventing handler bugs from going unnoticed.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
given(orderService.findById(99L))
.willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Order not found with id = 99"));
}
}
Or test the handler directly without Spring context:
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void shouldMapOrderNotFoundTo404() {
ResponseEntity<ErrorResponse> response =
handler.handleNotFound(new OrderNotFoundException(42L));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody().message())
.isEqualTo("Order not found with id = 42");
}
}
The second approach is faster since it doesn’t load MockMvc, suitable when you only want to verify exception-to-response mapping logic, not routing.
9. Common Mistakes
- Forgetting a handler for
Exception.class: when unexpected errors occur (NullPointerException, DB connection errors…), clients get Spring Boot’s default 500 response with stack trace — exposing internal details. Always have a “catch-all” handler at the end. - Leaking sensitive details in exception messages: for instance, JPA exceptions might contain table names or column names. Don’t return
ex.getMessage()directly fromDataIntegrityViolationExceptionto clients; use a generic message instead. - Multiple
@ControllerAdvicehandling the same exception: easily causes confusion about which one runs. If using multiple advices, use@Orderto define precedence, or better yet, consolidate logic into one class. - Abusing
@ControllerAdvicefor business logic: advice should only “translate” exceptions to responses, not contain business processing (e.g., sending error notification emails) — that belongs in services or separate event listeners.
Note:
@ControllerAdviceonly catches exceptions thrown within controller methods (or layers they call) in the same request thread. Exceptions thrown in other threads (e.g.,@Async) or in filters running beforeDispatcherServletwill not be caught by@ControllerAdvice.
10. Conclusion
@ControllerAdvice combined with @ExceptionHandler lets you completely separate error handling logic from controllers, ensuring all error responses share the same format while preventing sensitive information from leaking to clients. Always include a handler catching Exception.class as a final safety net, use @Valid + MethodArgumentNotValidException for request validation, and consider ProblemDetail if you want to follow the RFC 7807 standard. Testing your handlers is just as important as testing services — it’s where you decide what clients see when errors occur.