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

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 :

CaracterísticaDescripción
Manejo global de excepcionesUsar @RestControllerAdvice y @ExceptionHandler para manejar excepciones de forma centralizada
Una única excepción de dominioEvita tener excepciones de dominio por todos lados (ver ejemplo con SafeBoxException)
Convertir a respuesta APITransformar la excepción interna a algo que el consumidor entienda
Códigos de errorCentralizar los errores con enums

Prerequisitos

Para seguir este artículo necesitas tener instalado:

Herramienta/PasoEnlace de descarga
JavaDescargar Java
MavenDescargar Maven
Proyecto con Spring Boot 3.5.3Crear proyecto Spring Boot

Dependencias

Las dependencias con las que se va trabajar son:

DependenciaDescripción
spring-boot-starter-webDependencias necesarias para construir aplicaciones web con servlet. Más en la documentación oficial.
lombokBiblioteca que reduce el código repetitivo mediante anotaciones. Más en la documentación oficial.

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:

CapaDescripción
DomainEntidades, interfaces de los servicios y excepciones de dominio.
ApplicationImplementación de servicios de la aplicación, mappers y DTOs.
InfrastructureControladores, filtros, configuración de la aplicación, interfaz del repositorio JPA e implementación del repositorio.

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

Lombok

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.

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ón del código

ElementoDescripción
@GetterAnotación de Lombok que genera el método getErrorMessage() para acceder al mensaje de error.
CrmExceptionClase que extiende RuntimeException y representa una excepción de dominio en la aplicación.
CrmErrorMessageEnum que contiene los mensajes de error de la aplicación. Se define a continuación.

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

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ón del código

Campos que se definen en el enum

CampoDescripción
httpStatusCódigo de estado HTTP que se quiere devolver al cliente. Se usa la clase org.springframework.http.HttpStatus
descriptionDescripción legible del error, pensada para que el consumidor de la API entienda el problema.

@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

MétodoDescripción
getHttpStatusDevuelve el código de estado HTTP asociado a un mensaje de error específico del enum.
getMessageObtiene la descripción legible del error para un mensaje de error específico del enum.
getFromHttpStatusBusca y devuelve el mensaje de error correspondiente a un código de estado HTTP dado.

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:

ConsejoDescripción
Agrupar erroresAgrupa los errores por funcionalidades. Te permitirá encontrar errores más rápido y dividir la clase si el proyecto crece mucho
Añade comentarios por funciónAñade comentarios en el enum para separar cada funcionalidad
Nombres descriptivosUtiliza nombres claros y representativos para cada error que ayuden a otros desarrolladores.
Revisar periódicamenteHaz revisiones regulares del enum para eliminar errores obsoletos o redundantes, manteniendo el código limpio y actualizado.

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.

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

CampoDescripción
codeCódigo de error de tipo CrmErrorMessage que identifica el tipo de error ocurrido.
descriptionMensaje legible que describe el error para que el consumidor de la API lo entienda fácilmente.

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:

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ón del código

Anotaciones

AnotaciónDescripciónDocumentación
@RestControllerAdviceIndica que la clase es un controlador global de excepciones. Permite capturar y manejar excepciones lanzadas por cualquier controlador REST.@RestControllerAdvice
@ExceptionHandlerEl método manejará una o varias excepciones específicas. Permite definir la lógica para procesar y responder ante esas excepciones.@ExceptionHandler

Los parámetros más interesantes de @RestControllerAdvice son:

ParámetroDescripción
basePackagesPermite especificar uno o varios paquetes base donde se aplicará el controlador global de excepciones. Solo los controladores en estos paquetes serán afectados. Ejemplo: @RestControllerAdvice(basePackages = "com.test.crm.infrastructure.controllers")
assignableTypesPermite indicar clases concretas de controladores a los que se aplicará el advice. Ejemplo: @RestControllerAdvice(assignableTypes = {UserController.class, CustomerController.class})

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

Aspecto@ControllerAdvice@RestControllerAdvice
PropósitoDiseñado para controladores MVC que devuelven vistas (HTML, JSP, Thymeleaf).Orientado a controladores REST que devuelven datos (JSON, XML).
Anotación internaUsa@Component.Combina @ControllerAdvice y @ResponseBody para serializar automáticamente la respuesta.
Uso típicoAplicaciones web con páginas HTML y plantillas MVC.APIs RESTful que devuelven datos estructurados.
Devolver JSON/XMLRequiere agregar @ResponseBodyDevuelve automáticamente

Ejemplo de uso en un servicio

Para lanzar una excepción de dominio en un servicio, puedes hacer lo siguiente:

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
  • Manejo de Excepciones
  • API REST
  • Buenas Prácticas
  • Java
Compartir: