Al cerrar la lección anterior quedó una grieta abierta: la API de CicloUrbana acepta cualquier cosa. Una estación con capacidad -5, un nombre vacío, una latitud de 200 grados o una matrícula que no se parece a RB-0142 entran sin resistencia y se guardan tan tranquilas. Las pocas comprobaciones que hemos escrito viven dispersas por los servicios, mezcladas con las reglas de negocio, y ninguna produce un mensaje útil para el cliente. En esta lección cerramos esa grieta con Jakarta Bean Validation: un mecanismo declarativo que convierte las restricciones en anotaciones sobre los propios datos, las aplica automáticamente en el borde de la aplicación y permite crear restricciones propias del dominio de Ribalta. Al terminar, ninguna petición mal formada llegará viva a la capa de servicio.
Contenido
- Por qué validar en el borde y qué capas de validación existen
- La dependencia y el catálogo de restricciones
@Validsobre@RequestBody@Validatedpara@PathVariabley@RequestParam- Objetos anidados y colecciones
- Grupos de validación: alta frente a modificación
- Mensajes personalizados e internacionalización
- Una restricción propia:
@MatriculaBicicleta - Una restricción de clase:
@CoordenadasValidas - Validación programática con
Validator - Error de validación (400) frente a regla de negocio (409/422)
- Errores Comunes y Consejos
- Ejercicios
- Por qué validar en el borde y qué capas de validación existen
El borde es el punto por el que los datos externos entran en la aplicación: en nuestro caso, el controlador. Validar ahí falla pronto y barato —un POST con capacidad negativa se rechaza antes de tocar el servicio, el repositorio o la base de datos—, produce mensajes útiles —"la capacidad debe ser mayor que cero" en vez de un error de restricción de la base de datos— y simplifica el código de dentro: si EstacionService puede asumir que la capacidad es positiva y el nombre no está vacío, desaparece la mitad de sus if.
Ahora bien, hay varias clases de validación y confundirlas produce diseños malos:
| Capa | Qué comprueba | Ejemplo en CicloUrbana | Dónde vive | Código HTTP |
|---|---|---|---|---|
| Formato | ¿El JSON es sintácticamente válido? | {"capacidad": sin cerrar |
Jackson | 400 |
| Tipo | ¿El valor encaja en el tipo Java? | "capacidad": "veinte" |
Jackson / ConversionService |
400 |
| Sintaxis | ¿El valor cumple el formato esperado? | capacidad > 0, matrícula RB-0000 |
Bean Validation, en el DTO | 400 |
| Consistencia | ¿Los campos son coherentes entre sí? | Latitud y longitud dentro de Ribalta | Bean Validation de clase | 400 |
| Negocio | ¿La operación es legítima dado el estado del sistema? | La estación de destino está llena | Servicio | 409 / 422 |
La frontera entre "sintaxis" y "negocio" es la que más cuesta. La regla práctica: si para decidir necesitas consultar el estado del sistema, es negocio; si te basta con mirar el dato, es sintaxis. "La capacidad debe ser positiva" se decide mirando el número: sintaxis. "No puede haber dos estaciones con el mismo nombre" exige consultar el repositorio: negocio.
graph LR
A["Petición HTTP"] --> B["Jackson<br/>formato y tipos"]
B --> C["Bean Validation<br/>@Valid en el controlador"]
C --> D["EstacionService<br/>reglas de negocio"]
D --> E["Repositorio"]
B -.400.-> X["Respuesta de error"]
C -.400.-> X
D -."409 / 422".-> X
- La dependencia y el catálogo de restricciones
Bean Validation no viene con spring-boot-starter-web: hay que añadirla explícitamente.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Este starter arrastra Hibernate Validator, la implementación de referencia de Jakarta Bean Validation 3.0. Al arrancar, ValidationAutoConfiguration registra un bean LocalValidatorFactoryBean —lo verías en el informe --debug de 02-06— y a partir de ahí @Valid funciona en los controladores. Un detalle importante: las anotaciones están en jakarta.validation.constraints, no en javax.validation; Spring Boot 3 migró al espacio de nombres de Jakarta EE y buena parte de los ejemplos que hay por internet siguen usando javax, que no es compatible.
El catálogo de restricciones estándar:
| Anotación | Qué exige | Tipos aplicables | Uso en CicloUrbana |
|---|---|---|---|
@NotNull |
No nulo (el vacío "" pasa) |
Cualquiera | estacionDestinoId al finalizar |
@NotEmpty |
No nulo y con tamaño > 0 | String, colecciones |
Lista de bicicletas de un lote |
@NotBlank |
No nulo y con algún carácter no blanco | Solo String |
nombre de estación |
@Size(min, max) |
Tamaño dentro del rango | String, colecciones |
nombre entre 3 y 80 |
@Min / @Max |
Entero dentro del rango | Enteros | nivelBateria entre 0 y 100 |
@Positive / @PositiveOrZero |
Mayor que cero / no negativo | Numéricos | capacidad |
@DecimalMin / @DecimalMax |
Rango con decimales | BigDecimal, double |
latitud, longitud |
@Digits(integer, fraction) |
Número de dígitos | Numéricos | importe: 6 y 2 |
@Email |
Formato de correo | String |
Correo del usuario |
@Pattern(regexp) |
Coincide con la expresión regular | String |
matricula (RB-\d{4}) |
@Past / @PastOrPresent |
Fecha en el pasado | java.time |
fechaNacimiento |
@Future / @FutureOrPresent |
Fecha en el futuro | java.time |
fechaFinPromocion |
@AssertTrue / @AssertFalse |
Booleano con valor concreto | boolean |
aceptaCondiciones |
Las tres primeras se confunden constantemente. Con el valor " " (tres espacios): @NotNull pasa, @NotEmpty pasa (tiene longitud 3) y @NotBlank falla; para un nombre casi siempre quieres @NotBlank. Y un apunte que ahorra sorpresas: todas las restricciones excepto @NotNull consideran válido el valor null, así que @Size(min = 3) sobre un campo nulo pasa sin protestar. Si el campo es obligatorio hay que combinarlas: @NotBlank @Size(max = 80).
@Valid sobre @RequestBody
@Valid sobre @RequestBodyAnotamos el DTO de creación de la lección anterior, renombrado ya a CrearEstacionRequest, que es el nombre definitivo que fijará 03-05:
package com.ciclourbana.estaciones;
import jakarta.validation.constraints.*;
public record CrearEstacionRequest(
@NotBlank(message = "El nombre de la estación es obligatorio")
@Size(min = 3, max = 80, message = "El nombre debe tener entre {min} y {max} caracteres")
String nombre,
@NotBlank @Size(max = 120)
String direccion,
@Positive(message = "La capacidad debe ser mayor que cero")
@Max(value = 60, message = "Ninguna estación de Ribalta supera los {value} anclajes")
int capacidad,
@DecimalMin("-90.0") @DecimalMax("90.0") double latitud,
@DecimalMin("-180.0") @DecimalMax("180.0") double longitud
) {}Los marcadores {min}, {max} y {value} se sustituyen por los valores de la propia anotación, así que el mensaje no se desincroniza si mañana el límite pasa de 60 a 80 anclajes. En el controlador basta con añadir @Valid:
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacion> crear(@Valid @RequestBody CrearEstacionRequest peticion) {
Estacion creada = estacionService.crear(peticion);
URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(creada.id()).toUri();
return ResponseEntity.created(ubicacion).body(creada);
}Si la validación falla, Spring lanza MethodArgumentNotValidException y el método del controlador no llega a ejecutarse. Con un POST de {"nombre":"","capacidad":-5,...}, la respuesta por defecto de Spring Boot 3 es:
{ "type": "about:blank", "title": "Bad Request", "status": 400,
"detail": "Invalid request content.", "instance": "/api/v1/estaciones" }Funciona —es un 400— pero es inservible para el cliente: no dice qué campos fallaron ni por qué. Los detalles están dentro de la excepción, y convertirlos en una respuesta con la lista de campos erróneos es el trabajo de 03-06: aquí generamos correctamente el error, allí lo presentaremos bien.
@Validated para @PathVariable y @RequestParam
@Validated para @PathVariable y @RequestParam@Valid solo funciona sobre objetos. Para validar parámetros sueltos —el id de la ruta, el tamanio de la paginación— hace falta @Validated a nivel de clase, que activa un proxy AOP que intercepta las llamadas al método.
@RestController
@RequestMapping(path = "/api/v1/estaciones", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated // <-- imprescindible: valida los parámetros
public class EstacionController {
@GetMapping
public List<Estacion> listar(
@RequestParam(required = false) @Size(max = 80) String nombre,
@RequestParam(required = false) @Positive Integer capacidadMinima,
@RequestParam(defaultValue = "0") @Min(0) int pagina,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int tamanio) {
// Adiós al recorte manual de la lección 03-02: ahora es declarativo
return estacionService.buscar(nombre, capacidadMinima, pagina, tamanio);
}
@GetMapping("/{id:\\d+}")
public ResponseEntity<Estacion> obtenerPorId(@PathVariable("id") @Positive Long id) { ... }
}Compara este listar con el de 03-02, donde había tres líneas de Math.min y Math.max para evitar que ?tamanio=1000000 tumbara el servicio. Ahora la restricción está junto al parámetro, se documenta sola en OpenAPI (03-07) y no se puede olvidar. Dos diferencias importantes frente a @Valid:
| Aspecto | @Valid en @RequestBody |
@Validated en la clase |
|---|---|---|
| Qué valida | Los campos del objeto | Los parámetros del método |
| Excepción | MethodArgumentNotValidException |
ConstraintViolationException |
| Estado por defecto | 400 Bad Request |
500 Internal Server Error |
| Mecanismo | Resolutor de argumentos | Proxy AOP (MethodValidationPostProcessor) |
El 500 de la tercera fila es una trampa clásica: ConstraintViolationException no está mapeada a ningún código HTTP, así que Spring la trata como un error inesperado. Es incorrecto —la culpa es del cliente, que envió ?tamanio=5000— y lo arreglaremos en el manejador global de 03-06. Recuerda la advertencia de 03-01: un 5xx por culpa del cliente contamina las alertas de producción.
- Objetos anidados y colecciones
Bean Validation no desciende automáticamente a los objetos anidados: hay que pedirlo con @Valid sobre el campo.
public record UbicacionRequest(
@DecimalMin("-90.0") @DecimalMax("90.0") double latitud,
@DecimalMin("-180.0") @DecimalMax("180.0") double longitud) {}
public record CrearEstacionRequest(
@NotBlank @Size(min = 3, max = 80) String nombre,
@NotBlank String direccion,
@Positive @Max(60) int capacidad,
@NotNull @Valid // <-- sin @Valid, la ubicación NO se valida
UbicacionRequest ubicacion
) {}Sin ese @Valid, una petición con {"ubicacion": {"latitud": 500}} pasaría la validación sin protestar. Es uno de los fallos silenciosos más frecuentes: la validación "funciona" y sin embargo deja pasar datos imposibles.
Para las colecciones hay dos niveles, y conviene distinguirlos:
public record LoteBicicletasRequest(
@NotEmpty(message = "El lote debe contener al menos una bicicleta")
@Size(max = 50, message = "No se pueden dar de alta más de {max} bicicletas de una vez")
List<@Valid @NotNull CrearBicicletaRequest> bicicletas, // valida CADA elemento
@NotNull @PastOrPresent LocalDate fechaRecepcion
) {}@NotEmpty y @Size se aplican a la lista —cuántos elementos tiene—, mientras que @Valid y @NotNull dentro de los corchetes angulares son restricciones sobre el tipo contenido (Bean Validation 2.0) y se aplican a cada elemento. También funciona sobre Optional<@NotBlank String> y sobre las claves y valores de un Map<@NotBlank String, @Positive Integer>.
Cuando el cuerpo entero es una lista, @Valid sobre el @RequestBody no basta: hay que envolverla en un objeto como el de arriba, o anotar el controlador con @Validated y escribir @RequestBody List<@Valid CrearBicicletaRequest> lote. La primera opción es preferible: permite añadir metadatos al lote sin romper el contrato.
- Grupos de validación: alta frente a modificación
Un mismo DTO puede necesitar reglas distintas según la operación: al crear una estación el nombre es obligatorio, mientras que al modificarla parcialmente puede no venir, aunque si viene debe cumplir el tamaño. Los grupos resuelven esto y son simplemente interfaces marcadoras:
package com.ciclourbana.comun;
/** Marcadores para los grupos de validación. No tienen métodos. */
public interface GruposValidacion {
interface AlCrear {}
interface AlActualizar {}
}public record EstacionRequest(
// Obligatorio solo al crear; al actualizar puede omitirse
@NotBlank(groups = AlCrear.class, message = "El nombre es obligatorio al dar de alta")
@Size(min = 3, max = 80) // sin grupo: pertenece al grupo Default
String nombre,
@NotBlank(groups = AlCrear.class) @Size(max = 120) String direccion,
@NotNull(groups = AlCrear.class) @Positive @Max(60) Integer capacidad,
// El id solo puede venir al actualizar, y debe coincidir con la ruta
@Null(groups = AlCrear.class, message = "No se puede fijar el id al crear")
@NotNull(groups = AlActualizar.class)
Long id
) {}Para activar un grupo hay que usar @Validated(Grupo.class), no @Valid, que no admite grupos: crear(@Validated(AlCrear.class) @RequestBody EstacionRequest peticion) y reemplazar(..., @Validated(AlActualizar.class) @RequestBody EstacionRequest peticion).
La regla del grupo Default que casi nadie recuerda: una restricción sin groups pertenece implícitamente al grupo Default, y @Validated(AlCrear.class) NO incluye Default. En el ejemplo anterior, @Size(min = 3, max = 80) no se evaluaría al crear. Se arregla declarando los dos grupos en cada anotación —@Size(..., groups = {AlCrear.class, AlActualizar.class})— o, mejor, haciendo que el grupo herede: public interface AlCrear extends jakarta.validation.groups.Default {}. Con lo segundo, @Validated(AlCrear.class) evalúa las restricciones de AlCrear y las que no tienen grupo, que es lo que casi siempre se quiere; es lo que adopta CicloUrbana.
Una advertencia de diseño: los grupos son potentes pero se vuelven ilegibles rápido. Con más de dos o tres, suele ser mejor tener DTOs separados —CrearEstacionRequest y ActualizarEstacionRequest—, cada uno con sus reglas. Es la solución que adoptará el proyecto en 03-05.
- Mensajes personalizados e internacionalización
Los mensajes por defecto de Hibernate Validator vienen en inglés y son genéricos ("must not be blank"). Fijarlos en la anotación, como hemos hecho, los deja escritos en el código y en un solo idioma. La solución completa es externalizarlos en un fichero por idioma en src/main/resources:
# messages.properties (idioma por defecto: español)
estacion.nombre.obligatorio=El nombre de la estación es obligatorio
estacion.nombre.tamanio=El nombre debe tener entre {min} y {max} caracteres
estacion.capacidad.positiva=La capacidad debe ser mayor que cero
bicicleta.matricula.formato=La matrícula debe seguir el formato RB-0000
bicicleta.bateria.rango=El nivel de batería debe estar entre {min} y {max}
# messages_en.properties
estacion.nombre.obligatorio=The station name is required
estacion.nombre.tamanio=The name must be between {min} and {max} characters
estacion.capacidad.positiva=Capacity must be greater than zero
bicicleta.matricula.formato=The plate must follow the RB-0000 format
bicicleta.bateria.rango=Battery level must be between {min} and {max}En las anotaciones se referencia la clave entre llaves: @NotBlank(message = "{estacion.nombre.obligatorio}"). Y hay que conectar el validador con el MessageSource de Spring, porque por defecto Hibernate Validator busca sus mensajes en ValidationMessages.properties y no en el messages.properties de Spring:
package com.ciclourbana.comun;
@Configuration
public class ConfiguracionValidacion {
/** Resuelve las {claves} contra el MessageSource de Spring, con lo que
* heredan el idioma negociado por Accept-Language. */
@Bean
LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
factory.setValidationMessageSource(messageSource);
return factory;
}
}Con la configuración del MessageSource en YAML:
spring:
messages:
basename: messages
encoding: UTF-8
fallback-to-system-locale: false # si falta el idioma, usa messages.propertiesEl idioma se selecciona a partir de Accept-Language. Para que Spring MVC la respete conviene declarar el resolutor explícitamente:
@Bean
LocaleResolver localeResolver() {
var resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.forLanguageTag("es"));
resolver.setSupportedLocales(List.of(Locale.forLanguageTag("es"),
Locale.forLanguageTag("ca"), Locale.forLanguageTag("en")));
return resolver;
}Ahora un cliente que envíe Accept-Language: en recibe "The station name is required" y "Capacity must be greater than zero" sin que el código cambie una línea.
- Una restricción propia:
@MatriculaBicicleta
@MatriculaBicicletaLas matrículas de la red de Ribalta tienen el formato RB-0142: dos letras fijas, un guion y cuatro dígitos. Podríamos usar @Pattern(regexp = "RB-\\d{4}") en cada sitio, pero copiar la misma expresión regular en cinco DTOs es una invitación a que un día no coincidan. Una restricción propia le pone nombre a la regla y la centraliza.
Una restricción se compone siempre de dos piezas: la anotación y el validador.
package com.ciclourbana.comun.validacion;
@Documented
@Constraint(validatedBy = MatriculaBicicletaValidator.class) // <-- el validador
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface MatriculaBicicleta {
// Los tres atributos siguientes son OBLIGATORIOS en toda restricción
String message() default "{bicicleta.matricula.formato}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** Si es true, se acepta el valor null (por defecto, como el resto). */
boolean permiteNulo() default true;
}Los tres atributos message, groups y payload no son opcionales: si falta alguno, Hibernate Validator falla al arrancar con un error poco explicativo. ElementType.RECORD_COMPONENT es necesario para que la anotación se pueda colocar sobre un componente de record.
El validador:
package com.ciclourbana.comun.validacion;
public class MatriculaBicicletaValidator
implements ConstraintValidator<MatriculaBicicleta, String> {
// La expresión se compila UNA vez: el validador es un singleton reutilizado
private static final Pattern PATRON = Pattern.compile("^RB-\\d{4}$");
private boolean permiteNulo;
@Override
public void initialize(MatriculaBicicleta anotacion) {
this.permiteNulo = anotacion.permiteNulo();
}
@Override
public boolean isValid(String matricula, ConstraintValidatorContext contexto) {
if (matricula == null) {
return permiteNulo; // por convención, null suele delegarse a @NotNull
}
return PATRON.matcher(matricula).matches();
}
}Y su uso, tan limpio como cualquier restricción estándar:
public record CrearBicicletaRequest(
@NotBlank @MatriculaBicicleta String matricula, // toda la regla, una palabra
@Min(value = 0, message = "{bicicleta.bateria.rango}")
@Max(value = 100, message = "{bicicleta.bateria.rango}")
int nivelBateria,
@NotNull @Positive Long estacionId
) {}Un detalle que conviene entender: el validador es un bean con ciclo de vida gestionado, así que puede inyectar dependencias por constructor. Eso permite validadores que consultan un repositorio... pero cuidado, eso ya sería una regla de negocio, y el apartado 11 explica por qué normalmente no debe hacerse.
- Una restricción de clase:
@CoordenadasValidas
@CoordenadasValidasAlgunas reglas no se pueden expresar sobre un campo aislado porque relacionan varios. En CicloUrbana, las coordenadas deben caer dentro del término municipal de Ribalta, y eso requiere mirar latitud y longitud a la vez. La solución es una restricción a nivel de clase.
package com.ciclourbana.comun.validacion;
@Documented
@Constraint(validatedBy = CoordenadasValidasValidator.class)
@Target(ElementType.TYPE) // <-- sobre el TIPO, no sobre el campo
@Retention(RetentionPolicy.RUNTIME)
public @interface CoordenadasValidas {
String message() default "{estacion.coordenadas.fuera-de-ribalta}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class CoordenadasValidasValidator
implements ConstraintValidator<CoordenadasValidas, CrearEstacionRequest> {
// Rectángulo que envuelve el término municipal de Ribalta
private static final double LAT_MIN = 41.30, LAT_MAX = 41.48;
private static final double LON_MIN = 2.05, LON_MAX = 2.28;
@Override
public boolean isValid(CrearEstacionRequest p, ConstraintValidatorContext contexto) {
if (p == null) {
return true;
}
boolean dentro = p.latitud() >= LAT_MIN && p.latitud() <= LAT_MAX
&& p.longitud() >= LON_MIN && p.longitud() <= LON_MAX;
if (!dentro) {
// Sin esto, el error se asocia al objeto entero y el cliente no
// sabe qué campo mirar. Con esto, se asocia a "latitud".
contexto.disableDefaultConstraintViolation();
contexto.buildConstraintViolationWithTemplate(
"{estacion.coordenadas.fuera-de-ribalta}")
.addPropertyNode("latitud").addConstraintViolation();
}
return dentro;
}
}Y se anota el record completo con @CoordenadasValidas, además de las restricciones de campo que ya tenía. El bloque de buildConstraintViolationWithTemplate es lo que diferencia una restricción de clase usable de una molesta: sin él, el error de validación no tiene campo asociado y el formulario del panel de Ribalta no puede resaltar nada. Con él, el cliente recibe el error apuntando a latitud.
Nótese también que si latitud es 500 fallarán tanto @DecimalMax("90.0") como @CoordenadasValidas, y el cliente recibirá dos violaciones: Bean Validation evalúa todas las restricciones y devuelve el conjunto completo, lo cual es deseable porque un formulario debe mostrar todos sus errores de una vez.
- Validación programática con
Validator
ValidatorA veces hace falta validar fuera del borde HTTP: al procesar un fichero CSV de alta masiva de bicicletas, al consumir un mensaje de una cola (módulo 7) o al validar un objeto construido dentro del propio servicio. Para eso se inyecta el Validator de Jakarta.
package com.ciclourbana.bicicletas;
@Service
public class ImportadorBicicletas {
private final Validator validator; // jakarta.validation.Validator
private final BicicletaService bicicletaService;
// ... constructor con inyección de ambos
/**
* Importa un lote validando fila a fila. A diferencia del borde HTTP,
* aquí NO queremos abortar todo el lote por una fila mala: se registra
* el fallo, se descarta esa fila y se continúa.
*/
public ResultadoImportacion importar(List<CrearBicicletaRequest> filas) {
List<Bicicleta> importadas = new ArrayList<>();
Map<Integer, List<String>> errores = new LinkedHashMap<>();
for (int i = 0; i < filas.size(); i++) {
var violaciones = validator.validate(filas.get(i));
if (violaciones.isEmpty()) {
importadas.add(bicicletaService.crear(filas.get(i)));
} else {
List<String> mensajes = violaciones.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted().toList();
errores.put(i + 1, mensajes); // fila 1 = primera del fichero
log.warn("Fila {} descartada: {}", i + 1, mensajes);
}
}
return new ResultadoImportacion(importadas.size(), errores);
}
}validator.validate(objeto) devuelve un Set<ConstraintViolation<T>> vacío si todo está bien. Cada violación expone getPropertyPath() (el campo), getMessage() (ya resuelto e internacionalizado) y getInvalidValue() (el valor rechazado).
| Enfoque | Cuándo usarlo | Ante el error |
|---|---|---|
Declarativo (@Valid) |
Entrada HTTP, el 95% de los casos | Aborta la petición con una excepción |
Programático (Validator) |
Lotes, mensajería, validación condicional | Tú decides: descartar, acumular, avisar |
La regla: declarativo por defecto, programático cuando necesites controlar qué pasa después del fallo. Rechazar 4.000 filas correctas porque la 137 tiene la matrícula mal sería absurdo.
Sobre getInvalidValue(), una advertencia de seguridad que se retoma en 03-06: nunca lo incluyas en la respuesta sin pensarlo. Si el campo que falló fuera una contraseña o un número de tarjeta, estarías devolviendo el dato sensible en el cuerpo del error y, casi seguro, escribiéndolo en el log.
- Error de validación (400) frente a regla de negocio (409/422)
Esta es la decisión de diseño que más discusiones genera. La política de CicloUrbana, con ejemplos:
| Situación | Se detecta | Código | Por qué |
|---|---|---|---|
| JSON mal formado | Jackson | 400 |
La petición no es interpretable |
capacidad no es un número |
Jackson | 400 |
Tipo incorrecto |
capacidad es -5 |
Bean Validation | 400 |
El dato es inválido en sí mismo |
matricula no cumple RB-0000 |
Bean Validation | 400 |
Formato incorrecto |
| Ya existe una estación con ese nombre | Servicio | 409 |
Conflicto con el estado actual |
| La estación de destino está llena | Servicio | 409 |
Conflicto con el estado actual |
| La bicicleta está en mantenimiento | Servicio | 422 |
Sintácticamente correcto, no procesable |
| Batería por debajo del umbral | Servicio | 422 |
Depende de la configuración y del estado |
El criterio operativo: si el cliente puede corregir la petición mirando solo lo que envió, es un 400; si necesita saber algo del estado del servidor, no lo es. capacidad: -5 se corrige solo; que la Plaza Mayor esté llena no se puede adivinar desde el cliente. Entre 409 y 422 la frontera es más difusa, y la convención del proyecto es 409 Conflict cuando el conflicto es con otro recurso existente (nombre duplicado, estación llena) y 422 Unprocessable Entity cuando la petición choca con una regla de negocio o con el estado de un recurso (bicicleta en mantenimiento, alquiler ya finalizado). Lo importante no es qué convención elijas, sino documentarla y aplicarla sin excepciones: un cliente que ve 409 en un sitio y 422 en otro para el mismo tipo de fallo no puede programar contra la API.
Hay una tentación que conviene resistir: meter reglas de negocio dentro de un ConstraintValidator. Es técnicamente posible —el validador es un bean y puede inyectar EstacionRepositorio— y hay ejemplos por internet de un @NombreEstacionUnico. Los problemas: produce el código HTTP equivocado (400 en lugar de 409); consulta la base de datos fuera de la transacción, con lo que entre la validación y el guardado otro hilo puede insertar el mismo nombre, de modo que la comprobación no es fiable; y rompe la reutilización, porque el validador solo se ejecuta si alguien llama a @Valid y el importador CSV o un consumidor de cola se lo saltarían.
La regla firme del proyecto: el validador mira el dato, el servicio mira el sistema.
Errores Comunes y Consejos
Olvidar la dependencia spring-boot-starter-validation. Sin ella, las anotaciones compilan y no hacen nada. Es el fallo silencioso más frecuente: todo parece bien hasta que llega una capacidad negativa a producción. Los tres olvidos hermanos producen el mismo síntoma: @Valid en el @RequestBody (las anotaciones del DTO se ignoran), @Validated en la clase (no se evalúan las restricciones de @RequestParam y @PathVariable) y @Valid en un campo anidado (el objeto interno pasa sin validar).
Confundir @NotNull, @NotEmpty y @NotBlank. Con " ": las dos primeras pasan, la tercera falla. Para un nombre quieres @NotBlank. Relacionado: @Size no implica no nulo, porque todas las restricciones salvo @NotNull aceptan null.
Grupos sin Default. @Validated(AlCrear.class) no evalúa las restricciones sin grupo. Haz que tu grupo extienda Default.
Usar javax.validation en Spring Boot 3. Las anotaciones existen si alguna dependencia antigua arrastra el paquete, pero Hibernate Validator no las mira. El correcto es jakarta.validation.
Consejo: valida en el DTO, nunca en la entidad de dominio. La entidad puede tener reglas distintas y no debe conocer el contrato de la API: otro argumento para la separación de 03-05. Y escribe el caso inválido antes que el válido: al crear un endpoint, la primera petición del fichero .http debería llevar datos incorrectos. Si responde 200, la validación no está activa.
Ejercicios
Ejercicio 1: Validar el alquiler completo
IniciarAlquilerRequest(Long usuarioId, Long bicicletaId) y FinalizarAlquilerRequest(Long estacionDestinoId) no tienen ninguna validación. Añádeles las restricciones adecuadas, decide qué comprobaciones no deben ser Bean Validation y justifica por qué. Aplica también validación a los parámetros del listado de alquileres, que acepta ?desde= y ?hasta= con fechas.
Ejercicio 2: Restricción propia @CapacidadCoherente
El ayuntamiento de Ribalta impone que la capacidad de una estación sea múltiplo de 6, porque los anclajes se instalan en bloques de seis unidades. Crea una restricción reutilizable @MultiploDe(6) con su validador, aplicable a int, Integer y long, con mensaje internacionalizado.
Ejercicio 3: Validación condicional entre campos
En la promoción "Ribalta Verano", el DTO PromocionRequest(String nombre, LocalDate inicio, LocalDate fin, Integer descuento, Boolean acumulable) debe cumplir: fin posterior a inicio; si acumulable es true, el descuento no puede superar el 20%; y la duración no puede exceder los 90 días. Impleméntalo con una restricción de clase que informe del campo correcto en cada caso.
Soluciones
Solución 1.
public record IniciarAlquilerRequest(
@NotNull(message = "{alquiler.usuario.obligatorio}") @Positive Long usuarioId,
@NotNull(message = "{alquiler.bicicleta.obligatoria}") @Positive Long bicicletaId) {}
public record FinalizarAlquilerRequest(
@NotNull(message = "{alquiler.destino.obligatorio}") @Positive Long estacionDestinoId) {}Qué NO debe ser Bean Validation:
| Comprobación | Por qué no es validación |
|---|---|
| Que el usuario 42 exista | Requiere el repositorio: negocio, y además 404 |
Que la bicicleta esté DISPONIBLE |
Estado actual del sistema: 422 |
| Que la batería supere el umbral | Configuración (ciclourbana.red.umbral-bateria) + estado |
| Que la estación de destino tenga hueco | Estado del sistema: 409 |
| Que el alquiler no esté ya finalizado | Estado del recurso: 422 |
Todas viven en AlquilerService. Bean Validation solo garantiza que los identificadores vienen y son positivos: exactamente lo que puede decidirse mirando el dato. Para el listado, con @Validated en la clase:
@GetMapping
public List<Alquiler> listar(
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
@PastOrPresent LocalDate desde,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
@PastOrPresent LocalDate hasta,
@RequestParam(defaultValue = "0") @Min(0) int pagina,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int tamanio) {
// "desde <= hasta" relaciona dos parámetros sueltos: Bean Validation
// no puede expresarlo sobre parámetros de método. Va al servicio.
return alquilerService.buscar(desde, hasta, pagina, tamanio);
}@DateTimeFormat no es una restricción sino una instrucción de conversión: le dice a Spring que ?desde=2026-08-01 se convierta a LocalDate. Sin ella la conversión depende del Locale y falla de forma inconsistente. Y el comentario señala lo interesante: la relación desde <= hasta no puede expresarse con Bean Validation sobre parámetros sueltos de un método. Las salidas son agrupar los parámetros en un record de filtro con una restricción de clase —como en la solución 3— o comprobarlo en el servicio, que basta para un caso aislado.
Solución 2.
package com.ciclourbana.comun.validacion;
@Documented
@Constraint(validatedBy = MultiploDeValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface MultiploDe {
String message() default "{validacion.multiplo-de}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** Divisor: el valor anotado debe ser múltiplo de este número. */
int value();
}
/**
* Se declara sobre Number para aceptar int, Integer, long, Long y short:
* el autoboxing hace que los primitivos lleguen como envoltorios.
*/
public class MultiploDeValidator implements ConstraintValidator<MultiploDe, Number> {
private int divisor;
@Override
public void initialize(MultiploDe anotacion) {
this.divisor = anotacion.value();
if (divisor == 0) {
// Falla al arrancar, no en tiempo de petición: mejor error, antes
throw new IllegalArgumentException("@MultiploDe no admite divisor 0");
}
}
@Override
public boolean isValid(Number valor, ConstraintValidatorContext contexto) {
if (valor == null) {
return true; // la obligatoriedad la impone @NotNull
}
return valor.longValue() % divisor == 0;
}
}validacion.multiplo-de=El valor debe ser múltiplo de {value}
estacion.capacidad.bloques=Los anclajes se instalan en bloques de {value}: la capacidad debe ser múltiplo de {value}Y en el DTO, junto a las restricciones que ya tenía el campo:
Con esta restricción, las cuatro estaciones existentes de Ribalta siguen siendo válidas: 24, 30, 18 y 36 son múltiplos de 6. Conviene hacer siempre esa comprobación antes de añadir una restricción a un sistema en marcha, porque una regla que invalida los datos existentes rompe el PUT de recursos que ya estaban ahí. Y sobre Number en lugar de Integer: un ConstraintValidator<MultiploDe, Integer> no se aplicaría a un campo long, mientras que Number cubre toda la familia entera; para BigDecimal haría falta otro validador, porque longValue() truncaría los decimales en silencio.
Solución 3.
@PromocionCoherente
public record PromocionRequest(
@NotBlank @Size(min = 3, max = 60) String nombre,
@NotNull @FutureOrPresent LocalDate inicio,
@NotNull LocalDate fin,
@NotNull @Min(1) @Max(50) Integer descuento,
@NotNull Boolean acumulable) {}
public class PromocionCoherenteValidator
implements ConstraintValidator<PromocionCoherente, PromocionRequest> {
private static final int DIAS_MAXIMOS = 90;
private static final int DESCUENTO_MAXIMO_ACUMULABLE = 20;
@Override
public boolean isValid(PromocionRequest p, ConstraintValidatorContext ctx) {
// Si faltan campos obligatorios, sus propias restricciones ya
// informarán: aquí no añadimos ruido. Es un patrón importante.
if (p == null || p.inicio() == null || p.fin() == null
|| p.descuento() == null || p.acumulable() == null) {
return true;
}
boolean valido = true;
ctx.disableDefaultConstraintViolation(); // controlamos los mensajes uno a uno
if (!p.fin().isAfter(p.inicio())) {
error(ctx, "{promocion.fin.posterior}", "fin");
valido = false;
} else if (ChronoUnit.DAYS.between(p.inicio(), p.fin()) > DIAS_MAXIMOS) {
error(ctx, "{promocion.duracion.maxima}", "fin");
valido = false;
}
if (p.acumulable() && p.descuento() > DESCUENTO_MAXIMO_ACUMULABLE) {
error(ctx, "{promocion.descuento.acumulable}", "descuento");
valido = false;
}
return valido;
}
private void error(ConstraintValidatorContext ctx, String plantilla, String campo) {
ctx.buildConstraintViolationWithTemplate(plantilla)
.addPropertyNode(campo).addConstraintViolation();
}
}promocion.fin.posterior=La fecha de fin debe ser posterior a la de inicio
promocion.duracion.maxima=Una promoción no puede durar más de 90 días
promocion.descuento.acumulable=Una promoción acumulable no puede superar el 20%Tres decisiones de diseño que conviene retener:
- Devolver
truesi faltan campos obligatorios. Bean Validation no garantiza el orden de evaluación, así que la restricción de clase puede ejecutarse con campos nulos que@NotNullya está señalando. Sin esa guarda, el cliente recibiría unNullPointerExceptionconvertido en500, o dos mensajes contradictorios sobre el mismo campo. else ifentre "fin posterior" y "duración máxima". Si el fin es anterior al inicio, informar además de la duración es ruido.- Acumular todos los errores en lugar de salir al primero. El método sigue evaluando el descuento aunque las fechas ya hayan fallado, porque son problemas independientes y el formulario debe mostrarlos juntos. La variable
validoexiste para eso; unreturn falseprematuro obligaría al usuario a dos viajes de ida y vuelta.
Conclusión
La grieta está cerrada. Sabes distinguir las cinco capas de validación —formato, tipo, sintaxis, consistencia y negocio— y tienes un criterio operativo para separarlas: si basta con mirar el dato es sintaxis, y si hay que consultar el estado del sistema es negocio. Conoces el catálogo completo de restricciones de Jakarta Bean Validation y las trampas clásicas: la diferencia entre @NotNull, @NotEmpty y @NotBlank, y el hecho de que todas salvo @NotNull aceptan el valor nulo sin protestar. Sabes aplicar @Valid a los cuerpos de petición y @Validated a nivel de clase para los parámetros de ruta y de consulta, y conoces la diferencia entre las excepciones que lanza cada uno —MethodArgumentNotValidException y ConstraintViolationException— y el hecho incómodo de que la segunda produce un 500 por defecto. Sabes descender a objetos anidados con @Valid en el campo, y validar cada elemento de una colección con las restricciones sobre el tipo contenido. Manejas los grupos de validación y la trampa del grupo Default, con la recomendación de no abusar de ellos.
Además, los mensajes de CicloUrbana ya están externalizados en messages.properties e internacionalizados, conectados al MessageSource de Spring y seleccionados por Accept-Language. Has construido dos restricciones propias completas: @MatriculaBicicleta con su ConstraintValidator para el formato RB-0000, y @CoordenadasValidas como restricción de clase que comprueba que una estación cae dentro del término municipal de Ribalta y asocia el error al campo correcto. Sabes validar programáticamente con el Validator inyectado cuando necesitas decidir qué hacer tras el fallo, como en la importación de lotes. Y tienes la tabla de política del proyecto sobre cuándo un fallo es 400, cuándo 409 y cuándo 422, con la regla firme de que el validador mira el dato y el servicio mira el sistema.
Queda un cabo suelto que no ha dejado de asomar. Estamos validando CrearEstacionRequest, un objeto que no es Estacion; hemos hablado de ActualizarEstacionRequest sin construirlo; y arrastramos desde 03-02 el ejercicio que demostraba por qué exponer la clase de dominio directamente no escala. El proyecto tiene ya dos representaciones de una estación conviviendo sin que hayamos ordenado esa convivencia.
La lección 03-05, DTOs y Mapeo entre Capas, la ordena. Veremos con casos concretos por qué no se exponen las entidades del dominio —acoplamiento, fugas de datos sensibles, referencias circulares, evolución imposible del contrato—, diseñaremos la jerarquía completa de DTOs de CicloUrbana separando los de petición de los de respuesta, y compararemos las estrategias de mapeo: manual, MapStruct con su configuración Maven y su código generado, y ModelMapper con sus riesgos. Decidiremos dónde vive el mapeo y construiremos EstacionResponse, EstacionDetalleResponse con su lista de BicicletaResumen, y AlquilerResponse. Al terminar, el dominio de Ribalta y su contrato público serán por fin dos cosas separadas.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
