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-catchem 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çãoOrderNotFoundException— não precisa mais detry-catchnoOrderController.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
@ExceptionHandlercuida 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. handleUnknowncapturaException.classcomo uma “rede de segurança” final — evita expor o stack trace original ao cliente. Isso deve estar presente em praticamente todoGlobalExceptionHandler.- 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
@ExceptionHandlerdeclaram 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
LinkedHashMappara 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 paraDataIntegrityViolationExceptionao cliente, use uma mensagem genérica. - Múltiplos
@ControllerAdvicetratando a mesma exceção: fácil causar confusão pois não fica claro qual é executado. Se usar múltiplos advice, use@Orderpara definir a ordem, ou melhor ainda, centralize a lógica comum em uma classe. - Abusar de
@ControllerAdvicepara 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:
@ControllerAdviceapenas 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 deDispatcherServletnã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.