← 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 los DTO validados, sus mensajes y los controladores. Reproduce un 400 de validación y un 404; guarda ambas respuestas para compararlas.
  2. Elige una regla propia de un campo y otra que compare dos campos. Escribe un caso válido y uno inválido para cada una.
  3. Localiza dónde se generan errores de recurso ausente y de conflicto. Crearás excepciones del dominio y un manejador en un paquete de errores bajo tu paquete base.

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

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

En application.properties:

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

Reinicia y repite el POST 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. El formato lo decide Spring, no tú, y arrastra ruido como defaultMessage o object=. En la sesión 13 diseñarás tu propio formato 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 porque estamos aprendiendo y porque dura una sesión.

Paso 3 · Crear una anotación de validación y su validador

Crea dos archivos distintos dentro de validacion: PrioridadValida.java para la anotación y PrioridadValidaValidator.java para la clase que implementa isValid. Copia cada bloque en su archivo, con su declaración de paquete e imports. Cuando ambos compilen, sustituye @Pattern por @PrioridadValida en los DTO; conserva @NotNull solo donde el campo sea obligatorio. Prueba una prioridad válida, otra no admitida y un campo omitido.

@Pattern(regexp = "baja|media|alta") funciona, y tiene tres problemas:

  1. Está repetida en TareaRequest y en TareaPatchRequest.
  2. Si mañana se añade la prioridad crítica, hay que acordarse de los dos sitios.
  3. No dice nada: hay que leer la expresión regular para entender la regla.

Vamos a convertir esa regla del dominio en una anotación propia.

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)
Dónde se puede poner esta anotación. Aquí, sobre atributos.
@Retention(RUNTIME)
Que siga existiendo mientras el programa se ejecuta. Sin esto, la anotación desaparece al compilar y nadie la ve.
@Constraint(validatedBy = ...)
Qué clase contiene la comprobación de verdad. La anotación solo es la etiqueta.
groups y payload
La especificación de Bean Validation los exige, aunque su uso es infrecuente. Se reproducen sin modificación.
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);
    }
}

Ese if (valor == null) return true no es un descuido: cada anotación comprueba una sola cosa. Si además rechazara los nulos, no podrías tener un campo opcional con esta regla, y es justo lo que necesitas en el PATCH.

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

En TareaPatchRequest, donde el campo es opcional, va sola:

@PrioridadValida
private String prioridad;

La regla vive ahora en un solo sitio, se lee sin descifrar nada, y añadir crítica es tocar una línea.

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

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

Antes de validar un dato, pregúntate si puedes elegir un tipo en el que el dato incorrecto no quepa. Es una idea que vale para toda la carrera, y volveremos a ella en la UD5 al modelar la base de datos.

Paso 4 · Reglas que miran dos campos a la vez

Reutiliza tu ProyectoRequest: añade fechaInicio y fechaFin sin borrar sus otros campos y genera sus getters y setters. Importa LocalDate de java.time y NotNull de jakarta.validation.constraints. Después crea estos dos archivos en validacion:

// validacion/FechasCoherentes.java
package com.ejemplo.gestor.validacion;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@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 {};
}
// 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; // La obligatoriedad se comprueba con @NotNull.
        }
        if (!entrada.getFechaFin().isBefore(entrada.getFechaInicio())) return true;
        contexto.disableDefaultConstraintViolation();
        contexto.buildConstraintViolationWithTemplate(contexto.getDefaultConstraintMessageTemplate())
            .addPropertyNode("fechaFin").addConstraintViolation();
        return false;
    }
}

La violación se asocia a fechaFin para que el manejador de errores de campo pueda mostrarla. Importa FechasCoherentes en el DTO y coloca @FechasCoherentes sobre la clase. En el POST de proyecto conserva @Valid. Prueba fechaFin posterior (aceptada), igual (aceptada), anterior (400) y omitida (aceptada si es opcional), manteniendo válidos los demás campos. Si conviertes el DTO a record más adelante, cambia los accesos a fechaInicio() y fechaFin() y conserva las restricciones que tu dominio requiera.

En la sesión 12 encontraste reglas que no encajaban en un solo campo. La más habitual: «la fecha de fin no puede ser anterior a la de inicio».

Ese tipo de restricción se pone sobre la clase, no sobre un atributo, porque necesita ver el objeto entero:

@FechasCoherentes
public class ProyectoRequest {

    @NotNull
    private LocalDate fechaInicio;

    private LocalDate fechaFin;
}

Se escribe igual que la de antes, cambiando dos cosas: @Target(TYPE) en la anotación, y ConstraintValidator<FechasCoherentes, ProyectoRequest> en el validador, cuyo isValid recibe el objeto completo y puede comparar los dos campos.

