Manejo centralizado de excepciones con @ControllerAdvice en Spring Boot
1. Descripción general
Al escribir una REST API, si cada controlador hace su propio try-catch y devuelve respuestas de error a su manera, terminarás con docenas de formatos JSON de error diferentes en el mismo sistema. @ControllerAdvice resuelve este problema permitiéndote definir la lógica de manejo de excepciones en un solo lugar, aplicable a todos (o algunos) controladores.
Después de leer este artículo, sabrás cómo escribir un GlobalExceptionHandler adecuado, mapear excepciones a estados HTTP apropiados, devolver cuerpos de error consistentes, y escribir pruebas para asegurar que funcione correctamente.
2. Problema sin manejo centralizado
Veamos un controlador “típico” cuando estás aprendiendo 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:
- La lógica de construir el cuerpo del error (
Map<String, String>) se repite en cada método, cada controlador. - Es fácil olvidar
try-catchen un nuevo método, resultando en error 500 con stack trace expuesto al cliente. - No hay lugar para manejar excepciones “generales” como
MethodArgumentNotValidException(errores de validación con@Valid).
Nota: No todo error debería capturarse con try-catch en el controlador. El controlador solo debe llamar al servicio; la tarea de “convertir excepciones en respuestas” es responsabilidad de una capa de manejo de excepciones dedicada.
3. ¿Qué es @ControllerAdvice?
@ControllerAdvice es una anotación a nivel de clase que marca una clase como contenedora de métodos para soportar (aconsejar) múltiples controladores simultáneamente — lo más común es manejar excepciones mediante @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);
}
}
Explicación:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody, se usa para REST API porque el cuerpo devuelto siempre se serializa a JSON.@ExceptionHandler(OrderNotFoundException.class)registra este método para manejar específicamente excepciones de tipoOrderNotFoundException— ya no necesitastry-catchenOrderController.ErrorResponsees un record simple que define el formato de error uniforme para todo el sistema.
3.1. Definición de ErrorResponse y excepciones personalizadas
public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long orderId) {
super("No se encontró la orden con id = " + orderId);
}
}
OrderNotFoundException hereda de RuntimeException, así que no necesitas declarar throws en todas partes. En OrderService, solo necesitas lanzar la excepción si no se encuentra:
@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));
}
}
Ahora el controlador se vuelve mucho más simple:
@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);
}
}
Sin try-catch manual, sin ResponseEntity manual — GlobalExceptionHandler se encarga de convertir excepciones en respuestas.
4. Manejo de múltiples tipos de excepciones
En la práctica tendrás múltiples tipos de errores: no encontrado (404), validación de solicitud fallida (400), conflicto de datos (409), error del sistema (500). @ExceptionHandler permite declarar múltiples métodos, cada uno manejando una o más excepciones.
@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, "Los datos están en conflicto, intente de nuevo");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Error del sistema, por favor contacte con soporte");
}
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
return ResponseEntity.status(status).body(body);
}
}
Explicación:
- Cada
@ExceptionHandlermaneja un tipo de error, Spring automáticamente elige el método que mejor coincida basado en la jerarquía de clases de la excepción lanzada. handleUnknowncapturaException.classcomo una “red de seguridad” final — evita exponer el stack trace original al cliente. Esto prácticamente siempre debería estar en todoGlobalExceptionHandler.- El orden de declaración de métodos no determina la prioridad; Spring elige el handler con el tipo de excepción más específico que coincida con la excepción lanzada.
Nota: Si dos
@ExceptionHandlerdeclaran el mismo tipo de excepción en el mismo@ControllerAdvice, Spring reportará un error de mapeo ambiguo al iniciar la aplicación.
5. Manejo de errores de validación con @Valid
Una situación muy común: validar el cuerpo de la solicitud usando Bean Validation. Cuando la validación falla, Spring lanza automáticamente MethodArgumentNotValidException, solo necesitas capturarla y formatearla para que sea legible.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
@NotBlank(message = "customerName no puede estar vacío") String customerName,
@Positive(message = "amount debe ser mayor que 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
Añade un 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);
}
Explicación:
ex.getBindingResult().getFieldErrors()devuelve una lista de qué campos son inválidos y sus mensajes correspondientes — exactamente los mensajes que declaras en@NotBlank,@Positive.- Usa
LinkedHashMappara mantener el orden de los campos, más fácil de depurar.
Enviar una solicitud sin customerName e amount negativo devolverá:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"status": 400,
"errors": {
"customerName": "customerName no puede estar vacío",
"amount": "amount debe ser mayor que 0"
},
"timestamp": "2026-10-05T10:15:30.123Z"
}
6. Reducir el alcance con basePackages
Por defecto, @ControllerAdvice se aplica a todos los controladores en el contexto de la aplicación. Si tienes múltiples módulos (por ejemplo admin y public-api) que necesitan manejar errores de manera diferente, puedes limitar el alcance:
@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(), "No tienes permiso para acceder", Instant.now());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
basePackages solo aplica este advice a controladores en el paquete com.nonladev.shop.api.admin. Además de basePackages, también puedes usar annotations (solo para controladores con una anotación específica) o assignableTypes (solo para ciertas clases de controlador).
7. Usar ProblemDetail (estándar RFC 7807) — enfoque moderno
A partir de Spring Framework 6, hay disponible ProblemDetail que implementa el estándar RFC 7807 para respuestas de error — ya no necesitas definir tu propio 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;
}
}
La respuesta tendrá esta forma:
{
"type": "about:blank",
"title": "Order Not Found",
"status": 404,
"detail": "No se encontró la orden con id = 99",
"instance": null,
"timestamp": "2026-10-05T10:20:00Z"
}
ProblemDetail es una buena opción si tu equipo quiere seguir un estándar común, fácil de integrar con cliente/herramientas que entienden RFC 7807 (por ejemplo algunos gateways API, herramientas OpenAPI).
8. Escribir pruebas para GlobalExceptionHandler
Las pruebas deben asegurar el código de estado correcto y el cuerpo correcto — evita que el handler se modifique incorrectamente sin que nadie lo sepa.
<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("No se encontró la orden con id = 99"));
}
}
O prueba unitaria directamente en el handler, sin necesidad de contexto 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("No se encontró la orden con id = 42");
}
}
El segundo enfoque es más rápido porque no necesita cargar MockMvc, apropiado cuando solo quieres verificar la lógica de mapeo excepción → respuesta, sin involucrar routing.
9. Errores comunes
- Olvidar el handler para
Exception.class: cuando hay un error inesperado (NullPointerException, error de conexión a BD…), el cliente recibe una respuesta 500 con el stack trace por defecto de Spring Boot — expone información interna. Siempre ten un handler “catch-all” al final. - Exponer detalles sensibles en mensajes de excepción: por ejemplo, excepciones de JPA pueden contener nombres de tablas, nombres de columnas. No devuelvas
ex.getMessage()directamente paraDataIntegrityViolationExceptional cliente, usa mensajes genéricos. - Múltiples
@ControllerAdvicemanejando la misma excepción: es confuso porque no está claro cuál se ejecuta. Si usas múltiples advice, usa@Orderpara determinar el orden, o mejor aún, consolidar la lógica común en una clase. - Abusar de
@ControllerAdvicepara lógica de negocio: advice solo debe encargarse de “traducir” excepciones en respuestas, no debe contener lógica de negocio (por ejemplo enviar correos de notificación) — eso debería estar en un servicio o event listener separado.
Nota:
@ControllerAdvicesolo puede capturar excepciones lanzadas desde dentro de métodos del controlador (o capas invocadas por ellos) en el mismo hilo de solicitud. Las excepciones lanzadas en otro hilo (por ejemplo@Async) o en un filter que se ejecuta antes deDispatcherServletno serán capturadas por@ControllerAdvice.
10. Conclusión
@ControllerAdvice combinado con @ExceptionHandler te ayuda a separar completamente la lógica de manejo de errores del controlador, asegurando que todas las respuestas de error tengan el mismo formato, mientras evitas exponer información sensible al cliente. Siempre ten un handler que capture Exception.class como red de seguridad final, usa @Valid + MethodArgumentNotValidException para validar solicitudes, y considera ProblemDetail si quieres seguir el estándar RFC 7807. Escribir pruebas para el handler es tan importante como escribir pruebas para el servicio — porque es aquí donde se determina qué ve el cliente cuando hay un error.