CodeGym /Cursos /Spring REST & MVC /Diseño de GlobalExceptionHandler

Diseño de GlobalExceptionHandler

Spring REST & MVC
Nivel 20, Lección 2
Disponible

1. El papel de las excepciones de dominio en el manejo de errores

Las excepciones de dominio no son «algo salió mal en Spring», sino una parte normal y esperada del comportamiento de su aplicación. Es el momento en que el cliente hace una solicitud lógica y el sistema responde honestamente: «recurso no encontrado» o «esta acción está prohibida por una regla de negocio». Si las excepciones de dominio se manejan de cualquier manera, la API se convierte en una serie de «adivina el código de estado según el estado de ánimo del servicio».

Imagine: el cliente solicita GET /api/v1/tasks/{taskId}, y la tarea no existe. Esto no es un «error del servidor» y ciertamente no es motivo para devolver un 500 y fingir que Spring «no pudo manejarlo». Es una rama normal del escenario, para la cual debemos poder devolver de manera estable un 404 Not Found y un ProblemDetail comprensible. Del mismo modo, si el cliente intenta adjuntar un archivo a una tarea archivada o realizar una transición de estado prohibida, esto no es un «JSON no válido» ni un «parámetro de consulta incorrecto», sino un conflicto de negocio que debe convertirse en un honesto 409 Conflict.

La conclusión clave para esta lección: una excepción de dominio debe ser una señal de significado, y un GlobalExceptionHandler debe ser el traductor de ese significado al lenguaje de las respuestas HTTP, que sea uniforme para toda la API.

2. Límite de capas: dominio y capa web

Para no convertir la aplicación en un caos, es importante mantener el límite: la capa de servicio habla del problema del dominio (a través de una excepción), y la capa web decide cómo se refleja esto en HTTP. De lo contrario, obtendrá servicios que conocen HttpStatus, ResponseEntity, application/problem+json, y luego se preguntará por qué no se pueden probar ni reutilizar correctamente sin MVC.

Es muy útil «recorrer» mentalmente la solicitud a través del pipeline una vez. En nuestro curso simplificamos la infraestructura (repositorios en memoria), pero el flujo de errores sigue siendo similar al de producción: el controlador llama al servicio, el servicio trabaja con el repositorio, en caso de problema lanza una excepción de dominio, esta «flota» de vuelta, y @ControllerAdvice la convierte en un ProblemDetail.

flowchart TD
    A[Solicitud HTTP] --> B["@RestController"]
    B --> C[TaskService]
    C --> D[Repositorio en memoria]
    D --> C
    C -->|lanza DomainException| E["@ControllerAdvice GlobalExceptionHandler"]
    E --> F["ResponseEntity<ProblemDetail>"]
    F --> G[Respuesta HTTP]

¿Por qué este esquema es tan importante ahora? Porque mantiene la pureza de las capas. Los controladores se vuelven cortos y legibles, los servicios no «filtran» detalles HTTP, y todo el estilo de errores se recopila en un solo lugar. Y sí — este es el caso en que «un archivo para los errores» inesperadamente hace que el proyecto sea más simple, no más complejo.

Un único advice ya sabe convertir una excepción en una respuesta HTTP. Ahora es importante hacer que las propias excepciones sean significativas para la aplicación: si al advice llegan simplemente RuntimeException aleatorias con cadenas diferentes, el contrato se desvanece de nuevo. Por lo tanto, los errores de dominio necesitan su propio modelo normal.

3. Excepciones de dominio sin HTTP

Antes de aumentar GlobalExceptionHandler con reglas de dominio, es necesario decidir cómo serán en general las excepciones de dominio. Aquí queremos hacer un modelo lo más simple posible, pero disciplinado. Es importante que la excepción de dominio sea completamente «Spring-free»: no debe importar HttpStatus, ResponseEntity, ProblemDetail ni nada de MVC. Su tarea es expresar el significado aplicado del problema.

Para Task Tracker API nos conviene tener dos elementos: ErrorCode (código de error legible por máquina que irá en ProblemDetail) y una clase base de excepción de dominio que lleve este código. No es una obligación «de libro de texto», sino simplemente una forma de evitar miles de líneas de código repetido y «cadenas mágicas» en diferentes lugares.

ErrorCode: diccionario de errores del dominio

Empecemos con un enum simple. Vive en la capa de dominio (por ejemplo, com.example.tasktracker.domain.exception), porque estos códigos son parte del significado aplicado. La capa web luego los usará para formar la respuesta.

package com.example.tasktracker.domain.exception;