Dónde se acaba lo que puede hacer Bean Validation

Todo lo de hoy comprueba el objeto que ha llegado, y nada más. Puede mirar un campo, o varios campos entre sí.

Lo que no puede es responder «¿existe el proyecto 7?», porque para eso hay que consultar los datos, y un validador no tiene acceso a ellos. Esa comprobación es una regla de negocio, no una regla de formato, y su sitio es otro: se resuelve con una excepción propia en la sesión 13 y encontrará su hogar definitivo en la UD4.

Paso 5 · Reescribe todos tus mensajes

  1. Activa las dos propiedades y comprueba que ves los mensajes.
  2. Recorre todos tus DTO de entrada y pon un message en cada restricción, aplicando las cuatro reglas.
  3. Envía {} a la creación de tareas y de proyectos, y lee el resultado como si fueras el cliente.
  4. Para cada mensaje, pregúntate: ¿podría mostrarlo literalmente a un usuario final? Si la respuesta es no, reescríbelo.

Paso 6 · Tu propia anotación

  1. Implementa @PrioridadValida y úsala en los dos DTO de tareas.
  2. Crea una segunda anotación propia para una regla real de tu dominio. Algunas ideas: un código de proyecto con un formato concreto, un nombre sin caracteres especiales, una fecha que no sea festivo.
  3. Añade a la colección una petición que la incumpla y comprueba que responde 400 con tu mensaje.
  4. Escribe en las decisiones técnicas por qué esa regla merece una anotación propia en lugar de un @Pattern.

Errores coherentes de API

Paso 7 · Excepciones que hablan de tu dominio

Ahora mismo tu controlador devuelve ResponseEntity.notFound().build() desde dentro de un bucle. Mezcla dos cosas: buscar y decidir qué responder.

Sepáralas. Primero, una excepción propia:

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 cada firma ni envolverla en try. El mensaje se construye además en un solo lugar, de modo que todos los «no encontrado» de la API se redactan igual.

Con ella, el controlador se limita a decir la verdad 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: ya no devuelve ResponseEntity, porque solo tiene un final posible; y el caso de error es una línea que se lee como una frase.

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

Copia estos records antes del manejador; cada declaración pública va en el archivo que indica su nombre:

// 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) {}

Comprueba que todos los métodos del manejador utilizan List<CampoError>. En el paso 10 se retira ErrorResponse, pero se conserva CampoError para los detalles de validación.

Antes del manejador, crea en el paquete error los tipos que utiliza: CampoError(String campo, String motivo) y ErrorResponse(LocalDateTime momento, int estado, String error, String mensaje, String ruta, List<CampoError> errores), cada uno como record en su propio archivo. Importa java.time.LocalDateTime y java.util.List donde se necesitan. Después crea ManejadorDeErrores.java con el bloque completo. En el paso 10 evolucionará a ProblemDetail: esa evolución sustituye firmas y retorno, no añade un segundo manejador para la misma excepción.

@RestControllerAdvice

Una clase que atiende las excepciones de todos los controladores. Cuando un método tuyo lanza algo y no lo captura nadie, Spring busca aquí quién sabe convertirlo en respuesta.

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 primero y también con el último, que atrapa cualquier Exception; gana el primero.
Por qué el último existe
Para que ningún fallo se escape con un formato ajeno. Sin él, un NullPointerException devolvería la página de error de Spring, que es otra forma distinta.
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, consultas o datos de otro usuario. Al cliente, un mensaje genérico; a la consola, todo.
Por qué se registra con log.error
Porque el cliente ya no recibe el detalle, así que si no queda escrito en el servidor, se pierde. Un 500 silencioso es un fallo que nadie podrá diagnosticar.

La regla del 500

Nunca devuelvas una traza de excepción 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 un agujero, y ruido inútil para todos los demás.

Ahora ya puedes quitar las dos propiedades server.error.* de ayer: eran el andamio, y esto es el edificio.

Paso 9 · Antes y después

Repite los cinco errores del principio. Ahora los cinco responden igual:

{
  "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" }
  ]
}

Ese es el formato que acabas de diseñar. Dentro de un momento lo pasaremos al estándar, y lo único que cambiará serán los nombres de los campos:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Hay campos que no son válidos",
  "instance": "/tareas",
  "momento": "2026-09-02T10:15:03.122",
  "invalidParams": [
    { "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, mensajes en tu idioma y escritos por ti. Un cliente escribe el tratamiento una vez.

Paso 10 · El último paso · pasar tu formato al estándar

Convierte el manejador en este orden: importa ProblemDetail y java.net.URI; sustituye el método auxiliar construir; cambia los retornos de sus métodos públicos a ProblemDetail; comprueba que todos llaman al nuevo auxiliar. Mantén las anotaciones @ExceptionHandler. Ejecuta primero un 404 y luego un 400 con varios campos: los errores por campo deben seguir apareciendo en invalidParams, no perderse al cambiar el formato.

El cambio es pequeño, porque el diseño ya está hecho. Solo cambian los nombres de los campos:

Tu ErrorResponse El estándar RFC 7807 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 del estándar: se añade aparte
errores propiedad extra Tampoco lo es; la llamaremos invalidParams

Spring trae la clase ProblemDetail de serie. No hay que añadir ninguna dependencia: elimina tu ErrorResponse y sustituye el método construir por este.

import org.springframework.http.ProblemDetail;

    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 cubre se añade como propiedad extra, y
        // aparece en el JSON al mismo nivel que las demás.
        problema.setProperty("momento", LocalDateTime.now());
        if (!campos.isEmpty()) {
            problema.setProperty("invalidParams", campos);
        }
        return problema;
    }

Cambia también el tipo de retorno de los cuatro manejadores, de ResponseEntity<ErrorResponse> a ProblemDetail. Ya no hace falta envolver nada en un ResponseEntity: Spring lee el status del propio ProblemDetail y lo usa como código de la respuesta.

Repite el 404 y el 400 y compara con lo que devolvías antes:

{
  "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 cosas que no estaban:

  1. El campo type. Es una URI que identifica la clase de problema, no esta ocurrencia concreta. about:blank significa «no tengo nada más que decir que el código HTTP». Si algún día documentas tus errores de negocio, aquí va el enlace a esa documentación.
  2. La cabecera Content-Type ya no es application/json, sino application/problem+json. Compruébalo en la pestaña de cabeceras. Es lo que permite a un cliente distinguir un error estructurado de una respuesta normal sin mirar el código de estado.

Por qué hemos hecho el rodeo

Cabría haber empezado por ProblemDetail, evitando la clase propia. Esa vía habría supuesto, sin embargo, adoptar un formato sin conocer por qué define esos campos y no otros.

Haberlo diseñado tú primero es lo que hace que ahora reconozcas detail como «qué ha ocurrido» y instance como «qué se estaba pidiendo», en lugar de memorizar cinco nombres en inglés. El estándar se entiende mejor después de haber tenido el problema que resuelve.

A partir de aquí, todo el curso usa este formato: los tests de la UD7 comprobarán $.title y $.status, la seguridad de la UD9 devolverá 401 y 403 con esta forma, y el cliente Angular de la UD12 leerá detail para mostrarlo en pantalla.

Paso 11 · El error que faltaba · el conflicto

Después de crear ConflictoException, añade este método al ManejadorDeErrores ya convertido a ProblemDetail:

@ExceptionHandler(ConflictoException.class)
public ProblemDetail conflicto(ConflictoException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}

Provoca la regla de conflicto y comprueba 409. Sin este método específico, el manejador genérico de Exception devolvería 500.

Hay una familia de errores que tu API todavía no sabe expresar: cuando la petición es correcta pero choca con el estado actual de los datos.

Ejemplos: crear un proyecto con un nombre que ya existe, o borrar un proyecto que aún tiene tareas.

No es un 400, porque el cuerpo es válido. No es un 404, porque el recurso existe.

409 Conflict

La petición se entiende y es válida, pero no se puede aplicar en el estado actual del recurso.

public class ConflictoException extends RuntimeException {

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

Añade su manejador con HttpStatus.CONFLICT y úsalo, por ejemplo, para impedir dos proyectos con el mismo nombre.

Paso 12 · Unifica toda tu API

  1. Crea el paquete error con las tres clases y el manejador.
  2. Sustituye todos los ResponseEntity.notFound() por la excepción.
  3. Simplifica las firmas: los métodos que ya solo tienen un final devuelven el DTO directamente.
  4. Añade ConflictoException y una regla que la use.
  5. Quita las propiedades server.error.*.
  6. Provoca los cinco errores del principio y comprueba que los cinco tienen la misma forma.
  7. Haz la migración a ProblemDetail del apartado anterior y vuelve a provocarlos: los cinco deben seguir teniendo la misma forma, ahora con los nombres del estándar y la cabecera Content-Type: application/problem+json. Guarda las cinco peticiones en tu colección: son las que la UD7 convertirá en tests automáticos.

Paso 13 · Comprobar y registrar el resultado del proyecto

  1. Comprueba regla propia, comparación entre campos, recurso ausente y conflicto. Deben devolver estados adecuados y la misma estructura de error, con mensajes que permitan corregir la petición.
  2. Provoca una entrada mal formada y verifica que también se presenta de forma coherente. No expongas detalles internos ni la traza de Java en el contrato público.

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.