· 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.

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:

CaracterísticaDescripció
Maneig global d’excepcionsUtilitzar @RestControllerAdvice i @ExceptionHandler per gestionar excepcions de forma centralitzada
Una única excepció de dominiEvita tenir excepcions de domini a tot arreu (veure exemple amb SafeBoxException)
Convertir a resposta APITransformar l’excepció interna en una cosa que el consumidor entengui

| Codis d’error | Centralitzar els errors amb enums |

Prerequisits

Per a seguir aquest article necessites tenir instal·lat:

Eina/PasEnllaç de descàrrega
JavaDescarregar Java
MavenDescarregar Maven
Projecte amb Spring Boot 3.5.3Crear projecte Spring Boot

Dependències

Les dependències amb les que es treballarà són:

DependènciaDescripció
spring-boot-starter-webDependències necessàries per a construir aplicacions web amb servlet. Més a la documentació oficial.
lombokBiblioteca que redueix el codi repetitiu mitjançant anotacions. Més a la documentació oficial.

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:

CapaDescripció
DomainEntitats, interfícies dels serveis i excepcions de domini.
ApplicationImplementació de serveis de l’aplicació, mappers i DTOs.
InfrastructureControladors, filtres, configuració de l’aplicació, interfície del repositori JPA i implementació del repositori.

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.java

Lombok

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.

CrmException.java
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

ElementDescripció
@GetterAnotació de Lombok que genera el mètode getErrorMessage() per a accedir al missatge d’error.
CrmExceptionClasse que estén RuntimeException i representa una excepció de domini a l’aplicació.
CrmErrorMessageEnum que conté els missatges d’error de l’aplicació. Es defineix a continuació.

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 throws a 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.

CrmErrorMessage.java

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

CampDescripció
httpStatusCodi d’estat HTTP que es vol retornar al client. Es fa servir la classe org.springframework.http.HttpStatus
descriptionDescripció llegible de l’error, pensada perquè el consumidor de l’API entengui el problema.

@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

MètodeDescripció
getHttpStatusRetorna el codi d’estat HTTP associat a un missatge d’error específic de l’enum.
getMessageObté la descripció llegible de l’error per a un missatge d’error específic de l’enum.
getFromHttpStatusCerca i retorna el missatge d’error corresponent a un codi d’estat HTTP donat.

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ò:

ConsellDescripció
Agrupar errorsAgrupa els errors per funcionalitats. Et permetrà trobar errors més ràpid i dividir la classe si el projecte creix molt
Afegeix comentaris per funcióAfegeix comentaris a l’enum per a separar cada funcionalitat
Noms descriptiusUtilitza noms clars i representatius per a cada error que ajudin a altres desenvolupadors.
Revisar periòdicamentFes revisions regulars de l’enum per a eliminar errors obsolets o redundants, mantenint el codi net i actualitzat.

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.

ApiCrmError.java
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:

CampDescripció
codeCodi d’error de tipus CrmErrorMessage que identifica el tipus d’error ocorregut.
descriptionMissatge llegible que descriu l’error perquè el consumidor de l’API l’entengui fàcilment.

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:

GlobalExceptionHandler.java
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

AnotacióDescripcióDocumentació
@RestControllerAdviceIndica que la classe és un controlador global d’excepcions. Permet capturar i gestionar excepcions llançades per qualsevol controlador REST.@RestControllerAdvice
@ExceptionHandlerEl mètode gestionarà una o diverses excepcions específiques. Permet definir la lògica per a processar i respondre davant d’aquestes excepcions.@ExceptionHandler

Els paràmetres més interessants de @RestControllerAdvice són:

ParàmetreDescripció
basePackagesPermet especificar un o diversos paquets base on s’aplicarà el controlador global d’excepcions. Només els controladors en aquests paquets seran afectats. Exemple: @RestControllerAdvice(basePackages = "com.test.crm.infrastructure.controllers")
assignableTypesPermet indicar classes concretes de controladors a les quals s’aplicarà l’advice. Exemple: @RestControllerAdvice(assignableTypes = {UserController.class, CustomerController.class})

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

Aspecte@ControllerAdvice@RestControllerAdvice
PropòsitDissenyat per a controladors MVC que retornen vistes (HTML, JSP, Thymeleaf).Orientat a controladors REST que retornen dades (JSON, XML).
Anotació internaUsa@Component.Combina @ControllerAdvice i @ResponseBody per a serialitzar automàticament la resposta.
Ús típicAplicacions web amb pàgines HTML i plantilles MVC.APIs RESTful que retornen dades estructurades.
Retornar JSON/XMLRequereix afegir @ResponseBodyRetorna automàticament

Exemple d’ús en un servei

Per a llançar una excepció de domini en un servei, pots fer el següent:

ItemServiceDefaultImpl.java
@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
Compartir: