Manejo de excepciones en Spring Boot con @ControllerAdvice
Una API que solo maneja el camino feliz no está lista para producción. Tarde o temprano un recurso no existe, una validación falla o un servicio externo no responde, y lo que el cliente recibe en esos momentos dice mucho sobre la calidad del sistema.
Spring Boot ofrece un mecanismo limpio para centralizar todo ese manejo en un solo lugar: @ControllerAdvice. En lugar de repetir bloques try-catch en cada controlador, defines los manejadores de excepciones una vez y Spring los aplica a toda la aplicación.
El problema sin centralización
Sin un mecanismo centralizado, el manejo de errores tiende a dispersarse:
@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
try {
Product product = productService.getById(id);
return ResponseEntity.ok(product);
} catch (ProductNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}Este patrón se repite en cada endpoint, genera inconsistencias en el formato de la respuesta de error y mezcla la lógica de manejo de errores con la lógica de negocio. @ControllerAdvice resuelve todo eso.
Estructura de la respuesta de error
Antes de escribir el manejador, conviene definir un formato de respuesta de error consistente. Un buen punto de partida:
public record ErrorResponse(
int status,
String error,
String message,
String path,
Instant timestamp
) {
public static ErrorResponse of(int status, String error, String message, String path) {
return new ErrorResponse(status, error, message, path, Instant.now());
}
}Ejemplo de respuesta JSON para un recurso no encontrado:
{
"status": 404,
"error": "Not Found",
"message": "Producto no encontrado: 99",
"path": "/api/products/99",
"timestamp": "2026-06-05T14:30:00Z"
}Un formato uniforme facilita el trabajo en el cliente: siempre sabe dónde mirar el mensaje de error, independientemente del tipo de falla.
Excepciones de dominio
El primer paso es definir excepciones de dominio que expresen condiciones de negocio:
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Producto no encontrado: " + id);
}
}
public class DuplicateProductException extends RuntimeException {
public DuplicateProductException(String name) {
super("Ya existe un producto con el nombre: " + name);
}
}
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(Long productId, int requested, int available) {
super("Stock insuficiente para el producto %d: solicitado %d, disponible %d"
.formatted(productId, requested, available));
}
}Usar RuntimeException como base evita que el compilador fuerce el manejo en cada llamada, lo que es apropiado para excepciones que representan errores irrecuperables en el contexto del request.
@ControllerAdvice y @ExceptionHandler
Con las excepciones definidas, el manejador global:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ErrorResponse> handleProductNotFound(
ProductNotFoundException ex,
HttpServletRequest request) {
log.warn("Producto no encontrado: {}", ex.getMessage());
ErrorResponse body = ErrorResponse.of(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(DuplicateProductException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(
DuplicateProductException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.of(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<ErrorResponse> handleInsufficientStock(
InsufficientStockException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.of(
HttpStatus.UNPROCESSABLE_ENTITY.value(),
"Unprocessable Entity",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(
Exception ex,
HttpServletRequest request) {
log.error("Error inesperado en {}: {}", request.getRequestURI(), ex.getMessage(), ex);
ErrorResponse body = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
"Ocurrió un error inesperado. Por favor, inténtalo más tarde.",
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}@RestControllerAdvice es equivalente a @ControllerAdvice + @ResponseBody: el valor de retorno de cada método se serializa automáticamente como JSON.
Spring evalúa los manejadores de más específico a más general. Si lanzas una ProductNotFoundException, el primer método la captura. Si lanza algo no mapeado, el manejador de Exception.class actúa como red de seguridad.
Validación de entrada con Bean Validation
Cuando usas @Valid o @Validated en los controladores, Spring lanza MethodArgumentNotValidException para cuerpos inválidos y ConstraintViolationException para parámetros de path o query inválidos. Es importante manejarlos explícitamente para devolver los errores de validación de forma estructurada:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
List<FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldError(error.getField(), error.getDefaultMessage()))
.toList();
ValidationErrorResponse body = new ValidationErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
"La solicitud contiene campos inválidos",
request.getRequestURI(),
Instant.now(),
fieldErrors
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ValidationErrorResponse> handleConstraintViolation(
ConstraintViolationException ex,
HttpServletRequest request) {
List<FieldError> fieldErrors = ex.getConstraintViolations()
.stream()
.map(v -> new FieldError(
v.getPropertyPath().toString(),
v.getMessage()
))
.toList();
ValidationErrorResponse body = new ValidationErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
"Parámetros inválidos",
request.getRequestURI(),
Instant.now(),
fieldErrors
);
return ResponseEntity.badRequest().body(body);
}Los registros de apoyo:
public record FieldError(String field, String message) {}
public record ValidationErrorResponse(
int status,
String error,
String message,
String path,
Instant timestamp,
List<FieldError> errors
) {}La respuesta JSON para una validación fallida quedaría:
{
"status": 400,
"error": "Bad Request",
"message": "La solicitud contiene campos inválidos",
"path": "/api/products",
"timestamp": "2026-06-05T14:31:00Z",
"errors": [
{ "field": "name", "message": "no debe estar vacío" },
{ "field": "price", "message": "debe ser mayor que 0" }
]
}Errores HTTP estándar
Spring también lanza sus propias excepciones para situaciones comunes que conviene interceptar:
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.of(
HttpStatus.METHOD_NOT_ALLOWED.value(),
"Method Not Allowed",
"Método HTTP no permitido: " + ex.getMethod(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(body);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleUnsupportedMediaType(
HttpMediaTypeNotSupportedException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.of(
HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(),
"Unsupported Media Type",
"Content-Type no soportado: " + ex.getContentType(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(body);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNoResourceFound(
NoResourceFoundException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.of(
HttpStatus.NOT_FOUND.value(),
"Not Found",
"Ruta no encontrada: " + request.getRequestURI(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}NoResourceFoundException es la excepción que Spring MVC lanza cuando ningún handler coincide con la URL solicitada (reemplaza a NoHandlerFoundException en versiones recientes de Spring Boot).
ProblemDetail: el estándar RFC 9457
Spring Boot 3 introdujo soporte nativo para ProblemDetail, el formato estandarizado por RFC 9457 (antes RFC 7807). Si quieres adherirte al estándar en lugar de un formato propio, puedes usarlo directamente:
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ProblemDetail> handleProductNotFound(
ProductNotFoundException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
problem.setTitle("Producto no encontrado");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}La respuesta sigue el formato RFC 9457:
{
"type": "about:blank",
"title": "Producto no encontrado",
"status": 404,
"detail": "Producto no encontrado: 99",
"instance": "/api/products/99",
"timestamp": "2026-06-05T14:30:00Z"
}ProblemDetail es una buena opción cuando construyes APIs públicas o integras con clientes que esperan el estándar. Para APIs internas, un formato propio controlado puede ser más práctico.
Probando el manejador global
El GlobalExceptionHandler es un candidato natural para tests con @WebMvcTest:
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void getById_returns404_withErrorBody_whenNotFound() throws Exception {
when(productService.getById(99L))
.thenThrow(new ProductNotFoundException(99L));
mockMvc.perform(get("/api/products/99")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.error").value("Not Found"))
.andExpect(jsonPath("$.message").value("Producto no encontrado: 99"))
.andExpect(jsonPath("$.timestamp").exists());
}
@Test
void create_returns400_withFieldErrors_whenInvalid() throws Exception {
String invalidJson = """
{"name": "", "price": -5}
""";
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(greaterThan(0)));
}
}Los tests verifican tanto el código HTTP como la estructura del cuerpo de error, asegurando que el contrato con el cliente se mantiene.
Buenas prácticas
- Define excepciones de dominio expresivas: una
ProductNotFoundExceptioncomunica más que unaRuntimeExceptiongenérica. Crea una excepción por condición de negocio distinta. - No expongas detalles internos en producción: el manejador genérico de
Exception.classdebe devolver un mensaje neutro al cliente y registrar el detalle completo en los logs. - Usa los códigos HTTP correctos:
404para recursos no encontrados,409para conflictos,422para entidades no procesables,400para validación de entrada. Evita usar siempre500o400. - Centraliza todo en un solo
@ControllerAdvice: tener múltiples clases con@ControllerAdvicepuede causar conflictos de orden. Un único handler global es más predecible. - Registra con el nivel correcto:
WARNpara errores esperados (recurso no encontrado, conflicto);ERRORpara excepciones inesperadas. - Incluye tests para los caminos de error: los clientes dependen del formato de la respuesta de error tanto como del camino feliz. Protégelo con tests.
Conclusión
@ControllerAdvice convierte el manejo de excepciones en una responsabilidad centralizada y coherente. Las excepciones de dominio expresan las condiciones de negocio, el manejador global las traduce a respuestas HTTP con un formato uniforme, y los tests verifican que ese contrato se mantiene.
El resultado es una API que comunica sus errores con la misma precisión que sus respuestas exitosas, lo que simplifica el trabajo de los clientes y facilita el diagnóstico en producción.