Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado cerrar el primer proyecto. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Un JSON puede tener sintaxis correcta y aun así contener datos que tu aplicación no acepta. Bean Validation permite declarar restricciones, como un texto obligatorio, sobre los DTO. @Valid pide a Spring que compruebe esas restricciones al recibir la entrada.
El formulario del cliente no es una defensa
Un cliente bien hecho comprueba los datos antes de enviarlos: marca en rojo el campo vacío y no deja pulsar el botón. Es buena práctica y mejora la experiencia.
Y no protege absolutamente de nada, porque esa comprobación vive en la máquina de quien la quiera saltar. Con lo que ya sabes hacer desde la UD1, cualquiera puede abrir Postman y enviar el cuerpo que le dé la gana directamente a tu servidor, sin pasar por ninguna interfaz.
- Desde el formulario de la aplicación, con sus comprobaciones
- Desde Postman, sin ninguna
- Desde un script de otra persona, sin ninguna
- Desde alguien que quiere ver qué pasa si envía basura
La regla que no se negocia
La validación del cliente es comodidad. La validación del servidor es la única que existe.
Que estén las dos no es duplicar trabajo: una evita un viaje innecesario y mejora la experiencia; la otra protege los datos. Si solo puedes tener una, es la del servidor.
Valida antes de ejecutar, como todo lo demás
Es el mismo mecanismo que ya has visto dos veces: en la UD1 con un @RequestParam que faltaba, y en la UD2 con un cuerpo que no se podía leer.
- Se busca el método que atiende la ruta
- Jackson convierte el cuerpo en el DTO de entrada
- Se comprueban las restricciones del DTO
- Si alguna falla,
400y aquí se acaba - Si todas pasan, se ejecuta tu método
La consecuencia práctica es la buena noticia del día: dentro de tu método ya no hace falta comprobar nada de esto. Si el código se está ejecutando, el título no está vacío y la prioridad es una de las tres. No escribas if (titulo == null) en el controlador: eso ya está resuelto antes, y en un solo sitio.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre
pom.xml, el DTO de entrada y los métodos del controlador que lo reciben. Repite una creación válida de la sesión 11. - Anota las restricciones de cada campo: obligatorio, longitud, rango o formato. Distingue esas condiciones de reglas que necesitan consultar otros registros.
- Prepara un JSON correcto y copias que incumplan una sola restricción cada vez. Así podrás atribuir cada rechazo a una causa concreta.
Paso 2 · Instala la dependencia, porque no viene puesta
Esto sorprende a mucha gente: la validación no está incluida en spring-boot-starter-web. Hay que pedirla.
En el pom.xml, dentro de <dependencies>:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Sin versión: la pone el padre, como aprendiste en la UD1. Recarga las dependencias de Maven en tu IDE antes de seguir.
Si las anotaciones no hacen nada, es esto
El síntoma es exacto y desconcertante: el proyecto compila, las anotaciones no dan error, la aplicación arranca y la validación se ignora por completo. Un cuerpo vacío se sigue aceptando.
Cuando eso pase, lo primero que se mira es si la dependencia está en el pom.xml y si Maven la ha descargado.
Paso 3 · Las reglas van en el DTO de entrada
Con Maven sincronizado, abre el DTO de entrada existente y añade las anotaciones e imports del bloque a sus campos. No sustituyas toda la clase por una versión sin constructor o métodos de acceso. @NotNull rechaza la ausencia, mientras que @Positive comprueba el valor cuando existe; juntas expresan que proyectoId es obligatorio y positivo. Guarda antes de cambiar el controlador.
En el modelo · no
El modelo lo construye también tu propio código, con datos que ya son válidos. Validar ahí mezcla las reglas del contrato exterior con las de tu dominio.
En el DTO de entrada · sí
Es exactamente la frontera: lo que llega de fuera y no es de fiar. Las reglas quedan junto a la clase que declara qué acepta la API.
package com.ejemplo.gestor.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public class TareaRequest {
@NotBlank
@Size(min = 3, max = 120)
private String titulo;
@NotNull
@Pattern(regexp = "baja|media|alta")
private String prioridad;
@NotNull
@Positive
private Integer proyectoId;
// constructor vacío, getters y setters
}
Fíjate en el paquete: jakarta.validation, no javax. Si el autocompletado te ofrece javax, es de una versión anterior y no funcionará con Spring Boot 3.
Las restricciones que cubren casi todo
| Anotación | Comprueba |
|---|---|
@NotNull |
Que el campo llegue, aunque venga vacío |
@NotBlank |
Que llegue y no sea vacío ni solo espacios. Solo para texto |
@NotEmpty |
Que llegue y no esté vacío. Para texto y colecciones |
@Size(min, max) |
Longitud de un texto o tamaño de una colección |
@Min / @Max |
Valor mínimo y máximo de un número |
@Positive |
Que un número sea mayor que cero |
@Email |
Que el texto tenga forma de correo |
@Pattern(regexp) |
Que el texto encaje con una expresión regular |
@Past / @Future |
Que una fecha sea anterior o posterior a hoy |
Las tres que se confunden siempre
| Valor recibido | @NotNull |
@NotEmpty |
@NotBlank |
|---|---|---|---|
Campo ausente, o null |
Falla | Falla | Falla |
"" |
Pasa | Falla | Falla |
" " |
Pasa | Pasa | Falla |
"hola" |
Pasa | Pasa | Pasa |
Para un texto obligatorio quieres casi siempre @NotBlank: es la única que impide que alguien registre una tarea titulada con tres espacios.
Otra razón para los tipos envoltorio
Poner @NotNull sobre un int no sirve de nada: un primitivo nunca es nulo, así que un campo ausente llega como 0 y la validación pasa.
Ya usabas Integer y Boolean en los DTO por lo del PATCH. Esta es la segunda razón, y es igual de importante.
Paso 4 · Activarla · @Valid
En el POST y PUT de TareaController, añade el import jakarta.validation.Valid y coloca @Valid delante del argumento @RequestBody. Conserva el cuerpo del método que ya asigna id, convierte y guarda; los puntos suspensivos de un esquema no son código Java. Reinicia y envía una entrada inválida. Después consulta el listado para verificar que el rechazo ocurrió antes del guardado.
@PostMapping
public ResponseEntity<TareaResponse> crear(@Valid @RequestBody TareaRequest peticion) {
// Conserva aquí el cuerpo de tu método actual; solo añadimos @Valid.
}
Una palabra. jakarta.validation.Valid.
POST /tareas
{ "titulo": "", "prioridad": "urgentísima" }
{
"timestamp": "2026-09-02T10:14:22.831+00:00",
"status": 400,
"error": "Bad Request",
"path": "/tareas"
}
400, y la tarea no se ha creado. Mira además la consola: ahí sí está el detalle completo, con el campo y la restricción que ha fallado.
Paso 5 · El problema que queda para la sesión 13
Vuelve a mirar ese 400. Ponte en el lugar de quien consume tu API:
{ "timestamp": "...", "status": 400, "error": "Bad Request", "path": "/tareas" }
¿Qué campo estaba mal? ¿El título? ¿La prioridad? ¿Los dos? ¿Qué valores acepta prioridad?
No hay forma de saberlo. La información existe —está entera en tu consola— y no se la estás dando a quien la necesita. Un cliente que recibe esto solo puede probar a ciegas.
Eso es la sesión 13. Hoy la validación ya protege tus datos; el mensaje todavía no ayuda a nadie.
Validar parámetros que no van en el cuerpo
Las restricciones también valen sobre un @RequestParam o un @PathVariable, pero hace falta un paso más: anotar la clase del controlador con @Validated, de org.springframework.validation.annotation.
Sin esa anotación de clase, un @Positive sobre un parámetro se ignora sin avisar. Es otro caso de «no da error y no hace nada», así que conviene reconocerlo.
Paso 6 · La tabla de rechazos
Con @Valid puesto, envía estos cuerpos a POST /tareas y predice antes el código y el motivo:
| # | Cuerpo | Predice |
|---|---|---|
| 1 | {"titulo":"Revisar el login","prioridad":"alta","proyectoId":7} |
|
| 2 | {} |
|
| 3 | {"titulo":"","prioridad":"alta","proyectoId":7} |
|
| 4 | {"titulo":" ","prioridad":"alta","proyectoId":7} |
|
| 5 | {"titulo":"ab","prioridad":"alta","proyectoId":7} |
|
| 6 | {"titulo":"Revisar","prioridad":"URGENTE","proyectoId":7} |
|
| 7 | {"titulo":"Revisar","prioridad":"alta","proyectoId":-3} |
|
| 8 | {"titulo":"Revisar","prioridad":"alta"} |
|
| 9 | {"titulo":"Revisar","prioridad":"alta","proyectoId":"siete"} |
Las dos interesantes son la 4 y la 9:
- La 4 solo la caza
@NotBlank. Con@NotEmptyhabrías creado una tarea titulada con tres espacios. - La 9 devuelve
400igual que las demás, pero por otro motivo completamente distinto: falla antes, al convertir el JSON, porque"siete"no es un número. Ni siquiera llega a validarse. Compruébalo en la consola: la excepción no es la misma.
Paso 7 · Las reglas de proyectos
- Añade restricciones a
ProyectoRequest: el nombre obligatorio y entre 3 y 80 caracteres, la descripción opcional pero como máximo 500. - Pon
@Validen elPOSTy en elPUT. - Decide qué hacer con
ProyectoPatchRequesty justifícalo: si un campo es opcional en unPATCH, ¿puede llevar@NotBlank? ¿Qué pasaría si lo lleva? - Añade a la colección tres peticiones de rechazo con su comprobación de
400.
La pregunta 3 es la que se piensa. Un PATCH recibe campos ausentes por definición, así que @NotNull ahí sería un error. Pero @Size sí tiene sentido: si llega, que tenga la longitud correcta.
Paso 8 · Comprobar y registrar el resultado del proyecto
- Ejecuta la tabla de rechazos y verifica que cada entrada inválida produce 400 sin crear ni modificar el registro.
- Repite una entrada válida después de los rechazos. Debe seguir funcionando; comprueba que la validación se activa en todos los métodos que reciben el DTO.
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 · Escribe el contrato de validación
Antes de tocar más código, escribe la tabla de reglas de todos los recursos de tu API. Este es el formato:
| Recurso | Campo | Obligatorio en crear | Reglas | ¿Modificable? |
|---|
Después responde:
- Hay reglas que no se pueden expresar con las anotaciones de hoy. Por ejemplo: «el
proyectoIddebe corresponder a un proyecto que exista», o «la fecha de fin no puede ser anterior a la de inicio». Localiza al menos dos en tu API y explica por qué se les resisten. - Para cada una de esas dos, ¿dónde tendría que comprobarse entonces? Piensa en quién sabe la respuesta: ¿la clase del DTO, o algo que tenga acceso a la lista de proyectos?
- Tu API acepta hoy una tarea de un proyecto inexistente. ¿Es eso un
400o un404? Argumenta las dos posturas y quédate con una.
La pregunta 1 abre el trabajo siguiente, y la 2 apunta a la UD4. La 3 no tiene respuesta única y se defiende.
TareaRequest validado y la tabla de nueve rechazos comprobada.PATCH justificada y tres rechazos en la colección.Ver respuestas
1 · Porque se ejecuta en la máquina de quien la quiera saltar. Cualquiera puede enviar el cuerpo directamente al servidor sin pasar por la interfaz.
2 · Que spring-boot-starter-validation esté en el pom.xml y descargado. Sin esa dependencia todo compila y las anotaciones se ignoran en silencio. Después, que el parámetro lleve @Valid.
3 · Un texto de solo espacios, como " ": no está vacío, pero no contiene nada útil.
4 · Porque un primitivo no puede ser nulo: un campo ausente llega como 0 y la comprobación pasa. Hay que declararlo como Integer.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
Las entradas inválidas no crean ni modifican recursos y el cliente recibe una respuesta que puede interpretar.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.