Tratamento centralizado de exceções com @ControllerAdvice no Spring Boot

1. Visão geral

Ao escrever uma REST API, se cada controller fizer seu próprio try-catch e retornar uma response de erro do seu jeito, você terá dezenas de formatos JSON diferentes de erro no mesmo sistema. @ControllerAdvice resolve esse problema permitindo que você defina a lógica de tratamento de exceção em um único lugar, aplicável a todos (ou alguns) controllers.

Após ler este artigo, você saberá como escrever um GlobalExceptionHandler correto, mapear exceções para HTTP status apropriados, retornar bodies de erro consistentes e escrever testes para garantir que funcione corretamente.

2. O problema sem tratamento centralizado

Veja um controller “típico” quando você está começando com 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);
        }
    }
}

Problemas:

  • A lógica de construção do body de erro (Map<String, String>) se repete em todo método, em todo controller.
  • É fácil esquecer do try-catch em um novo método, resultando em um erro 500 com stack trace exposto.
  • Não há um lugar para tratar exceções “globais” como MethodArgumentNotValidException (erro de validação do @Valid).

Nota: Nem toda exceção deve ser capturada com try-catch no controller. O controller deve apenas chamar o service; a responsabilidade de “transformar exceção em response” é da camada de tratamento de exceção separada.

3. O que é @ControllerAdvice

@ControllerAdvice é uma annotation em nível de classe que marca uma classe como sendo responsável por oferecer suporte (advice) a múltiplos controllers simultaneamente — o uso mais comum é o tratamento de exceções através de @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);
    }
}

Explicação:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody, usada para REST API porque o body retornado é sempre serializado para JSON.
  • @ExceptionHandler(OrderNotFoundException.class) registra este método para tratar especificamente a exceção OrderNotFoundException — não precisa mais de try-catch no OrderController.
  • ErrorResponse é um record simples que define um formato de erro padronizado para todo o sistema.

3.1. Definindo ErrorResponse e exceção customizada

public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {

    public OrderNotFoundException(Long orderId) {
        super("Não foi encontrada order com id = " + orderId);
    }
}

OrderNotFoundException estende RuntimeException então não precisa declarar throws em todo lugar que a chama. No OrderService, você apenas faz throw quando não encontra:

@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));
    }
}

Agora o controller fica muito mais limpo:

@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);
    }
}

Sem mais try-catch, sem mais ResponseEntity manual — GlobalExceptionHandler fica responsável por toda a transformação de exceção em response.

4. Tratando múltiplos tipos de exceção

Na prática você terá vários tipos de erro: not found (404), validação de request inválida (400), conflito de dados (409), erro do sistema (500). @ExceptionHandler permite declarar múltiplos métodos, cada um tratando uma ou mais exceções.

@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, "Dados em conflito, tente novamente");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Erro no sistema, entre em contato com o suporte");
    }

    private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}

Explicação:

  • Cada @ExceptionHandler cuida de um tipo de erro, Spring escolhe automaticamente o método que melhor corresponde baseado na hierarquia de classe da exceção que foi lançada.
  • handleUnknown captura Exception.class como uma “rede de segurança” final — evita expor o stack trace original ao cliente. Isso deve estar presente em praticamente todo GlobalExceptionHandler.
  • A ordem de declaração dos métodos não determina a prioridade; Spring escolhe o handler que tem o tipo de exceção mais específico correspondendo à exceção real que foi lançada.

Nota: Se dois @ExceptionHandler declaram o mesmo tipo de exceção em um mesmo @ControllerAdvice, Spring reportará um erro de ambiguous mapping na inicialização.

5. Tratando erros de validação com @Valid

Uma situação muito comum: validar request body usando Bean Validation. Quando a validação falha, Spring automaticamente lança MethodArgumentNotValidException, você só precisa capturá-la e formatar de forma legível.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "customerName não pode estar vazio") String customerName,
        @Positive(message = "amount deve ser maior que 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

Adicione um handler para 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);
}

Explicação:

  • ex.getBindingResult().getFieldErrors() retorna uma lista de quais campos estão incorretos e suas mensagens correspondentes — exatamente as mensagens que você declarou em @NotBlank, @Positive.
  • Use LinkedHashMap para manter a ordem dos campos, facilitando o debug.

