· Jose Antonio López · 8 min lectura
Global Exception Handling a Spring Boot - Guia Completa amb @RestControllerAdvice
Implementar un maneig global d'excepcions a Spring Boot 3.5.3 usant @RestControllerAdvice i @ExceptionHandler. Centralitza errors, millora la resposta de les teves APIs REST i aplica bones pràctiques per a un desenvolupament professional.

Objectiu
Fer una guia real de com es configura el maneig global d’excepcions a Spring Boot 3.5.3:
| Codis d’error | Centralitzar els errors amb enums |
Prerequisits
Per a seguir aquest article necessites tenir instal·lat:
Dependències
Les dependències amb les que es treballarà són:
He triat Maven com a gestor de dependències perquè em sento més còmode. Si prefereixes Gradle, pots usar-lo sense problemes.
Decisions de disseny
CRM
El codi d’aquest article està basat en un projecte fictici anomenat CRM (Customer Relationship Management). El codi que veuràs és una versió simplificada d’un projecte real que estic desenvolupant. L’objectiu és mostrar com es pot gestionar les excepcions globalment a Spring Boot.
Onion Architecture
L’arquitectura serà del tipus onion architecture. Permet separar responsabilitats en tres grans capes:
A continuació pots veure l’estructura:
crm/
├── CrmApplication.java
├── domain
│ ├── exceptions
│ │ ├── CrmErrorMessage.java
│ │ └── CrmException.java
│ └── services
│ └── ItemService.java
├── application
│ └── services
│ └── ItemServiceDefaultImpl.java
└── infrastructure
├── dtos
│ └── ApiCrmError.java
└── handlers
└── GlobalExceptionHandler.javaLombok
Per reduir el codi repetitiu, faig servir Lombok que redueix el codi visible mitjançant anotacions. En aquest cas, només es fa servir l’anotació @Getter.
Excepció de domini
L’excepció de domini es llança quan ocorre un error en la lògica de negoci de l’aplicació. En aquest cas, es defineix una excepció anomenada 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ó del codi
Fer servir l’operador super() és opcional i es considera redundant. Jo el poso perquè quedi clar que s’està cridant al constructor de la classe pare i ajudar altres persones menys experimentades a entendre el codi.
Estendre de la classe RuntimeException permet que l’excepció sigui una unchecked Exception:
- No és necessari rebotar l’excepció fent servir
throwsa la signatura del mètode. - No és necessari gestionar l’excepció en un bloc
try-catch. - S’inicialitza el seu Stack Trace automàticament.
Codis d’error
Els codis d’error són una forma d’identificar els errors que poden ocórrer a l’aplicació. Una de les millors estratègies que he vist és fer servir un enum per a definir els codis d’error. En aquest cas, es defineix un enum anomenat 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ó del codi
Camps que es defineixen a l’enum
@Getter
L’anotació @Getter de Lombok genera els mètodes getHttpStatus() i getDescription() per a accedir als camps httpStatus i description respectivament. Aquests camps han de ser només accessibles des de dins de la classe. És per això que a @Getter se li passa el paràmetre AccessLevel.PRIVATE.
Mètodes estàtics
Els mètodes estàtics permetran obtenir el codi d’estat HTTP i la descripció de l’error. L’avantatge és que el programador podrà recuperar informació sense necessitat de fer servir un switch o múltiples if.
Consells per als codis d’error
L’implementació és molt útil per a centralitzar els errors. El problema és que si s’afegeixen molts errors, l’enum creix molt i es torna difícil de mantenir. Alguns consells per a gestionar això:
DTO d’error
El DTO d’error és una classe que representa el cos de la resposta d’error que es retornarà al client. En aquest cas, es defineix una classe anomenada ApiCrmError.
package com.test.crm.infrastructure.dtos;
public record ApiCrmError(CrmErrorMessage code, String message) {
}Explicació del codi
Els camps que es defineixen al DTO són:
En fer servir un record es redueix el codi repetitiu i els camps són immutables per defecte, la qual cosa és una bona pràctica per als DTOs.
Pots fer que implementi Serializable. És opcional posar-ho per a aquest cas. En la meva experiència de vegades m’he trobat amb problemes per no posar-ho en altres contextos com en serialitzar objectes a la memòria cau.
Global Exception Handler
El gestor global d’excepcions s’encarrega de capturar i les excepcions que es llancen a l’aplicació. L’objectiu és centralitzar el maneig d’excepcions per a evitar gestionar les excepcions a cada controlador.
Es defineix una classe anomenada 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ó del codi
Anotacions
Els paràmetres més interessants de @RestControllerAdvice són:
Aquests paràmetres et permeten limitar l’abast del maneig global d’excepcions a certs paquets o controladors específics, en lloc d’aplicar-lo a tota l’aplicació. És un pas previ per a migrar d’una aplicació monolítica a una arquitectura més modular i posteriorment microserveis.
handleCrmException
El mètode handleCrmException gestiona les excepcions de tipus CrmException. S’encarrega de crear una resposta amb el codi d’estat HTTP i el cos de la resposta. La clau és tenir una única excepció de domini que es llança a tota l’aplicació i passar-li el codi d’error i el missatge d’error.
handleValidationExceptions
Deixo el mètode handleValidationExceptions per a gestionar les excepcions de validació de Spring. El mètode és un bon exemple de la quantitat de coses que pots fer amb el maneig d’excepcions.
handleGenericException
Aquest mètode serveix per a gestionar qualsevol excepció que no hagi estat capturada pels mètodes anteriors. És una bona pràctica tenir un gestor genèric per a capturar errors inesperats i retornar un missatge d’error genèric al client.
@ControllerAdvice vs @RestControllerAdvice
Exemple d’ús en un servei
Per a llançar una excepció de domini en un servei, pots fer el següent:
@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
- Maneig d'Excepcions
- API REST
- Bones Pràctiques
- Java