public enum ErrorCode {
    // Códigos legibles por máquina: convenientes para devolver al cliente en ProblemDetail (campo code)
    TASK_NOT_FOUND,
    COMMENT_NOT_FOUND,
    INVALID_STATUS_TRANSITION,
    FILE_UPLOAD_NOT_ALLOWED
}

Aquí ya hay disciplina: el código de error no se toma «de la nada» con una cadena "task_not_found" en un lugar y "TASK_NOT_FOUND" en otro. Un solo enum — una única fuente de verdad. Si se equivoca en el nombre del código, el compilador será su amigo estricto (y no «lo veremos en los registros después»).

DomainException base: significado + código, sin HTTP

Ahora la clase base. Almacena ErrorCode y un mensaje. El mensaje (message) es lo que normalmente se convertirá en detail en ProblemDetail. El código es lo que se convertirá en code. Por ahora, conscientemente no incorporamos el estado HTTP aquí para no romper las capas.

package com.example.tasktracker.domain.exception;

public abstract class DomainException extends RuntimeException {

    // Código de error del dominio: no se trata de HTTP, sino de significado (qué está violado exactamente)
    private final ErrorCode code;

    protected DomainException(ErrorCode code, String message) {
        super(message); // El mensaje se usará como detail en ProblemDetail
        this.code = code;
    }

    public ErrorCode getCode() {
        return code;
    }
}

Esta es una clase muy pequeña, pero hace algo importante: unifica el contrato de los errores de dominio. Cualquier excepción de dominio ahora «por defecto» tiene un código de error.

Excepción concreta: TaskNotFoundException

Ahora, los detalles. Por ejemplo, la ausencia de una tarea por su id. Observe: la excepción habla del dominio («tarea no encontrada»), no del transporte («404 not found»). Esta es una diferencia sutil pero fundamental.

package com.example.tasktracker.domain.exception;

public class TaskNotFoundException extends DomainException {

    public TaskNotFoundException(String taskId) {
        // Importante: aquí fijamos el significado de dominio (TASK_NOT_FOUND), no el estado HTTP
        super(ErrorCode.TASK_NOT_FOUND, "Task '%s' not found".formatted(taskId));
    }
}

De manera similar se crean CommentNotFoundException, InvalidStatusTransitionException y otras. La regla principal: la excepción debe ser legible en los registros y comprensible como significado, pero no debe convertirse en una «excepción HTTP en el dominio».

Mapeo de errores de dominio: excepción → estado HTTP → ProblemDetail + code

Cuando las excepciones están listas, surge la pregunta principal: ¿quién decide qué estado devolver? La respuesta es GlobalExceptionHandler. Y para que no se convierta en una «sábana de condiciones», es útil escribir el mapeo de antemano como un pequeño contrato. Esto no es burocracia, es una forma de hacer que la API sea predecible.

A continuación se muestra un ejemplo de una tabla de correspondencias mínima para escenarios de dominio. Conscientemente no agregamos errores a nivel de framework (JSON mal formado, desajuste de tipos, tipo de medio no soportado) — para ellos, en este mismo handler habrá otra rama a través de ResponseEntityExceptionHandler.

Escenario de dominio Ejemplo de excepción Estado HTTP code
Recurso no encontrado TaskNotFoundException 404 Not Found TASK_NOT_FOUND
Subrecurso no encontrado CommentNotFoundException 404 Not Found COMMENT_NOT_FOUND
Transición de estado prohibida InvalidStatusTransitionException 409 Conflict INVALID_STATUS_TRANSITION
Operación prohibida (por ejemplo, subir archivo a una tarea archivada) FileUploadNotAllowedException 409 Conflict FILE_UPLOAD_NOT_ALLOWED

Aquí es importante que un mismo significado dé lugar de manera estable al mismo estado. Si a veces TASK_NOT_FOUND se convierte en 404, a veces en 400, y a veces en 200 con el mensaje "error": "not found", el código del cliente comenzará a sufrir. Y un cliente que sufre es como pruebas que fallan «a veces»: no es su problema, pero molesta a diario.

4. Implementación de GlobalExceptionHandler

Ahora armamos el manejador en sí. Queremos que se parezca a una «tabla de reglas»: qué excepción — qué estado — qué código — qué título. Y que al mismo tiempo ProblemDetail se construya de manera uniforme, sin repetir el mismo código en cada método.

Esqueleto de @RestControllerAdvice

La clase en sí suele ser conveniente mantenerla en la zona web/api, por ejemplo com.example.tasktracker.api.controller.advice. Esto es parte de la capa de API, por lo que las anotaciones de Spring son apropiadas aquí.

package com.example.tasktracker.api.controller.advice;

import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice // Un único advice para toda la capa web REST
public class GlobalExceptionHandler {
}

