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
- Di qué se espera, no qué está mal. «Debe tener entre 3 y 120 caracteres» es accionable; «longitud inválida» no.
- Incluye los valores admitidos cuando sean pocos: «baja, media o alta» ahorra una consulta a la documentación.
- Habla del dominio, no del código. «Toda tarea debe pertenecer a un proyecto», no «proyectoId no puede ser null».
- 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
- Abre tu proyecto y ejecuta la colección de peticiones de la sesión 12 en Bruno o Postman.
- Reproduce un
400de validación (por ejemplo, enviando un cuerpo vacío{}aPOST /tareas) y un404(consultandoGET /tareas/999). - 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.
- Abre
TareaRequest.javayProyectoRequest.java. - Añade el atributo
messagea 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...
}
- Haz lo mismo en
ProyectoRequest(nombre obligatorio y entre 3 y 80 caracteres, descripción de hasta 500 caracteres si existe). - Reinicia y repite el
POST /tareascon{}. 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:
- Está duplicada en
TareaRequesty enTareaPatchRequest. - Si mañana se añade la prioridad
crítica, hay que acordarse de modificarla en varios archivos. - 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.
groupsypayload- 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:
- En
TareaRequest, donde es obligatoria, se combina con@NotNull:
@NotNull(message = "La prioridad es obligatoria")
@PrioridadValida
private String prioridad;
- 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
disableDefaultConstraintViolationyaddPropertyNode("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:
fechaFinposterior afechaInicio──> Aceptada (201 Created).fechaFinigual afechaInicio──> Aceptada (201 Created).fechaFinanterior afechaInicio──> Rechazada (400 Bad Requestcon tu mensaje).fechaFinomitida (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:
- Ya no devuelve
ResponseEntity<TareaResponse>, sinoTareaResponsedirectamente, porque por la vía normal solo tiene un final posible. - 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
RecursoNoEncontradoExceptionencaja con el primer método y también con el último (que atrapa cualquierException); gana el específico. - Por qué el último existe
- Para que ningún fallo se escape con un formato ajeno. Sin él, un
NullPointerExceptionimprevisto 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
500silencioso 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:
GET /tareas/999(debe responder404 Not Foundcon mensaje claro).POST /tareascon{}(debe responder400 Bad Requestcon los campos fallidos en castellano).POST /tareascon{,}(debe responder400 Bad Requestindicando 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:
- Elimina el archivo
ErrorResponse.java(conservaCampoError.java, que seguirá usándose para la lista de fallos de validación). - En
ManejadorDeErrores.java, sustituye el método auxiliarconstruirpor 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;
}
- Cambia el tipo de retorno de los cuatro métodos
@ExceptionHandler: deResponseEntity<ErrorResponse>aProblemDetail. Ya no hace falta envolverlos enResponseEntity: Spring lee el código HTTP del propioProblemDetail.
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:
- El campo
type: Es una URI que categoriza la clase de problema.about:blankindica que no hay documentación externa adicional más allá de la semántica del código HTTP. - La cabecera
Content-Type: Ya no esapplication/json, sinoapplication/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:
- Paquete
errorordenado: contieneRecursoNoEncontradoException,ConflictoException, el record auxiliarCampoErrory la claseManejadorDeErrores. - Controladores limpios: sustituye cualquier
ResponseEntity.notFound().build()residual por el lanzamiento deRecursoNoEncontradoException. - 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. - Retirada del andamio provisional: elimina las dos propiedades
server.error.*deapplication.properties. Tu manejador global se encarga ahora del 100% de los errores con total seguridad. - Colección actualizada: guarda en Bruno o Postman peticiones para verificar:
404(recurso ausente),400(validación de campos),400(JSON roto) y409(conflicto de negocio).
Paso 12 · Comprobar y registrar el resultado del proyecto
- 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. - Verifica que los mensajes de validación son legibles y permiten corregir la petición sin mirar el código fuente.
- 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
- Coge la regla «una tarea solo puede pertenecer a un proyecto que exista» e intenta implementarla como anotación propia. Llega hasta donde puedas.
- Vas a chocar con un muro concreto. Descríbelo: ¿qué necesita el validador que no tiene?
- 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.
- Propón dónde debería vivir la comprobación y qué código de estado debería producir. Argumenta entre
400y404.
Este reto no tiene una solución cerrada y es el que más se parece a una discusión de equipo real.
@PrioridadValida implementada y en uso, más una segunda anotación de tu dominio con su prueba.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
- Añade a la colección una comprobación de formato para cada error: que existan las claves
estado,mensajeyruta. - 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");
}
- Llámalo y comprueba dos cosas: que el cliente recibe
500con tu formato, y que ese texto no aparece por ninguna parte de la respuesta. - Comprueba que sí aparece en la consola del servidor.
- 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.
404 pasando por la excepción propia.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.