En tancar la lliçó anterior va quedar una esquerda oberta: l'API de CicloUrbana accepta qualsevol cosa. Una estació amb capacitat -5, un nom buit, una latitud de 200 graus o una matrícula que no s'assembla a RB-0142 entren sense resistència i es desen tan tranquil·les. Les poques comprovacions que hem escrit viuen disperses pels serveis, barrejades amb les regles de negoci, i cap no produeix un missatge útil per al client. En aquesta lliçó tanquem aquesta esquerda amb Jakarta Bean Validation: un mecanisme declaratiu que converteix les restriccions en anotacions sobre les dades mateixes, les aplica automàticament a la vora de l'aplicació i permet crear restriccions pròpies del domini de Ribalta. En acabar, cap petició mal formada no arribarà viva a la capa de servei.
Contingut
- Per què validar a la vora i quines capes de validació existeixen
- La dependència i el catàleg de restriccions
@Validsobre@RequestBody@Validatedper a@PathVariablei@RequestParam- Objectes imbricats i col·leccions
- Grups de validació: alta davant de modificació
- Missatges personalitzats i internacionalització
- Una restricció pròpia:
@MatriculaBicicleta - Una restricció de classe:
@CoordenadesValides - Validació programàtica amb
Validator - Error de validació (400) davant de regla de negoci (409/422)
- Errors Comuns i Consells
- Exercicis
- Per què validar a la vora i quines capes de validació existeixen
La vora és el punt pel qual les dades externes entren a l'aplicació: en el nostre cas, el controlador. Validar-hi falla aviat i barat —un POST amb capacitat negativa es rebutja abans de tocar el servei, el repositori o la base de dades—, produeix missatges útils —"la capacitat ha de ser més gran que zero" en lloc d'un error de restricció de la base de dades— i simplifica el codi de dins: si EstacioService pot assumir que la capacitat és positiva i el nom no és buit, desapareix la meitat dels seus if.
Ara bé, hi ha diverses menes de validació i confondre-les produeix dissenys dolents:
| Capa | Què comprova | Exemple a CicloUrbana | On viu | Codi HTTP |
|---|---|---|---|---|
| Format | El JSON és sintàcticament vàlid? | {"capacitat": sense tancar |
Jackson | 400 |
| Tipus | El valor encaixa en el tipus Java? | "capacitat": "vint" |
Jackson / ConversionService |
400 |
| Sintaxi | El valor compleix el format esperat? | capacitat > 0, matrícula RB-0000 |
Bean Validation, al DTO | 400 |
| Consistència | Els camps són coherents entre si? | Latitud i longitud dins de Ribalta | Bean Validation de classe | 400 |
| Negoci | L'operació és legítima donat l'estat del sistema? | L'estació de destí és plena | Servei | 409 / 422 |
La frontera entre "sintaxi" i "negoci" és la que més costa. La regla pràctica: si per decidir necessites consultar l'estat del sistema, és negoci; si en tens prou de mirar la dada, és sintaxi. "La capacitat ha de ser positiva" es decideix mirant el número: sintaxi. "No hi pot haver dues estacions amb el mateix nom" exigeix consultar el repositori: negoci.
graph LR
A["Petició HTTP"] --> B["Jackson<br/>format i tipus"]
B --> C["Bean Validation<br/>@Valid al controlador"]
C --> D["EstacioService<br/>regles de negoci"]
D --> E["Repositori"]
B -.400.-> X["Resposta d'error"]
C -.400.-> X
D -."409 / 422".-> X
- La dependència i el catàleg de restriccions
Bean Validation no ve amb spring-boot-starter-web: cal afegir-la explícitament.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Aquest starter arrossega Hibernate Validator, la implementació de referència de Jakarta Bean Validation 3.0. En arrencar, ValidationAutoConfiguration registra un bean LocalValidatorFactoryBean —el veuries a l'informe --debug de 02-06— i a partir d'aquí @Valid funciona als controladors. Un detall important: les anotacions són a jakarta.validation.constraints, no a javax.validation; Spring Boot 3 va migrar a l'espai de noms de Jakarta EE i bona part dels exemples que hi ha per internet continuen fent servir javax, que no és compatible.
El catàleg de restriccions estàndard:
| Anotació | Què exigeix | Tipus aplicables | Ús a CicloUrbana |
|---|---|---|---|
@NotNull |
No nul (el buit "" passa) |
Qualsevol | estacioDestiId en finalitzar |
@NotEmpty |
No nul i amb mida > 0 | String, col·leccions |
Llista de bicicletes d'un lot |
@NotBlank |
No nul i amb algun caràcter no blanc | Només String |
nom d'estació |
@Size(min, max) |
Mida dins del rang | String, col·leccions |
nom entre 3 i 80 |
@Min / @Max |
Enter dins del rang | Enters | nivellBateria entre 0 i 100 |
@Positive / @PositiveOrZero |
Més gran que zero / no negatiu | Numèrics | capacitat |
@DecimalMin / @DecimalMax |
Rang amb decimals | BigDecimal, double |
latitud, longitud |
@Digits(integer, fraction) |
Nombre de dígits | Numèrics | import: 6 i 2 |
@Email |
Format de correu | String |
Correu de l'usuari |
@Pattern(regexp) |
Coincideix amb l'expressió regular | String |
matricula (RB-\d{4}) |
@Past / @PastOrPresent |
Data al passat | java.time |
dataNaixement |
@Future / @FutureOrPresent |
Data al futur | java.time |
dataFiPromocio |
@AssertTrue / @AssertFalse |
Booleà amb valor concret | boolean |
acceptaCondicions |
Les tres primeres es confonen constantment. Amb el valor " " (tres espais): @NotNull passa, @NotEmpty passa (té longitud 3) i @NotBlank falla; per a un nom gairebé sempre vols @NotBlank. I un apunt que estalvia sorpreses: totes les restriccions excepte @NotNull consideren vàlid el valor null, així que @Size(min = 3) sobre un camp nul passa sense protestar. Si el camp és obligatori cal combinar-les: @NotBlank @Size(max = 80).
@Valid sobre @RequestBody
@Valid sobre @RequestBodyAnotem el DTO de creació de la lliçó anterior, reanomenat ja a CrearEstacioRequest, que és el nom definitiu que fixarà 03-05:
package com.ciclourbana.estacions;
import jakarta.validation.constraints.*;
public record CrearEstacioRequest(
@NotBlank(message = "El nom de l'estació és obligatori")
@Size(min = 3, max = 80, message = "El nom ha de tenir entre {min} i {max} caràcters")
String nom,
@NotBlank @Size(max = 120)
String adreca,
@Positive(message = "La capacitat ha de ser més gran que zero")
@Max(value = 60, message = "Cap estació de Ribalta no supera els {value} ancoratges")
int capacitat,
@DecimalMin("-90.0") @DecimalMax("90.0") double latitud,
@DecimalMin("-180.0") @DecimalMax("180.0") double longitud
) {}Els marcadors {min}, {max} i {value} se substitueixen pels valors de la mateixa anotació, així que el missatge no es desincronitza si demà el límit passa de 60 a 80 ancoratges. Al controlador n'hi ha prou d'afegir @Valid:
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacio> crear(@Valid @RequestBody CrearEstacioRequest peticio) {
Estacio creada = estacioService.crear(peticio);
URI ubicacio = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(creada.id()).toUri();
return ResponseEntity.created(ubicacio).body(creada);
}Si la validació falla, Spring llança MethodArgumentNotValidException i el mètode del controlador no arriba a executar-se. Amb un POST de {"nom":"","capacitat":-5,...}, la resposta per defecte de Spring Boot 3 és:
{ "type": "about:blank", "title": "Bad Request", "status": 400,
"detail": "Invalid request content.", "instance": "/api/v1/estacions" }Funciona —és un 400— però és inservible per al client: no diu quins camps han fallat ni per què. Els detalls són dins de l'excepció, i convertir-los en una resposta amb la llista de camps erronis és la feina de 03-06: aquí generem correctament l'error, allà el presentarem bé.
@Validated per a @PathVariable i @RequestParam
@Validated per a @PathVariable i @RequestParam@Valid només funciona sobre objectes. Per validar paràmetres solts —l'id de la ruta, la mida de la paginació— cal @Validated a nivell de classe, que activa un proxy AOP que intercepta les crides al mètode.
@RestController
@RequestMapping(path = "/api/v1/estacions", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated // <-- imprescindible: valida els paràmetres
public class EstacioController {
@GetMapping
public List<Estacio> llistar(
@RequestParam(required = false) @Size(max = 80) String nom,
@RequestParam(required = false) @Positive Integer capacitatMinima,
@RequestParam(defaultValue = "0") @Min(0) int pagina,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int mida) {
// Adeu al retall manual de la lliçó 03-02: ara és declaratiu
return estacioService.cercar(nom, capacitatMinima, pagina, mida);
}
@GetMapping("/{id:\\d+}")
public ResponseEntity<Estacio> obtenirPerId(@PathVariable("id") @Positive Long id) { ... }
}Compara aquest llistar amb el de 03-02, on hi havia tres línies de Math.min i Math.max per evitar que ?mida=1000000 tombés el servei. Ara la restricció és al costat del paràmetre, es documenta sola a OpenAPI (03-07) i no es pot oblidar. Dues diferències importants davant de @Valid:
| Aspecte | @Valid a @RequestBody |
@Validated a la classe |
|---|---|---|
| Què valida | Els camps de l'objecte | Els paràmetres del mètode |
| Excepció | MethodArgumentNotValidException |
ConstraintViolationException |
| Estat per defecte | 400 Bad Request |
500 Internal Server Error |
| Mecanisme | Resolutor d'arguments | Proxy AOP (MethodValidationPostProcessor) |
El 500 de la tercera fila és un parany clàssic: ConstraintViolationException no està mapada a cap codi HTTP, així que Spring la tracta com un error inesperat. És incorrecte —la culpa és del client, que ha enviat ?mida=5000— i ho arreglarem al gestor global de 03-06. Recorda l'advertiment de 03-01: un 5xx per culpa del client contamina les alertes de producció.
- Objectes imbricats i col·leccions
Bean Validation no baixa automàticament als objectes imbricats: cal demanar-ho amb @Valid sobre el camp.
public record UbicacioRequest(
@DecimalMin("-90.0") @DecimalMax("90.0") double latitud,
@DecimalMin("-180.0") @DecimalMax("180.0") double longitud) {}
public record CrearEstacioRequest(
@NotBlank @Size(min = 3, max = 80) String nom,
@NotBlank String adreca,
@Positive @Max(60) int capacitat,
@NotNull @Valid // <-- sense @Valid, la ubicació NO es valida
UbicacioRequest ubicacio
) {}Sense aquest @Valid, una petició amb {"ubicacio": {"latitud": 500}} passaria la validació sense protestar. És una de les fallades silencioses més freqüents: la validació "funciona" i tanmateix deixa passar dades impossibles.
Per a les col·leccions hi ha dos nivells, i convé distingir-los:
public record LotBicicletesRequest(
@NotEmpty(message = "El lot ha de contenir com a mínim una bicicleta")
@Size(max = 50, message = "No es poden donar d'alta més de {max} bicicletes de cop")
List<@Valid @NotNull CrearBicicletaRequest> bicicletes, // valida CADA element
@NotNull @PastOrPresent LocalDate dataRecepcio
) {}@NotEmpty i @Size s'apliquen a la llista —quants elements té—, mentre que @Valid i @NotNull dins dels claudàtors angulars són restriccions sobre el tipus contingut (Bean Validation 2.0) i s'apliquen a cada element. També funciona sobre Optional<@NotBlank String> i sobre les claus i els valors d'un Map<@NotBlank String, @Positive Integer>.
Quan el cos sencer és una llista, @Valid sobre el @RequestBody no n'hi ha prou: cal embolcallar-la en un objecte com el de dalt, o anotar el controlador amb @Validated i escriure @RequestBody List<@Valid CrearBicicletaRequest> lot. La primera opció és preferible: permet afegir metadades al lot sense trencar el contracte.
- Grups de validació: alta davant de modificació
Un mateix DTO pot necessitar regles diferents segons l'operació: en crear una estació el nom és obligatori, mentre que en modificar-la parcialment pot no venir, encara que si ve ha de complir la mida. Els grups resolen això i són simplement interfícies marcadores:
package com.ciclourbana.comu;
/** Marcadors per als grups de validació. No tenen mètodes. */
public interface GrupsValidacio {
interface AlCrear {}
interface AlActualitzar {}
}public record EstacioRequest(
// Obligatori només en crear; en actualitzar es pot ometre
@NotBlank(groups = AlCrear.class, message = "El nom és obligatori en donar d'alta")
@Size(min = 3, max = 80) // sense grup: pertany al grup Default
String nom,
@NotBlank(groups = AlCrear.class) @Size(max = 120) String adreca,
@NotNull(groups = AlCrear.class) @Positive @Max(60) Integer capacitat,
// L'id només pot venir en actualitzar, i ha de coincidir amb la ruta
@Null(groups = AlCrear.class, message = "No es pot fixar l'id en crear")
@NotNull(groups = AlActualitzar.class)
Long id
) {}Per activar un grup cal fer servir @Validated(Grup.class), no @Valid, que no admet grups: crear(@Validated(AlCrear.class) @RequestBody EstacioRequest peticio) i reemplacar(..., @Validated(AlActualitzar.class) @RequestBody EstacioRequest peticio).
La regla del grup Default que gairebé ningú no recorda: una restricció sense groups pertany implícitament al grup Default, i @Validated(AlCrear.class) NO inclou Default. A l'exemple anterior, @Size(min = 3, max = 80) no s'avaluaria en crear. S'arregla declarant els dos grups a cada anotació —@Size(..., groups = {AlCrear.class, AlActualitzar.class})— o, millor, fent que el grup hereti: public interface AlCrear extends jakarta.validation.groups.Default {}. Amb això segon, @Validated(AlCrear.class) avalua les restriccions d'AlCrear i les que no tenen grup, que és el que gairebé sempre es vol; és el que adopta CicloUrbana.
Un advertiment de disseny: els grups són potents però es tornen il·legibles de pressa. Amb més de dos o tres, sol ser millor tenir DTO separats —CrearEstacioRequest i ActualitzarEstacioRequest—, cadascun amb les seves regles. És la solució que adoptarà el projecte a 03-05.
- Missatges personalitzats i internacionalització
Els missatges per defecte d'Hibernate Validator vénen en anglès i són genèrics ("must not be blank"). Fixar-los a l'anotació, com hem fet, els deixa escrits al codi i en un sol idioma. La solució completa és externalitzar-los en un fitxer per idioma a src/main/resources:
# messages.properties (idioma per defecte: català)
estacio.nom.obligatori=El nom de l'estació és obligatori
estacio.nom.mida=El nom ha de tenir entre {min} i {max} caràcters
estacio.capacitat.positiva=La capacitat ha de ser més gran que zero
bicicleta.matricula.format=La matrícula ha de seguir el format RB-0000
bicicleta.bateria.rang=El nivell de bateria ha d'estar entre {min} i {max}
# messages_en.properties
estacio.nom.obligatori=The station name is required
estacio.nom.mida=The name must be between {min} and {max} characters
estacio.capacitat.positiva=Capacity must be greater than zero
bicicleta.matricula.format=The plate must follow the RB-0000 format
bicicleta.bateria.rang=Battery level must be between {min} and {max}A les anotacions es referencia la clau entre claus: @NotBlank(message = "{estacio.nom.obligatori}"). I cal connectar el validador amb el MessageSource de Spring, perquè per defecte Hibernate Validator busca els seus missatges a ValidationMessages.properties i no al messages.properties de Spring:
package com.ciclourbana.comu;
@Configuration
public class ConfiguracioValidacio {
/** Resol les {claus} contra el MessageSource de Spring, amb la qual cosa
* hereten l'idioma negociat per Accept-Language. */
@Bean
LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
factory.setValidationMessageSource(messageSource);
return factory;
}
}Amb la configuració del MessageSource en YAML:
spring:
messages:
basename: messages
encoding: UTF-8
fallback-to-system-locale: false # si falta l'idioma, fa servir messages.propertiesL'idioma se selecciona a partir d'Accept-Language. Perquè Spring MVC la respecti convé declarar el resolutor explícitament:
@Bean
LocaleResolver localeResolver() {
var resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.forLanguageTag("ca"));
resolver.setSupportedLocales(List.of(Locale.forLanguageTag("ca"),
Locale.forLanguageTag("es"), Locale.forLanguageTag("en")));
return resolver;
}Ara un client que enviï Accept-Language: en rep "The station name is required" i "Capacity must be greater than zero" sense que el codi canviï ni una línia.
- Una restricció pròpia:
@MatriculaBicicleta
@MatriculaBicicletaLes matrícules de la xarxa de Ribalta tenen el format RB-0142: dues lletres fixes, un guió i quatre dígits. Podríem fer servir @Pattern(regexp = "RB-\\d{4}") a cada lloc, però copiar la mateixa expressió regular en cinc DTO és una invitació que un dia no coincideixin. Una restricció pròpia posa nom a la regla i la centralitza.
Una restricció es compon sempre de dues peces: l'anotació i el validador.
package com.ciclourbana.comu.validacio;
@Documented
@Constraint(validatedBy = MatriculaBicicletaValidator.class) // <-- el validador
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface MatriculaBicicleta {
// Els tres atributs següents són OBLIGATORIS a tota restricció
String message() default "{bicicleta.matricula.format}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** Si és true, s'accepta el valor null (per defecte, com la resta). */
boolean permetNul() default true;
}Els tres atributs message, groups i payload no són opcionals: si en falta algun, Hibernate Validator falla en arrencar amb un error poc explicatiu. ElementType.RECORD_COMPONENT és necessari perquè l'anotació es pugui col·locar sobre un component de record.
El validador:
package com.ciclourbana.comu.validacio;
public class MatriculaBicicletaValidator
implements ConstraintValidator<MatriculaBicicleta, String> {
// L'expressió es compila UNA vegada: el validador és un singleton reutilitzat
private static final Pattern PATRO = Pattern.compile("^RB-\\d{4}$");
private boolean permetNul;
@Override
public void initialize(MatriculaBicicleta anotacio) {
this.permetNul = anotacio.permetNul();
}
@Override
public boolean isValid(String matricula, ConstraintValidatorContext context) {
if (matricula == null) {
return permetNul; // per convenció, null se sol delegar a @NotNull
}
return PATRO.matcher(matricula).matches();
}
}I el seu ús, tan net com qualsevol restricció estàndard:
public record CrearBicicletaRequest(
@NotBlank @MatriculaBicicleta String matricula, // tota la regla, una paraula
@Min(value = 0, message = "{bicicleta.bateria.rang}")
@Max(value = 100, message = "{bicicleta.bateria.rang}")
int nivellBateria,
@NotNull @Positive Long estacioId
) {}Un detall que convé entendre: el validador és un bean amb cicle de vida gestionat, així que pot injectar dependències per constructor. Això permet validadors que consulten un repositori... però compte, això ja seria una regla de negoci, i l'apartat 11 explica per què normalment no s'ha de fer.
- Una restricció de classe:
@CoordenadesValides
@CoordenadesValidesAlgunes regles no es poden expressar sobre un camp aïllat perquè en relacionen diversos. A CicloUrbana, les coordenades han de caure dins del terme municipal de Ribalta, i això requereix mirar latitud i longitud alhora. La solució és una restricció a nivell de classe.
package com.ciclourbana.comu.validacio;
@Documented
@Constraint(validatedBy = CoordenadesValidesValidator.class)
@Target(ElementType.TYPE) // <-- sobre el TIPUS, no sobre el camp
@Retention(RetentionPolicy.RUNTIME)
public @interface CoordenadesValides {
String message() default "{estacio.coordenades.fora-de-ribalta}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class CoordenadesValidesValidator
implements ConstraintValidator<CoordenadesValides, CrearEstacioRequest> {
// Rectangle que envolta el terme 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(CrearEstacioRequest p, ConstraintValidatorContext context) {
if (p == null) {
return true;
}
boolean dins = p.latitud() >= LAT_MIN && p.latitud() <= LAT_MAX
&& p.longitud() >= LON_MIN && p.longitud() <= LON_MAX;
if (!dins) {
// Sense això, l'error s'associa a l'objecte sencer i el client no
// sap quin camp mirar. Amb això, s'associa a "latitud".
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"{estacio.coordenades.fora-de-ribalta}")
.addPropertyNode("latitud").addConstraintViolation();
}
return dins;
}
}I s'anota el record complet amb @CoordenadesValides, a més de les restriccions de camp que ja tenia. El bloc de buildConstraintViolationWithTemplate és el que diferencia una restricció de classe usable d'una de molesta: sense ell, l'error de validació no té camp associat i el formulari del tauler de Ribalta no pot ressaltar res. Amb ell, el client rep l'error apuntant a latitud.
Observa també que si latitud és 500 fallaran tant @DecimalMax("90.0") com @CoordenadesValides, i el client rebrà dues violacions: Bean Validation avalua totes les restriccions i retorna el conjunt complet, cosa desitjable perquè un formulari ha de mostrar tots els seus errors de cop.
- Validació programàtica amb
Validator
ValidatorDe vegades cal validar fora de la vora HTTP: en processar un fitxer CSV d'alta massiva de bicicletes, en consumir un missatge d'una cua (mòdul 7) o en validar un objecte construït dins del servei mateix. Per a això s'injecta el Validator de Jakarta.
package com.ciclourbana.bicicletes;
@Service
public class ImportadorBicicletes {
private final Validator validator; // jakarta.validation.Validator
private final BicicletaService bicicletaService;
// ... constructor amb injecció de tots dos
/**
* Importa un lot validant fila a fila. A diferència de la vora HTTP,
* aquí NO volem avortar tot el lot per una fila dolenta: es registra
* la fallada, es descarta aquesta fila i es continua.
*/
public ResultatImportacio importar(List<CrearBicicletaRequest> files) {
List<Bicicleta> importades = new ArrayList<>();
Map<Integer, List<String>> errors = new LinkedHashMap<>();
for (int i = 0; i < files.size(); i++) {
var violacions = validator.validate(files.get(i));
if (violacions.isEmpty()) {
importades.add(bicicletaService.crear(files.get(i)));
} else {
List<String> missatges = violacions.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted().toList();
errors.put(i + 1, missatges); // fila 1 = primera del fitxer
log.warn("Fila {} descartada: {}", i + 1, missatges);
}
}
return new ResultatImportacio(importades.size(), errors);
}
}validator.validate(objecte) retorna un Set<ConstraintViolation<T>> buit si tot està bé. Cada violació exposa getPropertyPath() (el camp), getMessage() (ja resolt i internacionalitzat) i getInvalidValue() (el valor rebutjat).
| Enfocament | Quan fer-lo servir | Davant de l'error |
|---|---|---|
Declaratiu (@Valid) |
Entrada HTTP, el 95% dels casos | Avorta la petició amb una excepció |
Programàtic (Validator) |
Lots, missatgeria, validació condicional | Tu decideixes: descartar, acumular, avisar |
La regla: declaratiu per defecte, programàtic quan necessitis controlar què passa després de la fallada. Rebutjar 4.000 files correctes perquè la 137 té la matrícula malament seria absurd.
Sobre getInvalidValue(), un advertiment de seguretat que es reprèn a 03-06: no l'incloguis mai a la resposta sense pensar-t'ho. Si el camp que ha fallat fos una contrasenya o un número de targeta, estaries retornant la dada sensible al cos de l'error i, gairebé segur, escrivint-la al log.
- Error de validació (400) davant de regla de negoci (409/422)
Aquesta és la decisió de disseny que més discussions genera. La política de CicloUrbana, amb exemples:
| Situació | Es detecta | Codi | Per què |
|---|---|---|---|
| JSON mal format | Jackson | 400 |
La petició no és interpretable |
capacitat no és un número |
Jackson | 400 |
Tipus incorrecte |
capacitat és -5 |
Bean Validation | 400 |
La dada és invàlida en si mateixa |
matricula no compleix RB-0000 |
Bean Validation | 400 |
Format incorrecte |
| Ja existeix una estació amb aquest nom | Servei | 409 |
Conflicte amb l'estat actual |
| L'estació de destí és plena | Servei | 409 |
Conflicte amb l'estat actual |
| La bicicleta és en manteniment | Servei | 422 |
Sintàcticament correcte, no processable |
| Bateria per sota del llindar | Servei | 422 |
Depèn de la configuració i de l'estat |
El criteri operatiu: si el client pot corregir la petició mirant només el que ha enviat, és un 400; si necessita saber alguna cosa de l'estat del servidor, no ho és. capacitat: -5 es corregeix sol; que la Plaça Major sigui plena no es pot endevinar des del client. Entre 409 i 422 la frontera és més difusa, i la convenció del projecte és 409 Conflict quan el conflicte és amb un altre recurs existent (nom duplicat, estació plena) i 422 Unprocessable Entity quan la petició xoca amb una regla de negoci o amb l'estat d'un recurs (bicicleta en manteniment, lloguer ja finalitzat). L'important no és quina convenció triïs, sinó documentar-la i aplicar-la sense excepcions: un client que veu 409 en un lloc i 422 en un altre per al mateix tipus de fallada no pot programar contra l'API.
Hi ha una temptació que convé resistir: ficar regles de negoci dins d'un ConstraintValidator. És tècnicament possible —el validador és un bean i pot injectar EstacioRepositori— i hi ha exemples per internet d'un @NomEstacioUnic. Els problemes: produeix el codi HTTP equivocat (400 en lloc de 409); consulta la base de dades fora de la transacció, amb la qual cosa entre la validació i el desat un altre fil pot inserir el mateix nom, de manera que la comprovació no és fiable; i trenca la reutilització, perquè el validador només s'executa si algú crida @Valid i l'importador CSV o un consumidor de cua se'l saltarien.
La regla ferma del projecte: el validador mira la dada, el servei mira el sistema.
Errors Comuns i Consells
Oblidar la dependència spring-boot-starter-validation. Sense ella, les anotacions compilen i no fan res. És la fallada silenciosa més freqüent: tot sembla bé fins que arriba una capacitat negativa a producció. Els tres oblits germans produeixen el mateix símptoma: @Valid al @RequestBody (les anotacions del DTO s'ignoren), @Validated a la classe (no s'avaluen les restriccions de @RequestParam i @PathVariable) i @Valid en un camp imbricat (l'objecte intern passa sense validar).
Confondre @NotNull, @NotEmpty i @NotBlank. Amb " ": les dues primeres passen, la tercera falla. Per a un nom vols @NotBlank. Relacionat: @Size no implica no nul, perquè totes les restriccions llevat de @NotNull accepten null.
Grups sense Default. @Validated(AlCrear.class) no avalua les restriccions sense grup. Fes que el teu grup estengui Default.
Fer servir javax.validation a Spring Boot 3. Les anotacions existeixen si alguna dependència antiga arrossega el paquet, però Hibernate Validator no les mira. La correcta és jakarta.validation.
Consell: valida al DTO, mai a l'entitat de domini. L'entitat pot tenir regles diferents i no ha de conèixer el contracte de l'API: un altre argument per a la separació de 03-05. I escriu el cas invàlid abans que el vàlid: en crear un endpoint, la primera petició del fitxer .http hauria de dur dades incorrectes. Si respon 200, la validació no està activa.
Exercicis
Exercici 1: Validar el lloguer complet
IniciarLloguerRequest(Long usuariId, Long bicicletaId) i FinalitzarLloguerRequest(Long estacioDestiId) no tenen cap validació. Afegeix-los les restriccions adequades, decideix quines comprovacions no han de ser Bean Validation i justifica per què. Aplica també validació als paràmetres del llistat de lloguers, que accepta ?desDe= i ?finsA= amb dates.
Exercici 2: Restricció pròpia @CapacitatCoherent
L'ajuntament de Ribalta imposa que la capacitat d'una estació sigui múltiple de 6, perquè els ancoratges s'instal·len en blocs de sis unitats. Crea una restricció reutilitzable @MultipleDe(6) amb el seu validador, aplicable a int, Integer i long, amb missatge internacionalitzat.
Exercici 3: Validació condicional entre camps
A la promoció "Ribalta Estiu", el DTO PromocioRequest(String nom, LocalDate inici, LocalDate fi, Integer descompte, Boolean acumulable) ha de complir: fi posterior a inici; si acumulable és true, el descompte no pot superar el 20%; i la durada no pot excedir els 90 dies. Implementa-ho amb una restricció de classe que informi del camp correcte en cada cas.
Solucions
Solució 1.
public record IniciarLloguerRequest(
@NotNull(message = "{lloguer.usuari.obligatori}") @Positive Long usuariId,
@NotNull(message = "{lloguer.bicicleta.obligatoria}") @Positive Long bicicletaId) {}
public record FinalitzarLloguerRequest(
@NotNull(message = "{lloguer.desti.obligatori}") @Positive Long estacioDestiId) {}Què NO ha de ser Bean Validation:
| Comprovació | Per què no és validació |
|---|---|
| Que l'usuari 42 existeixi | Requereix el repositori: negoci, i a més 404 |
Que la bicicleta estigui DISPONIBLE |
Estat actual del sistema: 422 |
| Que la bateria superi el llindar | Configuració (ciclourbana.xarxa.llindar-bateria) + estat |
| Que l'estació de destí tingui lloc | Estat del sistema: 409 |
| Que el lloguer no estigui ja finalitzat | Estat del recurs: 422 |
Totes viuen a LloguerService. Bean Validation només garanteix que els identificadors vénen i són positius: exactament el que es pot decidir mirant la dada. Per al llistat, amb @Validated a la classe:
@GetMapping
public List<Lloguer> llistar(
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
@PastOrPresent LocalDate desDe,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
@PastOrPresent LocalDate finsA,
@RequestParam(defaultValue = "0") @Min(0) int pagina,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int mida) {
// "desDe <= finsA" relaciona dos paràmetres solts: Bean Validation
// no ho pot expressar sobre paràmetres de mètode. Va al servei.
return lloguerService.cercar(desDe, finsA, pagina, mida);
}@DateTimeFormat no és una restricció sinó una instrucció de conversió: diu a Spring que ?desDe=2026-08-01 es converteixi a LocalDate. Sense ella la conversió depèn del Locale i falla de manera inconsistent. I el comentari assenyala l'interessant: la relació desDe <= finsA no es pot expressar amb Bean Validation sobre paràmetres solts d'un mètode. Les sortides són agrupar els paràmetres en un record de filtre amb una restricció de classe —com a la solució 3— o comprovar-ho al servei, que n'hi ha prou per a un cas aïllat.
Solució 2.
package com.ciclourbana.comu.validacio;
@Documented
@Constraint(validatedBy = MultipleDeValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface MultipleDe {
String message() default "{validacio.multiple-de}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** Divisor: el valor anotat ha de ser múltiple d'aquest nombre. */
int value();
}
/**
* Es declara sobre Number per acceptar int, Integer, long, Long i short:
* l'autoboxing fa que els primitius arribin com a embolcalls.
*/
public class MultipleDeValidator implements ConstraintValidator<MultipleDe, Number> {
private int divisor;
@Override
public void initialize(MultipleDe anotacio) {
this.divisor = anotacio.value();
if (divisor == 0) {
// Falla en arrencar, no en temps de petició: millor error, abans
throw new IllegalArgumentException("@MultipleDe no admet divisor 0");
}
}
@Override
public boolean isValid(Number valor, ConstraintValidatorContext context) {
if (valor == null) {
return true; // l'obligatorietat la imposa @NotNull
}
return valor.longValue() % divisor == 0;
}
}validacio.multiple-de=El valor ha de ser múltiple de {value}
estacio.capacitat.blocs=Els ancoratges s'instal·len en blocs de {value}: la capacitat ha de ser múltiple de {value}I al DTO, al costat de les restriccions que ja tenia el camp:
Amb aquesta restricció, les quatre estacions existents de Ribalta continuen sent vàlides: 24, 30, 18 i 36 són múltiples de 6. Convé fer sempre aquesta comprovació abans d'afegir una restricció a un sistema en marxa, perquè una regla que invalida les dades existents trenca el PUT de recursos que ja hi eren. I sobre Number en lloc d'Integer: un ConstraintValidator<MultipleDe, Integer> no s'aplicaria a un camp long, mentre que Number cobreix tota la família entera; per a BigDecimal caldria un altre validador, perquè longValue() truncaria els decimals en silenci.
Solució 3.
@PromocioCoherent
public record PromocioRequest(
@NotBlank @Size(min = 3, max = 60) String nom,
@NotNull @FutureOrPresent LocalDate inici,
@NotNull LocalDate fi,
@NotNull @Min(1) @Max(50) Integer descompte,
@NotNull Boolean acumulable) {}
public class PromocioCoherentValidator
implements ConstraintValidator<PromocioCoherent, PromocioRequest> {
private static final int DIES_MAXIMS = 90;
private static final int DESCOMPTE_MAXIM_ACUMULABLE = 20;
@Override
public boolean isValid(PromocioRequest p, ConstraintValidatorContext ctx) {
// Si falten camps obligatoris, les seves pròpies restriccions ja
// informaran: aquí no hi afegim soroll. És un patró important.
if (p == null || p.inici() == null || p.fi() == null
|| p.descompte() == null || p.acumulable() == null) {
return true;
}
boolean valid = true;
ctx.disableDefaultConstraintViolation(); // controlem els missatges un a un
if (!p.fi().isAfter(p.inici())) {
error(ctx, "{promocio.fi.posterior}", "fi");
valid = false;
} else if (ChronoUnit.DAYS.between(p.inici(), p.fi()) > DIES_MAXIMS) {
error(ctx, "{promocio.durada.maxima}", "fi");
valid = false;
}
if (p.acumulable() && p.descompte() > DESCOMPTE_MAXIM_ACUMULABLE) {
error(ctx, "{promocio.descompte.acumulable}", "descompte");
valid = false;
}
return valid;
}
private void error(ConstraintValidatorContext ctx, String plantilla, String camp) {
ctx.buildConstraintViolationWithTemplate(plantilla)
.addPropertyNode(camp).addConstraintViolation();
}
}promocio.fi.posterior=La data de fi ha de ser posterior a la d'inici
promocio.durada.maxima=Una promoció no pot durar més de 90 dies
promocio.descompte.acumulable=Una promoció acumulable no pot superar el 20%Tres decisions de disseny que convé retenir:
- Retornar
truesi falten camps obligatoris. Bean Validation no garanteix l'ordre d'avaluació, així que la restricció de classe es pot executar amb camps nuls que@NotNullja està assenyalant. Sense aquesta guarda, el client rebria unaNullPointerExceptionconvertida en500, o dos missatges contradictoris sobre el mateix camp. else ifentre "fi posterior" i "durada màxima". Si el fi és anterior a l'inici, informar a més de la durada és soroll.- Acumular tots els errors en lloc de sortir al primer. El mètode continua avaluant el descompte encara que les dates ja hagin fallat, perquè són problemes independents i el formulari els ha de mostrar junts. La variable
validexisteix per això; unreturn falseprematur obligaria l'usuari a dos viatges d'anada i tornada.
Conclusió
L'esquerda està tancada. Saps distingir les cinc capes de validació —format, tipus, sintaxi, consistència i negoci— i tens un criteri operatiu per separar-les: si en tens prou de mirar la dada és sintaxi, i si cal consultar l'estat del sistema és negoci. Coneixes el catàleg complet de restriccions de Jakarta Bean Validation i els paranys clàssics: la diferència entre @NotNull, @NotEmpty i @NotBlank, i el fet que totes llevat de @NotNull accepten el valor nul sense protestar. Saps aplicar @Valid als cossos de petició i @Validated a nivell de classe per als paràmetres de ruta i de consulta, i coneixes la diferència entre les excepcions que llança cadascun —MethodArgumentNotValidException i ConstraintViolationException— i el fet incòmode que la segona produeix un 500 per defecte. Saps baixar a objectes imbricats amb @Valid al camp, i validar cada element d'una col·lecció amb les restriccions sobre el tipus contingut. Manegues els grups de validació i el parany del grup Default, amb la recomanació de no abusar-ne.
A més, els missatges de CicloUrbana ja estan externalitzats a messages.properties i internacionalitzats, connectats al MessageSource de Spring i seleccionats per Accept-Language. Has construït dues restriccions pròpies completes: @MatriculaBicicleta amb el seu ConstraintValidator per al format RB-0000, i @CoordenadesValides com a restricció de classe que comprova que una estació cau dins del terme municipal de Ribalta i associa l'error al camp correcte. Saps validar programàticament amb el Validator injectat quan necessites decidir què fer després de la fallada, com a la importació de lots. I tens la taula de política del projecte sobre quan una fallada és 400, quan 409 i quan 422, amb la regla ferma que el validador mira la dada i el servei mira el sistema.
Queda un cap solt que no ha deixat d'apuntar. Estem validant CrearEstacioRequest, un objecte que no és Estacio; hem parlat d'ActualitzarEstacioRequest sense construir-lo; i arrosseguem des de 03-02 l'exercici que demostrava per què exposar la classe de domini directament no escala. El projecte ja té dues representacions d'una estació convivint sense que hàgim ordenat aquesta convivència.
La lliçó 03-05, DTO i Mapatge entre Capes, l'ordena. Veurem amb casos concrets per què no s'exposen les entitats del domini —acoblament, fuites de dades sensibles, referències circulars, evolució impossible del contracte—, dissenyarem la jerarquia completa de DTO de CicloUrbana separant els de petició dels de resposta, i compararem les estratègies de mapatge: manual, MapStruct amb la seva configuració Maven i el seu codi generat, i ModelMapper amb els seus riscos. Decidirem on viu el mapatge i construirem EstacioResponse, EstacioDetallResponse amb la seva llista de BicicletaResum, i LloguerResponse. En acabar, el domini de Ribalta i el seu contracte públic seran per fi dues coses separades.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