Por ahora está vacío, pero ya es un «ancla»: el único punto donde las reglas de errores vivirán de forma centralizada.

Helper para construir ProblemDetail

Construir ProblemDetail manualmente en cada @ExceptionHandler pronto cansa. Por eso hacemos un pequeño helper: una sobrecarga básica construye la forma común de la respuesta, y las ramas de dominio simplemente le añaden el code legible por máquina. Así el payload no se bifurca: cambia la fuente del error, no el formato de la respuesta.

import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;

private ProblemDetail problem(HttpStatusCode status, String title, String detail) {
    // Plantilla básica de ProblemDetail: status + detail
    ProblemDetail body = ProblemDetail.forStatusAndDetail(status, detail);

    // Título corto para humanos
    body.setTitle(title);

    return body;
}

private ProblemDetail problem(
        HttpStatusCode status,
        String title,
        String detail,
        String code
) {
    // Los errores de dominio usan la misma forma de ProblemDetail, simplemente con un code adicional
    ProblemDetail body = problem(status, title, detail);
    body.setProperty("code", code);
    return body;
}

Observe el «punto delicado de seguridad y calidad del contrato». En detail colocamos un mensaje comprensible, pero controlado. No colocamos ex.toString(), porque eso casi con seguridad arrastraría detalles internos al contrato público. Para las excepciones de dominio, ex.getMessage() suele ser adecuado, porque nosotros mismos lo formamos y entendemos lo que dice.

Manejador para TaskNotFoundException → 404

Ahora agregamos el mapeo concreto. Para TaskNotFoundException siempre devolvemos 404 y el code TASK_NOT_FOUND. title es un título corto legible por humanos, y detail es lo que explica la situación con más detalle.

import com.example.tasktracker.domain.exception.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
    ProblemDetail body = problem(
            HttpStatus.NOT_FOUND,      // Estado HTTP para el escenario "recurso no encontrado"
            "Task not found",          // Título corto
            ex.getMessage(),           // detail: mensaje controlado de la excepción de dominio
            ex.getCode().name()        // code legible por máquina (por ejemplo, TASK_NOT_FOUND)
    );

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}

Sí, aquí se ve que ex.getCode().name() devuelve la cadena TASK_NOT_FOUND. Ese es exactamente el código legible por máquina que es conveniente para que el cliente lo procese.

Manejador para conflictos de dominio → 409

Los errores de negocio conflictivos (transición de estado prohibida, prohibición de subir archivos, etc.) son 409 Conflict. Aquí también es importante ser coherente: 409 no es «algo está mal con el JSON», sino «la entrada es normal, pero la acción contradice las reglas».

Ejemplo para la transición de estado:

import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;

@ExceptionHandler(InvalidStatusTransitionException.class)
public ResponseEntity<ProblemDetail> handleInvalidStatusTransition(InvalidStatusTransitionException ex) {
    ProblemDetail body = problem(
            HttpStatus.CONFLICT,       // Conflicto de reglas de negocio, no un error de formato de solicitud
            "Invalid status transition",
            ex.getMessage(),
            ex.getCode().name()
    );

    return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}

Así, los escenarios de dominio no crean un segundo formato de errores: simplemente enriquecen el mismo ProblemDetail con el campo code.

Manejador general para el dominio «inesperado»

A veces se desea hacer un único @ExceptionHandler(DomainException.class) y mapear el estado según ErrorCode. Esto es aceptable, pero no lo haría como primer paso: para un desarrollador principiante, los métodos separados para excepciones separadas son más fáciles de leer. Cuando haya muchas excepciones, podrá refactorizar cuidadosamente, pero por ahora es mejor la «legibilidad del código» que la ultra-universalidad.

5. Controlador y servicio después del manejo

Después de que los errores de dominio se centralizan, el controlador finalmente deja de ser el lugar para «ensamblar errores». Solo habla del happy path: qué datos acepta y qué devuelve en caso de éxito. Y si algo está mal, la excepción flotará y GlobalExceptionHandler generará la respuesta correcta.

Ejemplo del servicio: busca una tarea y, si no la encuentra, lanza TaskNotFoundException. El servicio no construye ProblemDetail ni elige un código de estado — simplemente comunica honestamente el significado del dominio.

import com.example.tasktracker.domain.exception.TaskNotFoundException;

public TaskDetailsResponse getById(String taskId) {
    // El servicio habla en el lenguaje del dominio: "tarea no encontrada", no "devuelve 404"
    Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));

    // Happy path: mapeamos la entidad de dominio a DTO
    return taskMapper.toDetails(task);
}

