· Jose Antonio López · 9 min lectura
Global Exception Handling en Spring Boot - Guía Completa con @RestControllerAdvice
Implementar un manejo global de excepciones en Spring Boot 3.5.3 usando @RestControllerAdvice y @ExceptionHandler. Centraliza errores, mejora la respuesta de tus APIs REST y aplica buenas prácticas para un desarrollo profesional.

Objetivo
Hacer una guía real de cómo se configura el manejo global de excepciones en Spring Boot 3.5.3 :
Prerequisitos
Para seguir este artículo necesitas tener instalado:
Dependencias
Las dependencias con las que se va trabajar son:
He elegido Maven como gestor de dependencias porque me siento más cómodo. Si prefieres Gradle, puedes usarlo sin problemas.
Decisiones de diseño
CRM
El código de este artículo está basado en un proyecto ficticio llamado CRM (Customer Relationship Management). El código que vas a ver es una versión simplificada de un proyecto real que estoy desarrollando. El objetivo es mostrar cómo se puede manejar las excepciones globalmente en Spring Boot.
Onion Architecture
La arquitectura va a ser del tipo onion architecture. Permite separar responsabilidades en tres grandes capas:
A continuación puedes ver la estructura:
crm/
├── CrmApplication.java
├── domain
│ ├── exceptions
│ │ ├── CrmErrorMessage.java
│ │ └── CrmException.java
│ └── services
│ └── ItemService.java
├── application
│ └── services
│ └── ItemServiceDefaultImpl.java
└── infrastructure
├── dtos
│ └── ApiCrmError.java
└── handlers
└── GlobalExceptionHandler.javaLombok
Para reducir el código repetitivo, uso Lombok que reduce el código visible mediante anotaciones. En este caso, se usa solo la anotación @Getter, .
Excepción de dominio
La excepción de dominio se lanza cuando ocurre un error en la lógica de negocio de la aplicación. En este caso, se define una excepción llamada CrmException.
package com.test.crm.domain.exceptions;
@Getter
public class CrmException extends RuntimeException {
private final CrmErrorMessage errorMessage;
public CrmException(CrmErrorMessage errorMessage) {
super();
this.errorMessage = errorMessage;
}
}Explicación del código
Usar el operador super() es opcional y se considera redundante. Yo lo suelo poner para que quede claro que se está llamando al constructor de la clase padre y ayudar a otras personas menos experimentadas a entender el código.
Extender de la clase RuntimeException permite que la excepción sea una unchecked Exception:
- No es necesario rebotar la excepción usand
throwsen la firma del método. - No es necesario manjear la exepción en un bloque
try-catch. - Se inicializa su Stack Trace automáticamente.
Códigos de error
Los códigos de error son una forma de identificar los errores que pueden ocurrir en la aplicación. Una de las mejores estrategias que he visto es usar un enum para definir los códigos de error. En este caso, se define un enum llamado CrmErrorMessage.
package com.test.crm.domain.exceptions;
public enum CrmErrorMessage {
//ITEM
ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "Item not found"),
//USER
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"),
USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "User already exists"),
//CUSTOMER
CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "Customer not found"),
CUSTOMER_ALREADY_EXISTS(HttpStatus.CONFLICT, "Customer already exists"),
//FILES
CREATE_DIRECTORY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create a directory"),
UPLOAD_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to upload image"),
DELETE_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete image"),
IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "Image not found"),
//GENERIC
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Something wrong happened"),
BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad request"),
READ_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to read image");
private final HttpStatus httpStatus;
private final String description;
private CrmErrorMessage(HttpStatus httpStatus, String description) {
this.httpStatus = httpStatus;
this.description = description;
}
public static HttpStatus getHttpStatus(CrmErrorMessage errorMessage) {
return Arrays.stream(CrmErrorMessage.values())
.filter(element -> element.equals(errorMessage))
.findFirst()
.map(element -> element.getHttpStatus())
.orElse(null);
}
public static String getMessage(CrmErrorMessage errorMessage) {
return Arrays.stream(CrmErrorMessage.values())
.filter(element -> element.equals(errorMessage))
.findFirst()
.map(element -> element.getDescription())
.orElse(null);
}
public static CrmErrorMessage getFromHttpStatus(HttpStatus code) {
return Arrays.stream(CrmErrorMessage.values())
.filter(element -> element.getHttpStatus().equals(code))
.findFirst()
.orElse(INTERNAL_SERVER_ERROR);
}
}Explicación del código
Campos que se definen en el enum
@Getter
La anotación @Getter de Lombok genera los métodos getHttpStatus() y getDescription() para acceder a los campos httpStatus y description respectivamente. Estos campos deben ser solo accesibles desde dentro de la clase. Es por eso que a @Getter se le pasa el parámetro AccessLevel.PRIVATE.
Métodos estáticos
Los métodos estáticos van a permitir obtener el código de estado HTTP y la descripción del error. La ventaja es que el programador podrá recuperar información sin necesidad de usar un switch o múltiples if.
Tips para los códigos de error
La implementación es muy útil para centralizar los errores. El problema es que si se añaden muchos errores, el enum crece mucho y se vuelve difícil de mantener. Algunos consejos para manejar esto:
DTO de error
El DTO de error es una clase que representa el cuerpo de la respuesta de error que se va a devolver al cliente. En este caso, se define una clase llamada ApiCrmError.
package com.test.crm.infrastructure.dtos;
public record ApiCrmError(CrmErrorMessage code, String message) {
}Explicación del código
Los campos que se definen en el DTO son:
Al usar un record se reduce el código repetitivo y los campos son inmutables por defecto, lo que es una buena práctica para los DTOs.
Puedes hacer que implemente Serializable. Es opcional ponerlo para este caso. En mi experiencia a veces me he encontrado con problemas por no ponerlo en otros contextos como al serializar objetos en caché.
Global Exception Handler
El manejador global de excepciones se encarga de capturar y las excepciones que se lanzan en la aplicación. El objetivo es centralizar el manejo de excepciones para evitar manejar las excepciones en cada controlador.
Se define una clase llamada GlobalExceptionHandler:
package com.test.crm.infrastructure.handlers;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CrmException.class)
public ResponseEntity<ApiCrmError> handleCrmException(CrmException exception) {
final CrmErrorMessage errorMessage = exception.getErrorMessage();
final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
final ApiCrmError body = createApiCrmError(errorMessage);
return ResponseEntity.status(status).body(body);
}
@ExceptionHandler(value = {
MethodArgumentTypeMismatchException.class,
})
public ResponseEntity<ApiCrmError> handleExceptions(MethodArgumentTypeMismatchException exception) {
final CrmErrorMessage errorMessage = CrmErrorMessage.BAD_REQUEST;
final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
final String paramName = exception.getName();
String requiredTypeName = "unknown";
if (exception.getRequiredType() != null) {
requiredTypeName = exception.getRequiredType().getSimpleName();
}
String providedTypeName = "unknown";
if (exception.getValue() != null) {
providedTypeName = exception.getValue().getClass().getSimpleName();
}
final String message = String.format(
"Invalid value for parameter '%s'. Expected type: '%s', but got: '%s'.",
paramName, requiredTypeName, providedTypeName
);
final ApiCrmError body = createApiCrmError(errorMessage, message);
return ResponseEntity.status(status).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiCrmError> handleValidationExceptions(MethodArgumentNotValidException exception) {
final Map<String, String> errors = new HashMap<>();
exception.getBindingResult()
.getFieldErrors()
.forEach((error) -> {
final String fieldName = error.getField();
final String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
final CrmErrorMessage errorMessage = CrmErrorMessage.BAD_REQUEST;
final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
final ApiCrmError body = new ApiCrmError(errorMessage, errors.toString());
return new ResponseEntity<>(body, status);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiCrmError> handleGenericException(Exception exception) {
final CrmErrorMessage errorMessage = CrmErrorMessage.INTERNAL_SERVER_ERROR;
final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
final ApiCrmError body = createApiCrmError(errorMessage);
return ResponseEntity.status(status).body(body);
}
private ApiCrmError createApiCrmError(CrmErrorMessage errorMessage) {
final String message = CrmErrorMessage.getMessage(errorMessage);
return new ApiCrmError(errorMessage, message);
}
private ApiCrmError createApiCrmError(CrmErrorMessage errorMessage, String message) {
return new ApiCrmError(errorMessage, message);
}
}
Explicación del código
Anotaciones
Los parámetros más interesantes de @RestControllerAdvice son:
Estos parámetros te permiten limitar el alcance del manejo global de excepciones a ciertos paquetes o controladores específicos, en lugar de aplicarlo a toda la aplicación. Es un paso previo para migrar de una aplicación monolítica a una arquitectura más modular y posteriormente microservicios.
handleCrmException
El método handleCrmException maneja las excepciones de tipo CrmException. Se encarga de crear una respuesta con el código de estado HTTP y el cuerpo de la respuesta. La clave es tener una única excepción de dominio que se lanza en toda la aplicación y pasarle el código de error y el mensaje de error.
handleValidationExceptions
Dejo el método handleValidationExceptions para manejar las excepciones de validación de Spring. El método es un buen ejemplo de la cantidad de cosas que puedes hacer con el manejo de excepciones.
handleGenericException
Este método sirve para manejar cualquier excepción que no haya sido capturada por los métodos anteriores. Es una buena práctica tener un manejador genérico para capturar errores inesperados y devolver un mensaje de error genérico al cliente.
@ControllerAdvice vs @RestControllerAdvice
Ejemplo de uso en un servicio
Para lanzar una excepción de dominio en un servicio, puedes hacer lo siguiente:
@Service
public class ItemServiceDefaultImpl implements ItemService {
private final ItemRepository itemRepository;
private static final Logger logger = LogManager.getLogger(ItemServiceDefaultImpl.class);
public ItemServiceDefaultImpl(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
@Override
public ItemDTO getItem(Long itemId) {
Optional<Item> persistedItem = itemRepository.findById(itemId);
return persistedItem.map(ItemMapper::fromItem)
.orElseThrow(() -> {
logger.error("[ITEM] : Item with {} id not found", itemId);
return new CrmException(CrmErrorMessage.ITEM_NOT_FOUND);
});
}
}- Spring Boot
- Manejo de Excepciones
- API REST
- Buenas Prácticas
- Java