← APIs REST: recursos, DTO, validación y errores

Sesión 13 · Semana 7

Reglas propias y errores coherentes

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado el ci del repositorio de servidor. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

La validación básica ya rechaza entradas, pero sus mensajes y los demás errores necesitan un formato común. Una excepción representa un fallo que el código comunica; un manejador global la transforma en una respuesta. También crearás validadores para reglas que las anotaciones existentes no expresan.

Ponte en el otro lado

Estás escribiendo un cliente contra una API ajena. Envías tu formulario y recibes esto:

{ "timestamp": "...", "status": 400, "error": "Bad Request", "path": "/tareas" }

¿Qué haces ahora? Solo te quedan tres opciones, y las tres son malas: probar campo por campo hasta acertar, buscar una documentación que quizá no exista, o escribirle a quien hizo la API.

Esa API eres tú desde ayer. Hoy lo arreglamos.

Un mensaje útil dice qué se espera

Los mensajes por defecto están en inglés y describen la restricción, no el problema. Compara:

Describe la restricción

«must not be blank», «size must be between 3 and 120». Traduce la anotación. Quien lo lee tiene que deducir qué hacer.

Dice qué corregir

«El título es obligatorio», «El título debe tener entre 3 y 120 caracteres». Ambos mensajes son presentables al usuario final sin reformular.

Cada anotación acepta un message:

public class TareaRequest {

    @NotBlank(message = "El título es obligatorio")
    @Size(min = 3, max = 120,
          message = "El título debe tener entre 3 y 120 caracteres")
    private String titulo;

    @NotNull(message = "La prioridad es obligatoria")
    @Pattern(regexp = "baja|media|alta",
             message = "La prioridad debe ser baja, media o alta")
    private String prioridad;

    @NotNull(message = "Toda tarea debe pertenecer a un proyecto")
    @Positive(message = "El identificador de proyecto debe ser un número positivo")
    private Integer proyectoId;
}

Las cuatro reglas de un buen mensaje

  1. Di qué se espera, no qué está mal. «Debe tener entre 3 y 120 caracteres» es accionable; «longitud inválida» no.
  2. Incluye los valores admitidos cuando sean pocos: «baja, media o alta» ahorra una consulta a la documentación.
  3. Habla del dominio, no del código. «Toda tarea debe pertenecer a un proyecto», no «proyectoId no puede ser null».
  4. No cuentes cómo está hecho por dentro. Nada de nombres de tablas, de clases ni de columnas.
Sacar los mensajes a un archivo

Los mensajes también pueden vivir fuera del código, en src/main/resources/ValidationMessages.properties, y referenciarse entre llaves:

@NotBlank(message = "{tarea.titulo.obligatorio}")

Sirve para traducir la API a varios idiomas y para revisar todos los textos de una vez sin abrir veinte clases. No lo necesitamos aquí, pero es lo que verás en cualquier aplicación que se publique en más de un idioma.

Cinco errores, cinco formatos

Provoca los cinco, uno detrás de otro, y copia el cuerpo de cada respuesta. Es el punto de partida de la sesión.

Provoca Código Qué cuerpo recibes
GET /tareas/999 404 Vacío del todo
POST /tareas con {} 400 El objeto de Spring con errors
POST /tareas con {,} 400 Otro objeto distinto, con message de Jackson
POST /tareas sin Content-Type 415 Otro más
Una excepción dentro de tu método 500 Otro más, quizá con la traza

Cinco fallos, cinco formas distintas, y una de ellas ni siquiera tiene cuerpo.

Ponte otra vez en el lado del cliente. Para tratar los errores de tu API tiene que escribir un caso especial por cada uno, y el 404 no le da nada con lo que trabajar: solo sabe que algo no estaba, pero no qué.

Lo que se promete en un contrato

Una API no promete solo qué devuelve cuando todo va bien. Promete también cómo son sus fallos.

Si los errores tienen una forma única y predecible, quien consume la API escribe el tratamiento una vez y le sirve para todos los endpoints, incluidos los que aún no existen.

Diseña el formato antes de escribir código

Un error útil responde a cuatro preguntas: qué ha pasado, cuándo, dónde y qué hay que corregir.

package com.ejemplo.gestor.error;

import java.time.LocalDateTime;
import java.util.List;

public record ErrorResponse(
        LocalDateTime momento,
        int estado,
        String error,
        String mensaje,
        String ruta,
        List<ErrorDeCampo> errores) {

    public record ErrorDeCampo(String campo, String motivo) {
    }
}
Campo Para qué sirve
momento Correlacionar con los registros del servidor cuando alguien reporta un fallo
estado El mismo número del código HTTP, repetido para quien solo lee el cuerpo
error El nombre legible: Not Found, Bad Request
mensaje Qué ha ocurrido, en una frase
ruta Qué se estaba pidiendo
errores La lista de campos que fallan. Vacía cuando el error no es de validación
Esto ya está estandarizado: Problem Details

Existe un estándar para el cuerpo de los errores HTTP, el RFC 9457, con campos fijos —type, title, status, detail, instance— y su propio tipo de contenido, application/problem+json.

Spring lo trae de serie en la clase ProblemDetail, y en una API pública es lo que conviene usar: quien la consuma reconocerá el formato sin leer tu documentación.

Aquí construimos el nuestro porque diseñarlo enseña qué preguntas tiene que responder un error. Cuando lo tengas claro, cambiar a ProblemDetail es media hora.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre tu proyecto y ejecuta la colección de peticiones de la sesión 12 en Bruno o Postman.
  2. Reproduce un 400 de validación (por ejemplo, enviando un cuerpo vacío {} a POST /tareas) y un 404 (consultando GET /tareas/999).
  3. Guarda ambas respuestas para compararlas: fíjate en qué estructura devuelve cada una y qué información le falta a quien consume la API.

Paso 2 · Consultar los mensajes de validación que devuelve la API

La información existe: Spring sabe qué campo ha fallado y por qué, y lo tienes en la consola. Simplemente no se envía al cliente, porque por defecto no se publican detalles de error.

Añade estas dos líneas en src/main/resources/application.properties:

server.error.include-message=always
server.error.include-binding-errors=always

Reinicia la aplicación y repite el POST /tareas con el cuerpo vacío {}:

{
  "timestamp": "2026-09-02T10:14:22.831+00:00",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for object='tareaRequest'. Error count: 3",
  "errors": [
    { "field": "titulo", "defaultMessage": "must not be blank" },
    { "field": "prioridad", "defaultMessage": "must not be null" },
    { "field": "proyectoId", "defaultMessage": "must not be null" }
  ],
  "path": "/tareas"
}

Ya se puede trabajar con eso: hay campos y hay motivos.

Dos avisos sobre estas dos líneas

Uno. Es una solución provisional mientras construimos el manejador. El formato lo decide Spring, no tú, y arrastra ruido como defaultMessage o object=. En unos pasos diseñarás tu propio formato estructurado y estas propiedades sobrarán.

Dos. include-message=always también publica el mensaje de cualquier excepción, incluidas las inesperadas. En una aplicación real eso puede filtrar detalles internos a quien no debería verlos. Aquí se acepta temporalmente como andamio de aprendizaje.

Paso 3 · Reescribir los mensajes con las cuatro reglas

Los mensajes por defecto ("must not be blank", "must not be null") están en inglés y describen la regla técnica en lugar del problema del usuario.

  1. Abre TareaRequest.java y ProyectoRequest.java.
  2. Añade el atributo message a cada anotación de validación, aplicando las cuatro reglas (di qué se espera, incluye los valores válidos, habla del dominio y no menciones código):
public class TareaRequest {

    @NotBlank(message = "El título es obligatorio")
    @Size(min = 3, max = 120, message = "El título debe tener entre 3 y 120 caracteres")
    private String titulo;

    @NotNull(message = "La prioridad es obligatoria")
    private String prioridad;

    @NotNull(message = "Toda tarea debe pertenecer a un proyecto")
    @Positive(message = "El identificador de proyecto debe ser un número positivo")
    private Integer proyectoId;

    // Getters y setters...
}
  1. Haz lo mismo en ProyectoRequest (nombre obligatorio y entre 3 y 80 caracteres, descripción de hasta 500 caracteres si existe).
  2. Reinicia y repite el POST /tareas con {}. Lee el JSON devuelto: comprueba que los mensajes ahora son claros y comprensibles directamente para un usuario final.

Paso 4 · Crear una anotación propia sobre un campo: @PrioridadValida

En la sesión anterior usaste @Pattern(regexp = "baja|media|alta"). Funciona, pero tiene tres inconvenientes:

  1. Está duplicada en TareaRequest y en TareaPatchRequest.
  2. Si mañana se añade la prioridad crítica, hay que acordarse de modificarla en varios archivos.
  3. No expresa la intención del dominio: obliga a descifrar una expresión regular.

Vamos a encapsular esa regla en una anotación propia. Crea dos archivos dentro del paquete com.ejemplo.gestor.validacion:

// validacion/PrioridadValida.java
package com.ejemplo.gestor.validacion;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PrioridadValidaValidator.class)
public @interface PrioridadValida {

    String message() default "La prioridad debe ser baja, media o alta";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
@Target(FIELD)
Indica dónde se puede aplicar la anotación. Aquí, sobre atributos de clase.
@Retention(RUNTIME)
Garantiza que la anotación siga existiendo mientras el programa se ejecuta para que el framework pueda leerla por reflexión.
@Constraint(validatedBy = ...)
Enlaza la anotación con la clase Java que contiene la lógica de comprobación real.
groups y payload
Parámetros exigidos por la especificación de Bean Validation. Se declaran tal cual.

Ahora crea la clase que valida el valor:

// validacion/PrioridadValidaValidator.java
package com.ejemplo.gestor.validacion;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.List;

public class PrioridadValidaValidator
        implements ConstraintValidator<PrioridadValida, String> {

    private static final List<String> VALIDAS = List.of("baja", "media", "alta");

    @Override
    public boolean isValid(String valor, ConstraintValidatorContext contexto) {
        // Un valor ausente es asunto de @NotNull, no nuestro.
        if (valor == null) {
            return true;
        }
        return VALIDAS.contains(valor);
    }
}

El condicional if (valor == null) return true no es un descuido: cada anotación comprueba una sola cosa. La presencia obligatoria es responsabilidad de @NotNull. Si este validador rechazara los nulos, no podrías usarlo en un campo opcional, que es exactamente lo que necesitas en una actualización parcial (PATCH).

Aplica la nueva anotación:

  1. En TareaRequest, donde es obligatoria, se combina con @NotNull:
@NotNull(message = "La prioridad es obligatoria")
@PrioridadValida
private String prioridad;
  1. En TareaPatchRequest, donde el campo es opcional, va sola:
@PrioridadValida
private String prioridad;

Prueba en Postman: una prioridad válida ("alta"), una prioridad no admitida ("urgente", debe devolver 400 con tu mensaje) y un campo omitido en PATCH (debe ser admitido).

Y aun así, la mejor validación es la que no hace falta

Si prioridad fuera un tipo enum en lugar de un String, un valor inválido sería imposible de representar: Jackson rechazaría "urgentísima" al deserializar y no habría regla que escribir ni mantener.

Antes de validar un dato, pregúntate si puedes elegir un tipo en el que el dato incorrecto no quepa. Volveremos a esta idea en la UD5 al modelar la base de datos.

Paso 5 · Reglas que miran dos campos a la vez: @FechasCoherentes

Hay reglas que no se pueden comprobar en un único atributo aislado. Por ejemplo: «en un proyecto, la fecha de fin no puede ser anterior a la de inicio». Para evaluar eso, el validador necesita tener acceso a ambos campos a la vez.

Por eso, esta restricción no se coloca sobre un campo, sino sobre la clase completa:

@FechasCoherentes
public class ProyectoRequest {

    @NotNull(message = "La fecha de inicio es obligatoria")
    private LocalDate fechaInicio;

    private LocalDate fechaFin;

    // Getters y setters...
}

Crea la anotación en src/main/java/com/ejemplo/gestor/validacion/FechasCoherentes.java. Fíjate en que ahora el objetivo es ElementType.TYPE:

package com.ejemplo.gestor.validacion;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FechasCoherentesValidator.class)
public @interface FechasCoherentes {

    String message() default "La fecha de fin no puede ser anterior a la de inicio";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

A continuación, crea el validador en src/main/java/com/ejemplo/gestor/validacion/FechasCoherentesValidator.java:

package com.ejemplo.gestor.validacion;

import com.ejemplo.gestor.dto.ProyectoRequest;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class FechasCoherentesValidator
        implements ConstraintValidator<FechasCoherentes, ProyectoRequest> {

    @Override
    public boolean isValid(ProyectoRequest entrada, ConstraintValidatorContext contexto) {
        if (entrada == null || entrada.getFechaInicio() == null || entrada.getFechaFin() == null) {
            return true; // Si alguna no viene, la obligatoriedad se delega a @NotNull.
        }

        // Si la fecha de fin no es anterior a la de inicio, es válida.
        if (!entrada.getFechaFin().isBefore(entrada.getFechaInicio())) {
            return true;
        }

        // Si es anterior, vinculamos el mensaje de error específicamente al campo fechaFin
        contexto.disableDefaultConstraintViolation();
        contexto.buildConstraintViolationWithTemplate(contexto.getDefaultConstraintMessageTemplate())
                .addPropertyNode("fechaFin")
                .addConstraintViolation();

        return false;
    }
}
¿Por qué usamos disableDefaultConstraintViolation y addPropertyNode("fechaFin")?
Por defecto, cuando una validación se ejecuta a nivel de clase, Spring asocia el error a la clase entera (a nivel de objeto). Con estas dos líneas desactivamos el error genérico y le decimos: «asocia este fallo expresamente al atributo fechaFin». De este modo, en el JSON de respuesta el cliente verá "campo": "fechaFin" y sabrá con precisión qué debe corregir.

Anota ProyectoRequest con @FechasCoherentes y compruébalo en Postman sobre POST /proyectos:

  • fechaFin posterior a fechaInicio ──> Aceptada (201 Created).
  • fechaFin igual a fechaInicio ──> Aceptada (201 Created).
  • fechaFin anterior a fechaInicio ──> Rechazada (400 Bad Request con tu mensaje).
  • fechaFin omitida (nula) ──> Aceptada si el campo es opcional.

Dónde se acaba lo que puede hacer Bean Validation

Todo lo que hemos hecho en estos pasos comprueba el objeto que ha llegado en la petición, y nada más. Puede mirar un campo o comparar varios entre sí.

Lo que no puede hacer es responder «¿existe el proyecto 7 en el sistema?», porque para saberlo hay que consultar los datos almacenados, y un validador no debe acceder a repositorios ni servicios. Esa comprobación es una regla de negocio, no una regla de formato, y se resuelve mediante excepciones en los pasos siguientes.

Errores coherentes de API

Paso 6 · Excepciones que hablan de tu dominio

Ahora mismo tu controlador devuelve ResponseEntity.notFound().build() desde dentro de un bucle. Mezcla dos responsabilidades distintas: buscar en la colección y decidir qué responder por HTTP.

Sepáralas. Primero, crea una excepción propia dentro del paquete com.ejemplo.gestor.error:

package com.ejemplo.gestor.error;

public class RecursoNoEncontradoException extends RuntimeException {

    public RecursoNoEncontradoException(String recurso, Object id) {
        super("No existe " + recurso + " con id " + id);
    }
}

Extiende RuntimeException para no tener que declararla en la firma de los métodos ni envolverla en bloques try/catch. El mensaje se construye además en un solo lugar, de modo que todos los «no encontrado» de la API se redactan de forma idéntica.

Con ella, el controlador se limita a comprobar si existe y seguir:

@GetMapping("/{id}")
public TareaResponse detalle(@PathVariable(name = "id") int id) {
    Tarea tarea = buscar(id);
    if (tarea == null) {
        throw new RecursoNoEncontradoException("tarea", id);
    }
    return TareaMapper.aRespuesta(tarea);
}

Fíjate en dos cambios importantes:

  1. Ya no devuelve ResponseEntity<TareaResponse>, sino TareaResponse directamente, porque por la vía normal solo tiene un final posible.
  2. El caso de error se reduce a una sola línea que se lee como una frase del dominio.

Aviso para la comprobación intermedia

Si pruebas esta petición ahora mismo en Postman (GET /tareas/999), Spring devolverá un 500 Internal Server Error con una traza roja en la consola de Java. Es normal: la excepción se ha lanzado correctamente, pero aún no hay ningún componente que la intercepte. En el siguiente paso creamos el manejador global que la convertirá en un limpio 404 Not Found.

Paso 7 · Centralizar la traducción de excepciones a respuestas HTTP

Para interceptar las excepciones que lanzan los controladores y traducirlas a respuestas HTTP estructuradas, Spring ofrece @RestControllerAdvice.

Primero, crea en el paquete com.ejemplo.gestor.error los dos records que definirán la forma del JSON de error:

// error/CampoError.java
package com.ejemplo.gestor.error;

public record CampoError(String campo, String motivo) {}
// error/ErrorResponse.java
package com.ejemplo.gestor.error;

import java.time.LocalDateTime;
import java.util.List;

public record ErrorResponse(
        LocalDateTime momento,
        int estado,
        String error,
        String mensaje,
        String ruta,
        List<CampoError> errores) {}

A continuación, crea la clase ManejadorDeErrores.java en ese mismo paquete:

package com.ejemplo.gestor.error;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@RestControllerAdvice
public class ManejadorDeErrores {

    private static final Logger log =
            LoggerFactory.getLogger(ManejadorDeErrores.class);

    @ExceptionHandler(RecursoNoEncontradoException.class)
    public ResponseEntity<ErrorResponse> noEncontrado(
            RecursoNoEncontradoException ex, HttpServletRequest peticion) {

        return construir(HttpStatus.NOT_FOUND, ex.getMessage(),
                peticion, List.of());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> validacion(
            MethodArgumentNotValidException ex, HttpServletRequest peticion) {

        List<CampoError> campos = new ArrayList<>();
        for (var error : ex.getBindingResult().getFieldErrors()) {
            campos.add(new CampoError(
                    error.getField(), error.getDefaultMessage()));
        }
        return construir(HttpStatus.BAD_REQUEST,
                "Hay campos que no son válidos", peticion, campos);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> cuerpoIlegible(
            HttpMessageNotReadableException ex, HttpServletRequest peticion) {

        return construir(HttpStatus.BAD_REQUEST,
                "El cuerpo de la petición no es un JSON válido",
                peticion, List.of());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> inesperado(
            Exception ex, HttpServletRequest peticion) {

        log.error("Error no controlado en {}", peticion.getRequestURI(), ex);
        return construir(HttpStatus.INTERNAL_SERVER_ERROR,
                "Ha ocurrido un error inesperado", peticion, List.of());
    }

    private ResponseEntity<ErrorResponse> construir(
            HttpStatus estado, String mensaje,
            HttpServletRequest peticion,
            List<CampoError> campos) {

        ErrorResponse cuerpo = new ErrorResponse(
                LocalDateTime.now(), estado.value(),
                estado.getReasonPhrase(), mensaje,
                peticion.getRequestURI(), campos);

        return ResponseEntity.status(estado).body(cuerpo);
    }
}
Cómo elige Spring el manejador
Por el tipo de la excepción, y siempre el más específico. Una RecursoNoEncontradoException encaja con el primer método y también con el último (que atrapa cualquier Exception); gana el específico.
Por qué el último existe
Para que ningún fallo se escape con un formato ajeno. Sin él, un NullPointerException imprevisto devolvería la página de error genérica de Spring, rompiendo el contrato con el cliente.
Por qué el último no dice qué ha pasado
Porque no se sabe si es seguro contarlo. El mensaje de una excepción inesperada puede incluir rutas de archivos internos, consultas SQL o datos de otros usuarios. Al cliente, un mensaje genérico y seguro; a la consola, el detalle completo.
Por qué se registra con log.error
Porque el cliente ya no recibe el detalle técnico, así que si no queda escrito en los registros del servidor, se pierde. Un 500 silencioso es un fallo imposible de diagnosticar.

La regla del 500

Nunca devuelvas una traza de excepción (stacktrace) a un cliente. Le dice qué framework usas, qué versiones, cómo se llaman tus clases y a veces qué datos manejabas. Es información de regalo para quien busque una vulnerabilidad, y ruido inútil para quien consume tu API de buena fe.

Paso 8 · Antes y después: comprobar la coherencia de los errores

Repite ahora las peticiones en Postman:

  1. GET /tareas/999 (debe responder 404 Not Found con mensaje claro).
  2. POST /tareas con {} (debe responder 400 Bad Request con los campos fallidos en castellano).
  3. POST /tareas con {,} (debe responder 400 Bad Request indicando que el JSON no es válido).

Comprueba que todas comparten la misma estructura limpia:

{
  "momento": "2026-09-02T10:14:22.831",
  "estado": 404,
  "error": "Not Found",
  "mensaje": "No existe tarea con id 999",
  "ruta": "/tareas/999",
  "errores": []
}
{
  "momento": "2026-09-02T10:15:03.122",
  "estado": 400,
  "error": "Bad Request",
  "mensaje": "Hay campos que no son válidos",
  "ruta": "/tareas",
  "errores": [
    { "campo": "titulo", "motivo": "El título es obligatorio" },
    { "campo": "prioridad", "motivo": "La prioridad debe ser baja, media o alta" }
  ]
}

Mismo formato, campos siempre en el mismo sitio y mensajes legibles. Un cliente puede programar el tratamiento de errores una sola vez para toda la aplicación.

Paso 9 · El paso al estándar: RFC 7807 / RFC 9457 (ProblemDetail)

El cambio es mínimo, porque el diseño conceptual ya está hecho. Solo cambian los nombres de los atributos para ajustarse al estándar HTTP:

Tu ErrorResponse El estándar RFC 7807 / 9457 Qué cambia
estado status Solo el nombre
error title Solo el nombre
mensaje detail Solo el nombre
ruta instance Solo el nombre
momento propiedad extra No es campo fijo del estándar: se añade con setProperty
errores propiedad extra Tampoco lo es; la llamaremos invalidParams

Spring Boot 3 trae la clase ProblemDetail de serie (en org.springframework.http.ProblemDetail). No hace falta añadir ninguna librería externa.

Vamos a migrar el manejador:

  1. Elimina el archivo ErrorResponse.java (conserva CampoError.java, que seguirá usándose para la lista de fallos de validación).
  2. En ManejadorDeErrores.java, sustituye el método auxiliar construir por este:
import org.springframework.http.ProblemDetail;
import java.net.URI;

    private ProblemDetail construir(
            HttpStatus estado, String mensaje,
            HttpServletRequest peticion,
            List<CampoError> campos) {

        ProblemDetail problema = ProblemDetail.forStatusAndDetail(estado, mensaje);
        problema.setTitle(estado.getReasonPhrase());
        problema.setInstance(URI.create(peticion.getRequestURI()));

        // Lo que el estándar no define de serie se añade como propiedad extra
        problema.setProperty("momento", LocalDateTime.now());
        if (!campos.isEmpty()) {
            problema.setProperty("invalidParams", campos);
        }
        return problema;
    }
  1. Cambia el tipo de retorno de los cuatro métodos @ExceptionHandler: de ResponseEntity<ErrorResponse> a ProblemDetail. Ya no hace falta envolverlos en ResponseEntity: Spring lee el código HTTP del propio ProblemDetail.

Repite el 404 y el 400 y revisa la respuesta:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "No existe tarea con id 999",
  "instance": "/tareas/999",
  "momento": "2026-09-02T10:14:22.831"
}

Fíjate en dos detalles nuevos fundamentales:

  1. El campo type: Es una URI que categoriza la clase de problema. about:blank indica que no hay documentación externa adicional más allá de la semántica del código HTTP.
  2. La cabecera Content-Type: Ya no es application/json, sino application/problem+json. Permite a cualquier cliente distinguir una respuesta de error estructurada de una respuesta de datos habitual.

Por qué hemos hecho este recorrido

Podríamos haber empezado directamente con ProblemDetail sin crear antes la clase propia. Pero diseñar primero tu propio formato es lo que hace que ahora comprendas por qué existen detail (qué ha ocurrido) o instance (qué ruta se estaba solicitando), en lugar de memorizar nombres en inglés sin contexto. El estándar se comprende mejor tras haber experimentado la necesidad que resuelve.

Paso 10 · El error que faltaba: el conflicto (409 Conflict)

Hay una familia de errores que tu API todavía no sabe expresar: cuando la petición tiene un formato y datos válidos, pero choca con el estado actual de los recursos en el servidor.

Ejemplos típicos:

  • Intentar registrar un proyecto con un nombre que ya está en uso.
  • Intentar borrar un proyecto que todavía contiene tareas asociadas.

No es un 400 Bad Request, porque los campos cumplen sus restricciones. Tampoco es un 404 Not Found, porque el recurso existe. Para estos casos, el código HTTP estándar es 409 Conflict.

Primero, crea la excepción en src/main/java/com/ejemplo/gestor/error/ConflictoException.java:

package com.ejemplo.gestor.error;

public class ConflictoException extends RuntimeException {

    public ConflictoException(String mensaje) {
        super(mensaje);
    }
}

A continuación, añade su manejador específico dentro de ManejadorDeErrores.java:

@ExceptionHandler(ConflictoException.class)
public ProblemDetail conflicto(ConflictoException ex, HttpServletRequest peticion) {
    return construir(HttpStatus.CONFLICT, ex.getMessage(), peticion, List.of());
}

Úsala en tu lógica (por ejemplo, en el método de creación de proyectos si ya existe uno con el mismo nombre):

if (existeProyectoConNombre(peticion.getNombre())) {
    throw new ConflictoException("Ya existe un proyecto con el nombre: " + peticion.getNombre());
}

Prueba a enviar dos veces seguidas la creación del mismo proyecto y comprueba que la segunda responde con 409 Conflict y la misma estructura application/problem+json.

Paso 11 · Checklist de consolidación: unificar toda tu API

Repasa esta lista para verificar que tu backend ha quedado completamente limpio y cohesionado:

  1. Paquete error ordenado: contiene RecursoNoEncontradoException, ConflictoException, el record auxiliar CampoError y la clase ManejadorDeErrores.
  2. Controladores limpios: sustituye cualquier ResponseEntity.notFound().build() residual por el lanzamiento de RecursoNoEncontradoException.
  3. Firmas simplificadas: los métodos de los controladores que solo tengan una vía de respuesta exitosa devuelven el DTO correspondiente directamente (por ejemplo, public TareaResponse detalle(...)), delegando los errores en las excepciones.
  4. Retirada del andamio provisional: elimina las dos propiedades server.error.* de application.properties. Tu manejador global se encarga ahora del 100% de los errores con total seguridad.
  5. Colección actualizada: guarda en Bruno o Postman peticiones para verificar: 404 (recurso ausente), 400 (validación de campos), 400 (JSON roto) y 409 (conflicto de negocio).

Paso 12 · Comprobar y registrar el resultado del proyecto

  1. Ejecuta tu colección completa y comprueba que todas las respuestas de error devuelven el código de estado adecuado y la estructura estándar application/problem+json.
  2. Verifica que los mensajes de validación son legibles y permiten corregir la petición sin mirar el código fuente.
  3. Provoca una entrada mal formada o una excepción imprevista y comprueba que el cliente recibe un error genérico seguro sin filtraciones de la traza de Java.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · La regla que no se deja validar

  1. Coge la regla «una tarea solo puede pertenecer a un proyecto que exista» e intenta implementarla como anotación propia. Llega hasta donde puedas.
  2. Vas a chocar con un muro concreto. Descríbelo: ¿qué necesita el validador que no tiene?
  3. Investiga si Spring permitiría dárselo, y en caso afirmativo explica por qué seguiría siendo mala idea meter ahí esa comprobación. Piensa en qué pasa cuando esa misma regla se necesita en una operación que no venga de una petición HTTP.
  4. Propón dónde debería vivir la comprobación y qué código de estado debería producir. Argumenta entre 400 y 404.

Este reto no tiene una solución cerrada y es el que más se parece a una discusión de equipo real.

Objetivo mínimoMensajes visibles y reescritos en todos los DTO de entrada, aplicando las cuatro reglas.
Si lo tienes@PrioridadValida implementada y en uso, más una segunda anotación de tu dominio con su prueba.
RetoEl muro del validador descrito, y la comprobación de existencia situada y argumentada.
Ver respuestas

1 · Que permite actuar sin consultar información adicional e incluso mostrarse literalmente al usuario final. Decir «longitud inválida» obliga a buscar cuál es la longitud correcta.

2 · Porque cada restricción comprueba una sola cosa: la obligatoriedad es trabajo de @NotNull. Si además rechazara los nulos, no podría usarse en un campo opcional como los del PATCH.

3 · Sobre la clase, con @Target(TYPE), porque necesita ver el objeto completo para comparar sus campos.

4 · Porque el valor incorrecto deja de ser representable: no hace falta escribir ni mantener una regla para algo que el tipo ya impide.

Reto · La prueba de que no se escapa nada

  1. Añade a la colección una comprobación de formato para cada error: que existan las claves estado, mensaje y ruta.
  2. Escribe un endpoint temporal que lance una excepción a propósito:
@GetMapping("/boom")
public String boom() {
    throw new IllegalStateException("Contraseña de la base de datos: 1234");
}
  1. Llámalo y comprueba dos cosas: que el cliente recibe 500 con tu formato, y que ese texto no aparece por ninguna parte de la respuesta.
  2. Comprueba que sí aparece en la consola del servidor.
  3. Borra el endpoint y explica en las decisiones técnicas qué habría pasado si el manejador genérico devolviera ex.getMessage().

El paso 3 es el que hay que ver con los propios ojos. Es la diferencia entre entender la regla del 500 y creérsela.

Objetivo mínimoEl manejador con los cuatro casos y todos los 404 pasando por la excepción propia.
Si lo tienesLos cinco errores unificados, el conflicto implementado y las propiedades provisionales retiradas.
RetoLa comprobación de formato en la colección y la demostración de que el mensaje interno no sale.
Ver respuestas

1 · Que escribe el tratamiento de errores una sola vez y le sirve para todos los endpoints, incluidos los que todavía no existen.

2 · Porque el mensaje de una excepción inesperada puede contener rutas, consultas o datos internos. Al cliente se le da un mensaje genérico y el detalle se queda en el servidor.

3 · Cuando la petición es válida y comprensible pero choca con el estado actual de los datos: un nombre repetido, un borrado que dejaría datos huérfanos.

4 · En el registro del servidor, escrito por el manejador con log.error. Si no se registra ahí, se pierde y el fallo será indiagnosticable.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Las respuestas 400, 404 y 409 tienen una estructura coherente y mensajes útiles, sin trazas internas.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.