Y ahora el controlador. Se vuelve muy corto, y esto no es «pérdida de control», sino precisamente una señal de una arquitectura saludable.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
    return taskService.getById(taskId);
}

Si la tarea existe — será 200 OK y un DTO. Si no — el servicio lanzará una excepción, y obtendrá un 404 con application/problem+json y el código TASK_NOT_FOUND.

Ejemplo de lo que verá el cliente (simplificado):

{
  "type": "about:blank",
  "title": "Task not found",
  "status": 404,
  "detail": "Task 'b3d2...' not found",
  "code": "TASK_NOT_FOUND"
}

Sí, type por defecto será about:blank, y instance puede estar vacío — esto es normal para el paso actual. Lo más importante hoy: estabilizar los estados y code para las excepciones de dominio y dejar de distribuir el manejo de errores por los controladores.

6. Fallback a 500

Incluso si ha manejado perfectamente todas las excepciones de dominio, la vida seguramente le presentará algo inesperado: NullPointerException, un error en el mapper, un error en el repositorio en memoria, etc. Debe tener un único manejador «de seguridad» que devuelva 500, pero que no revele detalles internos al exterior.

Este es el lugar donde los principiantes a menudo cometen dos errores extremos. El primero es devolver al exterior ex.getMessage() y así publicar detalles internos (a veces incluso fragmentos de datos). El segundo es devolver una sola frase "Error" y perder capacidad de diagnóstico. El equilibrio correcto: al exterior se devuelve un mensaje controlado, hacia adentro (en los registros) se escriben los detalles.

Ejemplo de un manejador mínimo:

import org.springframework.http.HttpStatus;

@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
    // Última línea de defensa: capturamos todo lo inesperado y devolvemos un contrato de error unificado
    // Importante: NO devolvemos ex.getMessage() al exterior para evitar que se filtren detalles internos
    ProblemDetail body = problem(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "Internal error",
            "Unexpected error",
            "INTERNAL_ERROR"
    );

    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}

Sí, INTERNAL_ERROR aquí no proviene del ErrorCode de dominio: es un marcador técnico en el mismo payload. El principio sigue siendo el mismo: los errores inesperados no deben caer en un formato separado ni convertirse en respuestas «como sea».

7. Errores típicos al diseñar un GlobalExceptionHandler

Error n.º 1: la capa de dominio comienza a importar HttpStatus y «entender HTTP».
Esto sucede a menudo cuando se quiere «simplificar» y hacer throw new DomainException(HttpStatus.NOT_FOUND, ...). Gana unas pocas líneas rápidamente, pero pierde arquitectura: el servicio se vuelve específico de la web y el límite entre capas se difumina. Es mejor mantener el mapeo HTTP en @ControllerAdvice.

Error n.º 2: el mismo significado de error se mapea a diferentes códigos de estado.
Hoy decide que TASK_NOT_FOUND es 404, mañana en otro controlador alguien devuelve 400 porque «el cliente envió un id incorrecto». Al código del cliente le da igual cómo lo explique: necesita un comportamiento estable. Para una excepción debe haber un estado estable.

Error n.º 3: se filtra al exterior ex.toString() o una «sábana» técnica en lugar de un detail controlado.
ex.toString() a menudo incluye el nombre de la clase de la excepción, a veces detalles anidados, a veces valores internos. Esto se convierte fácilmente en una filtración pública de los internos del proyecto. Para las excepciones de dominio, generalmente es suficiente ex.getMessage() porque usted mismo lo forma. Para errores inesperados, es mejor devolver un detail fijo.

Error n.º 4: copiar y pegar la construcción de ProblemDetail en cada manejador.
Al principio parece que «bueno, solo tres métodos». Luego se convierten en siete. Luego quince. Luego en un lugar code se establece mediante setProperty, en otro se olvida, en un tercero se llama al campo errorCode, y el contrato se desvanece. Un método helper no es una «capa extra», es una protección contra la dispersión.

Error n.º 5: un único @ExceptionHandler(Exception.class) sin manejadores de dominio específicos.
Esto «funciona», pero destruye la precisión. Al final, TaskNotFoundException termina en 500, el cliente recibe "Internal error" y comienza a pensar que el servidor se cayó. Y el servidor en realidad simplemente no encontró la tarea honestamente. En una API, esto es una gran diferencia.

1
Tarea
Spring REST & MVC, nivel 20, lección 2
Bloqueada
Excepciones de dominio con `ErrorCode` para la API del almacén
Excepciones de dominio con `ErrorCode` para la API del almacén
1
Tarea
Spring REST & MVC, nivel 20, lección 2
Bloqueada
Dos excepciones de dominio con diferentes estados HTTP
Dos excepciones de dominio con diferentes estados HTTP
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION