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-catch in 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 handle OrderNotFoundException specifically — no need for try-catch in OrderController anymore.
  • ErrorResponse is 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 @ExceptionHandler handles one type of error. Spring automatically picks the most specific matching method based on the exception class hierarchy.
  • handleUnknown catches Exception.class as a final safety net — preventing the original stack trace from leaking to clients. This should be in nearly every GlobalExceptionHandler.
  • 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 @ExceptionHandler methods in the same @ControllerAdvice declare 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 LinkedHashMap preserves 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 from DataIntegrityViolationException to clients; use a generic message instead.
  • Multiple @ControllerAdvice handling the same exception: easily causes confusion about which one runs. If using multiple advices, use @Order to define precedence, or better yet, consolidate logic into one class.
  • Abusing @ControllerAdvice for 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: @ControllerAdvice only 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 before DispatcherServlet will 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.

spring-bootexception-handlingcontroller-advicejavarest-api