Enviar uma requisição sem customerName e com amount negativo retornará:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": 400,
  "errors": {
    "customerName": "customerName não pode estar vazio",
    "amount": "amount deve ser maior que 0"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. Limitando o escopo com basePackages

Por padrão, @ControllerAdvice é aplicado a todos os controllers no application context. Se você tem múltiplos módulos (por exemplo admin e public-api) que precisam de tratamento de erro diferente, você pode limitar o escopo:

@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(), "Você não tem permissão para acessar", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackages aplica este advice apenas a controllers localizados no package com.nonladev.shop.api.admin. Além de basePackages, você também pode usar annotations (aplicar apenas a controllers com uma annotation específica) ou assignableTypes (aplicar apenas a uma lista específica de classes de controller).

7. Usando ProblemDetail (padrão RFC 7807) — a forma moderna

A partir do Spring Framework 6, existe ProblemDetail nativo implementando o padrão RFC 7807 para responses de erro — não precisa mais definir seu próprio ErrorResponse:

@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;
    }
}

A response terá o seguinte formato:

{
  "type": "about:blank",
  "title": "Order Not Found",
  "status": 404,
  "detail": "Não foi encontrada order com id = 99",
  "instance": null,
  "timestamp": "2026-10-05T10:20:00Z"
}

ProblemDetail é uma boa escolha se seu time quer seguir um padrão comum, fácil de integrar com client/tooling que entende RFC 7807 (por exemplo alguns API gateways, ferramentas de OpenAPI).

8. Escrevendo testes para GlobalExceptionHandler

Os testes devem garantir o status code correto e o body correto — evitar que o handler seja alterado incorretamente sem ninguém perceber.

<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("Não foi encontrada order com id = 99"));
    }
}

Ou teste unitário diretamente no handler, sem context do Spring:

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("Não foi encontrada order com id = 42");
    }
}

A segunda forma é mais rápida porque não precisa carregar MockMvc, apropriada quando você quer apenas verificar a lógica de mapear exceção → response, sem se preocupar com roteamento.

9. Erros comuns

  • Esquecer handler para Exception.class: quando há um erro inesperado (NullPointerException, erro de conexão com DB…), o cliente recebe uma response 500 com o stack trace padrão do Spring Boot — expõe informação interna. Sempre tenha um handler “catch-all” no final.
  • Deixar detalhes sensíveis na mensagem de exceção: por exemplo exceção do JPA pode conter nome de tabela, nome de coluna. Não retorne ex.getMessage() diretamente para DataIntegrityViolationException ao cliente, use uma mensagem genérica.
  • Múltiplos @ControllerAdvice tratando a mesma exceção: fácil causar confusão pois não fica claro qual é executado. Se usar múltiplos advice, use @Order para definir a ordem, ou melhor ainda, centralize a lógica comum em uma classe.
  • Abusar de @ControllerAdvice para lógica de negócio: advice deve apenas “traduzir” exceção para response, não deve conter lógica de tratamento business (por exemplo enviar email notificando erro) — isso deve estar no service ou em um event listener separado.

Nota: @ControllerAdvice apenas captura exceção que é lançada dentro dos métodos do controller (ou camadas chamadas a partir daí) na mesma thread de request. Exceção lançada em uma thread diferente (por exemplo em @Async) ou em um filter executado antes de DispatcherServlet não será capturada pelo @ControllerAdvice.

10. Conclusão

@ControllerAdvice combinado com @ExceptionHandler ajuda você a separar completamente a lógica de tratamento de erro do controller, garantindo que toda response de erro tenha o mesmo formato, enquanto evita expor informação sensível ao cliente. Sempre tenha um handler que capture Exception.class como rede de segurança final, use @Valid + MethodArgumentNotValidException para validação de request, e considere ProblemDetail se quiser seguir o padrão RFC 7807. Escrever testes para o handler é tão importante quanto escrever testes para o service — pois é aqui que se decide o que o cliente vê quando há um erro.

spring-bootexception-handlingcontroller-advicejavarest-api