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

  1. Per què validar a la vora i quines capes de validació existeixen
  2. La dependència i el catàleg de restriccions
  3. @Valid sobre @RequestBody
  4. @Validated per a @PathVariable i @RequestParam
  5. Objectes imbricats i col·leccions
  6. Grups de validació: alta davant de modificació
  7. Missatges personalitzats i internacionalització
  8. Una restricció pròpia: @MatriculaBicicleta
  9. Una restricció de classe: @CoordenadesValides
  10. Validació programàtica amb Validator
  11. Error de validació (400) davant de regla de negoci (409/422)
  12. Errors Comuns i Consells
  13. Exercicis

  1. 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

  1. 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).

  1. @Valid sobre @RequestBody

Anotem 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é.

  1. @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ó.

  1. 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.

  1. 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.

  1. 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.properties

L'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.

  1. Una restricció pròpia: @MatriculaBicicleta

Les 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.

  1. Una restricció de classe: @CoordenadesValides

Algunes 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.

  1. Validació programàtica amb Validator

De 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.

  1. 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:

@Positive @Max(60)
@MultipleDe(value = 6, message = "{estacio.capacitat.blocs}")
int capacitat,

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:

  1. Retornar true si falten camps obligatoris. Bean Validation no garanteix l'ordre d'avaluació, així que la restricció de classe es pot executar amb camps nuls que @NotNull ja està assenyalant. Sense aquesta guarda, el client rebria una NullPointerException convertida en 500, o dos missatges contradictoris sobre el mateix camp.
  2. else if entre "fi posterior" i "durada màxima". Si el fi és anterior a l'inici, informar a més de la durada és soroll.
  3. 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 valid existeix per això; un return false prematur 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

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats