A la lliçó anterior vam treure la configuració de CicloUrbana del codi, però la solució va quedar a mig camí: les claus estan repartides per diverses classes, un camelCase mal escrit en un @Value falla en silenci, ningú no comprova que el preu per minut sigui positiu i l'IDE no ajuda a escriure-les. Aquesta lliçó resol les quatre coses amb @ConfigurationProperties, la forma tipada de llegir configuració: agrupa les propietats relacionades en objectes immutables, enllaça llistes i mapes de manera natural, converteix Duration, DataSize i enumerats sense intervenció, valida els valors a l'arrencada amb Bean Validation i genera metadades perquè el teu IDE autocompleti. En acabar, tota la configuració de tarifes i de la xarxa de Ribalta serà en dos record validats, i aquesta serà la seva llar definitiva durant la resta del curs.

Contingut

  1. Què és @ConfigurationProperties
  2. @Value davant de @ConfigurationProperties
  3. Registrar les propietats: tres formes
  4. Enllaçat a record i a classe amb setters
  5. Estructures imbricades, llistes i mapes
  6. La configuració completa de CicloUrbana
  7. Validació amb @Validated i Bean Validation
  8. Conversió de tipus
  9. Metadades per a l'IDE
  10. Propietats sensibles
  11. Errors Comuns i Consells
  12. Exercicis

  1. Què és @ConfigurationProperties

@ConfigurationProperties enllaça un grup de propietats amb un prefix comú als camps d'un objecte Java. En lloc de repartir cinc @Value per quatre classes, defineixes un objecte que representa "la configuració de tarifes" i Spring l'omple.

La idea en una imatge:

flowchart LR
    Y["application.yaml<br/><br/>ciclourbana:<br/>&nbsp;&nbsp;tarifa:<br/>&nbsp;&nbsp;&nbsp;&nbsp;desbloqueig: 0.50<br/>&nbsp;&nbsp;&nbsp;&nbsp;preu-minut: 0.12"] --> B["Binder de Spring Boot<br/>relaxació de noms<br/>+ conversió de tipus<br/>+ validació"]
    B --> O["TarifaProperties<br/>desbloqueig = 0.50 (BigDecimal)<br/>preuMinut = 0.12 (BigDecimal)"]
    O --> S["Injectat a TarifaEstandard,<br/>SelectorTarifa, ..."]

L'objecte resultant és un bean com qualsevol altre: s'injecta per constructor i s'utilitza amb seguretat de tipus.

  1. @Value davant de @ConfigurationProperties

Criteri @Value @ConfigurationProperties
Unitat de treball Una propietat solta Un grup amb prefix comú
Relaxació de noms No: coincidència exacta Sí: preu-minut ≡ preuMinut ≡ PREU_MINUT
Llistes YAML No (només cadenes amb comes) Sí, de manera natural
Mapes No Sí
Objectes imbricats No Sí, a qualsevol profunditat
Validació (@NotBlank, @Min...) No Sí, amb @Validated
Metadades per a l'IDE No Sí, amb el processor
Conversió de tipus Bàsica Completa (Duration, DataSize, enums, Period...)
SpEL (#{...}) Sí No
Missatge d'error en fallar Una propietat cada vegada Totes les fallades de cop
On viu la configuració Repartida pel codi Centralitzada en classes dedicades
Recomanació Valors aïllats i ocasionals Tota la resta

L'única capacitat exclusiva de @Value és SpEL. A canvi perd tota la resta. La regla que seguirem a CicloUrbana:

Si una propietat la llegeix una sola classe i no necessita validació, @Value és acceptable. Tan bon punt hi ha dues o més propietats relacionades, o dues classes que llegeixen la mateixa, o qualsevol restricció sobre el valor: @ConfigurationProperties.

  1. Registrar les propietats: tres formes

Una classe amb @ConfigurationProperties no es converteix en bean tota sola: cal registrar-la. Hi ha tres mecanismes.

Forma 1: @ConfigurationPropertiesScan (la recomanada)

S'anota una vegada la classe principal i Spring escaneja el paquet base buscant classes @ConfigurationProperties:

package com.ciclourbana;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan   // busca @ConfigurationProperties a com.ciclourbana
public class CicloUrbanaApplication {

    public static void main(String[] args) {
        SpringApplication.run(CicloUrbanaApplication.class, args);
    }
}

A partir d'aquí, qualsevol classe anotada amb @ConfigurationProperties a l'arbre de paquets es registra automàticament. És l'opció que utilitzarem: una anotació i ja no cal recordar res més.

Forma 2: @EnableConfigurationProperties

Registra classes concretes, enumerant-les:

@Configuration
@EnableConfigurationProperties({TarifaProperties.class, XarxaProperties.class})
public class ConfiguracioCicloUrbana { }

És més verbós, però té una virtut: és explícit. És la forma habitual dins d'una autoconfiguració o d'un starter, on no hi ha escaneig de components de l'usuari. L'utilitzarem així a la lliçó 02-06.

Forma 3: estereotip directe

@Component
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public class TarifaProperties { /* ... */ }

Funciona, però només amb classes mutables (amb setters): un record no pot ser @Component perquè necessita l'enllaçat per constructor. És la forma menys recomanable.

Forma Verbositat Quan utilitzar-la
@ConfigurationPropertiesScan Mínima Aplicacions: una vegada i llest
@EnableConfigurationProperties Mitjana Autoconfiguracions i starters
@Component sobre la classe Mínima Gairebé mai: no admet record

  1. Enllaçat a record i a classe amb setters

Spring Boot 3 admet dos estils d'enllaçat.

Enllaçat per constructor: record immutable (recomanat)

package com.ciclourbana.lloguers;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.math.BigDecimal;

/**
 * Configuració de les tarifes de la xarxa de Ribalta.
 * Prefix: ciclourbana.tarifa
 */
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public record TarifaProperties(

        /** Import fix de desbloqueig, en euros. */
        BigDecimal desbloqueig,

        /** Import per minut d'ús, en euros. */
        BigDecimal preuMinut
) {
}

Amb aquest YAML:

ciclourbana:
  tarifa:
    desbloqueig: 0.50
    preu-minut: 0.12

Un detall important de Spring Boot 3: @ConstructorBinding ja no cal si la classe té un únic constructor amb paràmetres, que és sempre el cas d'un record. A Boot 2 s'havia de posar explícitament; veuràs molt codi antic amb ell. Si una classe té diversos constructors, @ConstructorBinding marca quin s'ha d'utilitzar, i a partir de Boot 3 s'anota sobre el constructor, no sobre la classe.

Enllaçat per setters: classe mutable

package com.ciclourbana.lloguers;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.math.BigDecimal;

@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public class TarifaProperties {

    private BigDecimal desbloqueig = new BigDecimal("0.50");   // valor per defecte
    private BigDecimal preuMinut = new BigDecimal("0.12");

    public BigDecimal getDesbloqueig() {
        return desbloqueig;
    }

    public void setDesbloqueig(BigDecimal desbloqueig) {
        this.desbloqueig = desbloqueig;
    }

    public BigDecimal getPreuMinut() {
        return preuMinut;
    }

    public void setPreuMinut(BigDecimal preuMinut) {
        this.preuMinut = preuMinut;
    }
}

Comparats:

Criteri record (constructor) Classe amb setters
Immutabilitat Sí No
Verbositat Mínima Alta
Valors per defecte Al constructor compacte o amb @DefaultValue Inicialitzant el camp
Propietats no definides Arriben com a null Conserven el valor inicial
Modificable en calent No Sí (Spring Cloud Config, vegeu 07-05)
Recomanació Per defecte Només si necessites mutabilitat

Els valors per defecte en un record es declaren amb @DefaultValue:

@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public record TarifaProperties(
        @DefaultValue("0.50") BigDecimal desbloqueig,
        @DefaultValue("0.12") BigDecimal preuMinut
) {
}

Ara, si el YAML no defineix ciclourbana.tarifa.desbloqueig, el valor serà 0.50 en lloc de null.

Utilitzar-lo a CicloUrbana

package com.ciclourbana.lloguers;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component
@Primary
public class TarifaEstandard implements CalculadoraTarifa {

    private final TarifaProperties propietats;

    // Un sol paràmetre en lloc de dos @Value
    public TarifaEstandard(TarifaProperties propietats) {
        this.propietats = propietats;
    }

    @Override
    public BigDecimal calcular(Duration durada) {
        BigDecimal minuts = BigDecimal.valueOf(Math.max(1, durada.toMinutes()));
        return propietats.desbloqueig()
                .add(propietats.preuMinut().multiply(minuts))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nom() {
        return "estandard";
    }
}

Fixa't en el guany: el constructor passa de dos paràmetres anotats amb cadenes de text fràgils a un objecte tipat. En un test, new TarifaEstandard(new TarifaProperties(new BigDecimal("0.50"), new BigDecimal("0.12"))) és directe i no requereix Spring.

  1. Estructures imbricades, llistes i mapes

Aquí és on @ConfigurationProperties se separa definitivament de @Value.

Objectes imbricats

Es declaren com a record imbricats:

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        String ciutat,
        Tarifa tarifa,
        Xarxa xarxa
) {
    public record Tarifa(BigDecimal desbloqueig, BigDecimal preuMinut) { }

    public record Xarxa(int capacitatMinima, int llindarBateria) { }
}
ciclourbana:
  ciutat: Ribalta
  tarifa:
    desbloqueig: 0.50
    preu-minut: 0.12
  xarxa:
    capacitat-minima: 8
    llindar-bateria: 20

L'accés és propietats.tarifa().desbloqueig(). Pots imbricar tants nivells com necessitis; a la pràctica, més de tres es torna incòmode.

Llistes

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        List<String> estacionsDestacades,
        List<Manteniment> finestresManteniment
) {
    public record Manteniment(String dia, LocalTime inici, LocalTime fi) { }
}
ciclourbana:
  estacions-destacades:
    - Plaça Major
    - Universitat
  finestres-manteniment:
    - dia: DIMARTS
      inici: "03:00"
      fi: "05:00"
    - dia: DIJOUS
      inici: "03:00"
      fi: "04:30"

A .properties la mateixa llista s'escriu amb índexs, cosa que il·lustra per què vam triar YAML:

ciclourbana.finestres-manteniment[0].dia=DIMARTS
ciclourbana.finestres-manteniment[0].inici=03:00
ciclourbana.finestres-manteniment[0].fi=05:00
ciclourbana.finestres-manteniment[1].dia=DIJOUS

Un avís: les llistes no es fusionen entre fonts de propietats. Si application.yaml defineix tres estacions destacades i una variable d'entorn en defineix una, el resultat és una, no pas quatre. La font de major prioritat reemplaça la llista sencera.

Mapes

És el cas més potent i el que resol el SelectorTarifa de la lliçó 02-02 de manera elegant:

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        Map<String, BigDecimal> tarifesPerUsuari
) {
}
ciclourbana:
  tarifes-per-usuari:
    estandard: 0.12
    estudiant: 0.08
    jubilat: 0.05

I el valor del mapa pot ser al seu torn un objecte:

public record CicloUrbanaProperties(
        Map<String, PerfilTarifa> tarifesPerUsuari
) {
    public record PerfilTarifa(
            BigDecimal desbloqueig,
            BigDecimal preuMinut,
            int minutsGratis
    ) { }
}
ciclourbana:
  tarifes-per-usuari:
    estandard:
      desbloqueig: 0.50
      preu-minut: 0.12
      minuts-gratis: 0
    estudiant:
      desbloqueig: 0.00
      preu-minut: 0.08
      minuts-gratis: 15
    jubilat:
      desbloqueig: 0.00
      preu-minut: 0.05
      minuts-gratis: 30

Amb això, afegir un tipus de tarifa deixa de requerir codi: n'hi ha prou amb afegir tres línies al YAML. És un salt qualitatiu respecte de la solució de la lliçó 02-02, on cada tarifa necessitava la seva classe.

Dos advertiments sobre els mapes: les claus no es relaxen (si escrius estudiant-becat, la clau és literalment estudiant-becat, no pas estudiantBecat), i si una clau conté caràcters especials cal tancar-la entre claudàtors: ciclourbana.tarifes-per-usuari.[clau.amb.punts].preu-minut.

  1. La configuració completa de CicloUrbana

Reunim tot en dues classes de propietats, que seran les definitives del projecte.

package com.ciclourbana.lloguers;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.math.BigDecimal;
import java.util.Map;

/**
 * Configuració del sistema de tarifes de la xarxa de Ribalta.
 * Prefix: ciclourbana.tarifes
 */
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(

        /** Import fix de desbloqueig per defecte, en euros. */
        @DefaultValue("0.50") BigDecimal desbloqueig,

        /** Import per minut per defecte, en euros. */
        @DefaultValue("0.12") BigDecimal preuMinut,

        /** Perfils de tarifa per tipus d'usuari, indexats pel seu identificador. */
        Map<String, PerfilTarifa> perTipusUsuari
) {

    /** Condicions econòmiques d'un tipus d'usuari. */
    public record PerfilTarifa(
            @DefaultValue("0.00") BigDecimal desbloqueig,
            BigDecimal preuMinut,
            @DefaultValue("0") int minutsGratis
    ) { }
}
package com.ciclourbana.estacions;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.time.Duration;
import java.util.List;

/**
 * Configuració operativa de la xarxa d'estacions de Ribalta.
 * Prefix: ciclourbana.xarxa
 */
@ConfigurationProperties(prefix = "ciclourbana.xarxa")
public record XarxaProperties(

        /** Nom de la ciutat, utilitzat en informes i en la salutació de l'arrencada. */
        @DefaultValue("Ribalta") String ciutat,

        /** Ancoratges mínims exigits per donar d'alta una estació. */
        @DefaultValue("8") int capacitatMinima,

        /** Percentatge de bateria per sota del qual una bicicleta es retira. */
        @DefaultValue("20") int llindarBateria,

        /** Temps màxim d'un lloguer abans d'aplicar recàrrec. */
        @DefaultValue("2h") Duration duradaMaximaLloguer,

        /** Estacions que es mostren destacades a l'aplicació mòbil. */
        @DefaultValue({"Plaça Major", "Universitat"}) List<String> estacionsDestacades
) {
}

I el YAML corresponent:

# src/main/resources/application.yaml
spring:
  application:
    name: ciclourbana

server:
  port: 8080
  shutdown: graceful

logging:
  level:
    com.ciclourbana: DEBUG

ciclourbana:
  xarxa:
    ciutat: Ribalta
    capacitat-minima: 8
    llindar-bateria: 20
    durada-maxima-lloguer: 2h
    estacions-destacades:
      - Plaça Major
      - Universitat
  tarifes:
    desbloqueig: 0.50
    preu-minut: 0.12
    per-tipus-usuari:
      estandard:
        desbloqueig: 0.50
        preu-minut: 0.12
        minuts-gratis: 0
      estudiant:
        preu-minut: 0.08
        minuts-gratis: 15
      jubilat:
        preu-minut: 0.05
        minuts-gratis: 30

Ara el selector de tarifes es recolza en la configuració en lloc de fer-ho en les classes:

package com.ciclourbana.lloguers;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Set;

/**
 * Calcula l'import d'un lloguer a partir dels perfils de tarifa
 * definits a la configuració. Afegir un tipus d'usuari ja no requereix
 * escriure una classe: n'hi ha prou amb afegir-lo al YAML.
 */
@Service
public class SelectorTarifa {

    private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);

    private final TarifesProperties tarifes;

    public SelectorTarifa(TarifesProperties tarifes) {
        this.tarifes = tarifes;
        log.info("Perfils de tarifa configurats: {}", tipusDisponibles());
    }

    public Set<String> tipusDisponibles() {
        return tarifes.perTipusUsuari().keySet();
    }

    public BigDecimal calcular(String tipusUsuari, Duration durada) {
        TarifesProperties.PerfilTarifa perfil = tarifes.perTipusUsuari().get(tipusUsuari);
        if (perfil == null) {
            throw new IllegalArgumentException("Tipus d'usuari desconegut: " + tipusUsuari
                    + ". Disponibles: " + tipusDisponibles());
        }

        long facturables = Math.max(0, durada.toMinutes() - perfil.minutsGratis());
        return perfil.desbloqueig()
                .add(perfil.preuMinut().multiply(BigDecimal.valueOf(facturables)))
                .setScale(2, RoundingMode.HALF_UP);
    }
}
c.c.lloguers.SelectorTarifa : Perfils de tarifa configurats: [estandard, estudiant, jubilat]

Un apunt de disseny: la interfície CalculadoraTarifa i les seves implementacions de la lliçó 02-02 continuen sent útils per a tarifes amb lògica pròpia (una tarifa de temporada alta que depengui de la data, per exemple, o una de promocional amb regles complexes). El que hem fet és moure a configuració el que només eren dades. Distingir una cosa de l'altra és un bon criteri de disseny: si la diferència entre dos casos són uns números, és configuració; si és un algorisme, és codi.

  1. Validació amb @Validated i Bean Validation

Un preu per minut negatiu, una capacitat mínima de zero o una llista de tarifes buida haurien d'impedir l'arrencada. @ConfigurationProperties s'integra amb Jakarta Bean Validation per aconseguir-ho de manera declarativa.

Primer, la dependència:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Aquest és l'starter que vam anunciar a la taula de la lliçó 01-04; l'utilitzarem també, i molt, a la lliçó 03-04 per validar l'entrada de l'API.

Ara les anotacions:

package com.ciclourbana.lloguers;

import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;

import java.math.BigDecimal;
import java.util.Map;

@Validated                                     // <-- activa la validació
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(

        @NotNull
        @PositiveOrZero(message = "El desbloqueig no pot ser negatiu")
        @DefaultValue("0.50") BigDecimal desbloqueig,

        @NotNull
        @DecimalMin(value = "0.01", message = "El preu per minut ha de ser més gran que 0")
        @DefaultValue("0.12") BigDecimal preuMinut,

        @NotEmpty(message = "Cal definir com a mínim un perfil de tarifa")
        Map<String, @Valid PerfilTarifa> perTipusUsuari   // @Valid: valida cada valor
) {

    public record PerfilTarifa(

            @NotNull @PositiveOrZero
            @DefaultValue("0.00") BigDecimal desbloqueig,

            @NotNull
            @DecimalMin(value = "0.01", message = "El preu per minut ha de ser positiu")
            BigDecimal preuMinut,

            @Min(value = 0, message = "Els minuts gratis no poden ser negatius")
            @DefaultValue("0") int minutsGratis
    ) { }
}

I per a la xarxa:

@Validated
@ConfigurationProperties(prefix = "ciclourbana.xarxa")
public record XarxaProperties(

        @NotBlank(message = "Cal indicar la ciutat de la xarxa")
        @DefaultValue("Ribalta") String ciutat,

        @Min(value = 4, message = "Una estació necessita com a mínim 4 ancoratges")
        @Max(value = 100, message = "Cap estació de Ribalta no supera els 100 ancoratges")
        @DefaultValue("8") int capacitatMinima,

        @Min(0) @Max(100)
        @DefaultValue("20") int llindarBateria,

        @NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
        @DefaultValue("2h") Duration duradaMaximaLloguer,

        @NotEmpty @DefaultValue({"Plaça Major", "Universitat"})
        List<@NotBlank String> estacionsDestacades
) {
}

Les restriccions més útils:

Anotació S'aplica a Comprova
@NotNull Qualsevol No és null
@NotBlank String No és null, no és buida ni només espais
@NotEmpty String, col·leccions, mapes No és null ni és buida
@Min / @Max Enters Rang
@Positive / @PositiveOrZero Nombres Signe
@DecimalMin / @DecimalMax BigDecimal, decimals Rang amb precisió decimal
@Size(min, max) Cadenes i col·leccions Longitud o nombre d'elements
@Pattern(regexp) String Expressió regular
@Email String Format de correu
@Valid Objectes imbricats i elements de col·lecció Valida en cascada
@DurationMin / @DurationMax Duration Rang temporal (de Spring Boot)

El @Valid dins del genèric —Map<String, @Valid PerfilTarifa>— és imprescindible: sense ell, les restriccions de PerfilTarifa no s'avaluen. És l'oblit més habitual en validar estructures imbricades.

La fallada d'arrencada

Amb aquesta configuració no vàlida:

ciclourbana:
  xarxa:
    ciutat: ""
    capacitat-minima: 2
    llindar-bateria: 150
  tarifes:
    per-tipus-usuari:
      estudiant:
        preu-minut: -0.05
        minuts-gratis: -3

L'arrencada falla amb un informe complet, no pas d'un en un:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.ciclourbana.estacions.XarxaProperties failed:

    Property: ciclourbana.xarxa.ciutat
    Value: ""
    Reason: Cal indicar la ciutat de la xarxa

    Property: ciclourbana.xarxa.capacitat-minima
    Value: "2"
    Reason: Una estació necessita com a mínim 4 ancoratges

    Property: ciclourbana.xarxa.llindar-bateria
    Value: "150"
    Reason: ha de ser menor o igual que 100

Action:

Update your application's configuration

Aquest missatge és la raó principal per utilitzar @ConfigurationProperties amb validació: diu quina propietat, quin valor i per què està malament, i ho diu de totes alhora. Compara'l amb el Could not resolve placeholder de @Value i la diferència és abismal.

I la propietat més valuosa de tot això: l'error passa a l'arrencada, no pas quan un ciutadà de Ribalta intenti pagar un lloguer amb una tarifa negativa.

  1. Conversió de tipus

El binder de Spring Boot converteix automàticament el text del fitxer al tipus Java declarat. Els casos que més s'utilitzen:

Duration

@DefaultValue("2h")   Duration duradaMaximaLloguer;
@DefaultValue("30s")  Duration tempsEsperaAncoratge;
@DefaultValue("500ms") Duration latenciaMaxima;
ciclourbana:
  xarxa:
    durada-maxima-lloguer: 2h           # 2 hores
    temps-espera-ancoratge: 30s         # 30 segons
    interval-sincronitzacio: PT15M      # format ISO-8601 també vàlid
Sufix Unitat Exemple
ns nanosegons 500ns
us microsegons 200us
ms mil·lisegons 500ms
s segons 30s
m minuts 15m
h hores 2h
d dies 7d
(cap) segons @DurationUnit, per defecte mil·lisegons 5000

Si prefereixes escriure números sense sufix, @DurationUnit fixa la unitat:

@DurationUnit(ChronoUnit.MINUTES)
@DefaultValue("120") Duration duradaMaximaLloguer;   // 120 significa 120 minuts
ciclourbana:
  xarxa:
    durada-maxima-lloguer: 120   # 120 minuts, no pas 120 mil·lisegons

Recomanació: utilitza sempre el sufix explícit (2h) en lloc de @DurationUnit. És autodocumentat i no depèn de llegir el codi Java per interpretar el fitxer.

Existeix l'equivalent @PeriodUnit per a java.time.Period (dies, mesos, anys), útil per a terminis de facturació.

DataSize

Per a mides de fitxer o de memòria:

@DefaultValue("5MB")  DataSize midaMaximaFotoIncidencia;
@DefaultValue("512KB") DataSize midaMaximaInforme;
ciclourbana:
  incidencies:
    mida-maxima-foto: 5MB

Sufixos: B, KB, MB, GB, TB. Sense sufix s'interpreten bytes, llevat que utilitzis @DataSizeUnit. L'utilitzaràs de debò al mòdul 3 en configurar la pujada de fotos d'incidències.

Enumerats

package com.ciclourbana.estacions;

/** Estat operatiu d'una estació de la xarxa. */
public enum EstatEstacio {
    OPERATIVA, MANTENIMENT, FORA_DE_SERVEI
}
@DefaultValue("OPERATIVA") EstatEstacio estatPerDefecte;
ciclourbana:
  xarxa:
    estat-per-defecte: manteniment       # sense distingir majúscules
    # també valen: MANTENIMENT, Manteniment, fora-de-servei

La conversió d'enumerats també aplica relaxació: fora-de-servei, FORA_DE_SERVEI i foraDeServei es resolen tots a FORA_DE_SERVEI. Si el valor no correspon a cap constant, l'arrencada falla amb un missatge que llista els valors vàlids, cosa que és excel·lent per a l'operador.

Col·leccions i altres tipus

List<String> estacionsDestacades;       // llista YAML natural
Set<String> etiquetes;                  // sense duplicats
Map<String, Integer> quotesPerBarri;    // mapa
LocalTime horaTancament;                // "23:30"
LocalDate iniciTemporada;               // "2026-06-01"
Charset codificacioInformes;            // "UTF-8"
Locale idiomaPerDefecte;                // "ca-ES"
Resource plantillaFactura;              // "classpath:plantilles/factura.html"
Class<?> implementacio;                 // nom complet de la classe
BigDecimal preu;                        // decimal exacte, obligatori per a diners

Convertidors propis

Si necessites un tipus que Spring no sap convertir, registra un Converter:

package com.ciclourbana.comu;

import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

/** Converteix "RB-0142" en un objecte Matricula. */
@Component
@ConfigurationPropertiesBinding   // <-- imprescindible: el fa visible al binder
public class ConversorMatricula implements Converter<String, Matricula> {

    @Override
    public Matricula convert(String origen) {
        return Matricula.de(origen);
    }
}

L'anotació @ConfigurationPropertiesBinding és la clau: sense ella, el convertidor existeix com a bean però el binder no l'utilitza.

  1. Metadades per a l'IDE

Quan escrius server.po a application.yaml, IntelliJ o VS Code et suggereixen server.port i et mostren la seva descripció i el seu valor per defecte. Això funciona perquè Spring Boot publica metadades en un fitxer JSON dins del jar. Les teves propietats poden fer el mateix.

El processor d'anotacions

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

<optional>true</optional> és important: el processor només cal en compilar; no s'ha de propagar a qui depengui del teu artefacte.

En compilar, genera target/classes/META-INF/spring-configuration-metadata.json a partir de les teves classes @ConfigurationProperties i dels seus comentaris Javadoc:

./mvnw clean compile
cat target/classes/META-INF/spring-configuration-metadata.json
{
  "groups": [
    {
      "name": "ciclourbana.xarxa",
      "type": "com.ciclourbana.estacions.XarxaProperties",
      "sourceType": "com.ciclourbana.estacions.XarxaProperties"
    }
  ],
  "properties": [
    {
      "name": "ciclourbana.xarxa.capacitat-minima",
      "type": "java.lang.Integer",
      "description": "Ancoratges mínims exigits per donar d'alta una estació.",
      "sourceType": "com.ciclourbana.estacions.XarxaProperties",
      "defaultValue": 8
    },
    {
      "name": "ciclourbana.xarxa.llindar-bateria",
      "type": "java.lang.Integer",
      "description": "Percentatge de bateria per sota del qual una bicicleta es retira.",
      "sourceType": "com.ciclourbana.estacions.XarxaProperties",
      "defaultValue": 20
    }
  ]
}

Observa d'on surt el camp description: del comentari Javadoc del component del record. Aquesta és la millor raó per documentar les teves propietats: el comentari no es queda al codi, apareix a l'autocompletat de qui configuri l'aplicació.

Metadades addicionals a mà

Per al que el processor no pot deduir —valors permesos, propietats declarades dinàmicament, marques d'obsolescència— existeix un fitxer que escrius tu:

// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "ciclourbana.xarxa.estat-per-defecte",
      "type": "com.ciclourbana.estacions.EstatEstacio",
      "description": "Estat amb què es donen d'alta les estacions noves.",
      "defaultValue": "OPERATIVA"
    },
    {
      "name": "ciclourbana.tarifes.tarifa-plana",
      "type": "java.math.BigDecimal",
      "description": "Tarifa plana mensual. Substituïda per ciclourbana.tarifes.subscripcio.",
      "deprecation": {
        "level": "error",
        "reason": "Substituïda pel model de subscripcions.",
        "replacement": "ciclourbana.tarifes.subscripcio.preu-mensual"
      }
    }
  ],
  "hints": [
    {
      "name": "ciclourbana.xarxa.estacions-destacades",
      "values": [
        { "value": "Plaça Major", "description": "Estació de 24 ancoratges al centre." },
        { "value": "Estació Nord", "description": "Estació de 30 ancoratges." },
        { "value": "Parc del Riu", "description": "Estació de 18 ancoratges." },
        { "value": "Universitat", "description": "Estació de 36 ancoratges al campus sud." }
      ]
    }
  ]
}

Els dos blocs resolen necessitats diferents:

  • deprecation fa que l'IDE ratlli la propietat i mostri l'alternativa. Amb "level": "error" indica que ja no funciona en absolut. És la forma correcta de retirar una propietat sense trencar els usuaris en silenci.
  • hints ofereix valors suggerits amb descripció. En escriure ciclourbana.xarxa.estacions-destacades: l'IDE proposa les quatre estacions de Ribalta.

Aquest fitxer es fusiona amb el generat automàticament; no el substitueix.

  1. Propietats sensibles

Recuperem el fil de la lliçó anterior. Les credencials no van al repositori; ara veiem com evitar a més que es filtrin per altres vies.

No les registris al log

La fallada més comuna: un toString() automàtic que inclou la contrasenya.

// MALAMENT: el toString() d'un record inclou TOTS els components
@ConfigurationProperties(prefix = "ciclourbana.passarela")
public record PassarelaProperties(String url, String apiKey) { }
log.info("Configuració de passarel·la: {}", propietats);
// -> PassarelaProperties[url=https://pagaments.ribalta.example, apiKey=sk_live_9f3a2b1c8d7e]
// La clau acaba de quedar escrita al fitxer de log, a l'agregador i a les còpies

La correcció: sobreescriure toString().

package com.ciclourbana.comu;

import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "ciclourbana.passarela")
public record PassarelaProperties(

        /** URL base de la passarel·la de pagament municipal. */
        @NotBlank String url,

        /** Clau d'API. No s'ha de registrar mai al log ni versionar-se. */
        @NotBlank String apiKey
) {

    /** Amaga la clau d'API en qualsevol sortida de text. */
    @Override
    public String toString() {
        return "PassarelaProperties[url=" + url + ", apiKey=" + emmascarar(apiKey) + "]";
    }

    private static String emmascarar(String valor) {
        if (valor == null || valor.length() < 8) {
            return "****";
        }
        // Conserva els 4 primers caràcters: prou per identificar la clau
        return valor.substring(0, 4) + "****" + valor.substring(valor.length() - 2);
    }
}
Configuració de passarel·la: PassarelaProperties[url=https://pagaments.ribalta.example, apiKey=sk_l****7e]

Ocultació a Actuator

L'endpoint /actuator/env exposa tota la configuració. Spring Boot emmascara automàticament les claus que contenen password, secret, key, token, credentials o vcap_services, i a Spring Boot 3 aquest endpoint no està exposat per defecte. Pots ampliar la llista:

management:
  endpoint:
    env:
      show-values: when-authorized      # mai 'always' en producció
    configprops:
      show-values: when-authorized
  endpoints:
    web:
      exposure:
        include: health,info            # NO exposis env ni configprops sense més

Actuator s'estudia a la lliçó 07-01; l'important ara és saber que existeix i que exposar-lo sense cura publica la teva configuració sencera.

Resum de regles

Regla Per què
El valor mai al repositori Git no oblida (lliçó 02-04)
Marcador sense valor per defecte (${CLAU_API}) Falla a l'arrencada si falta
toString() emmascarat Evita la filtració per logs
Actuator sense env ni configprops exposats Evita la filtració per HTTP
Rotar la credencial si es filtra Esborrar-la del codi no la invalida

Errors Comuns i Consells

Oblidar registrar la classe de propietats. Sense @ConfigurationPropertiesScan, @EnableConfigurationProperties o @Component, la classe no és un bean i l'arrencada falla amb NoSuchBeanDefinitionException. És, de bon tros, l'error número u amb @ConfigurationProperties.

Posar @ConfigurationProperties sobre una classe sense setters i sense constructor amb paràmetres. Tots els camps queden a null sense cap error. Amb record no pot passar; amb classes mutables, sí.

Oblidar @Valid en un objecte imbricat o al genèric d'una col·lecció. Les restriccions internes no s'avaluen i una configuració no vàlida passa l'arrencada. Recorda Map<String, @Valid PerfilTarifa>.

Posar @Validated a la classe imbricada en lloc de a l'arrel. @Validated va a la classe anotada amb @ConfigurationProperties; la cascada cap endins la produeix @Valid.

Esperar que les llistes es fusionin entre fonts. No ho fan: la font de major prioritat reemplaça la llista sencera.

Utilitzar double per a imports. 0.1 + 0.2 no és 0.3 en coma flotant binària. En diners, sempre BigDecimal.

Escriure el prefix amb majúscules o guions baixos. El prefix de @ConfigurationProperties ha d'anar en minúscules i kebab-case: ciclourbana.tarifes, no pas cicloUrbana.Tarifes. Spring Boot ho rebutja explícitament.

No incloure el spring-boot-configuration-processor. No trenca res, però perds l'autocompletat i la documentació a l'IDE, que és un dels millors avantatges del mecanisme. I recorda: si afegeixes el processor amb l'IDE obert, hauràs de recompilar i, a IntelliJ, de vegades reimportar el projecte Maven.

Consell: una classe de propietats per àrea funcional. TarifesProperties a .lloguers, XarxaProperties a .estacions. Col·locar-les al costat de la seva funcionalitat, no en un paquet config genèric, manté la cohesió que vam decidir a la lliçó 01-04.

Consell: documenta cada component amb Javadoc. No és cerimònia: aquest text acaba a l'autocompletat de l'IDE de qui configuri l'aplicació.

Consell: valida sempre, encara que sembli excessiu. Cada restricció que afegeixes converteix un possible error de producció en una fallada d'arrencada de trenta segons.

Consell: no dupliquis configuració al codi. Si XarxaProperties.capacitatMinima és 8, EstacioService ha de llegir aquesta propietat, no pas tenir el seu propi if (capacitat < 8). És exactament el que corregirem al primer exercici.

Exercicis

Exercici 1: migrar EstacioService a la configuració

EstacioService té encara la regla if (estacio.capacitat() < 8) amb el 8 escrit a foc. Injecta XarxaProperties i utilitza capacitatMinima(). Afegeix a més una validació que impedeixi registrar una estació el nom de la qual no sigui entre les destacades si la xarxa és en estat de manteniment (utilitza una propietat booleana nova ciclourbana.xarxa.nomes-destacades, amb valor per defecte false). Comprova el comportament sobreescrivint la propietat per línia d'ordres.

Exercici 2: propietats d'incidències amb validació i tipus

Crea IncidenciaProperties amb prefix ciclourbana.incidencies que inclogui: mida-maxima-foto (DataSize, per defecte 5MB, entre 100KB i 20MB), termini-resolucio (Duration, per defecte 48h, mínim 1 hora), prioritat-per-defecte (un enumerat Prioritat amb BAIXA, MITJANA, ALTA), destinataris-avis (llista de correus validats amb @Email, no buida). Escriu un CommandLineRunner que bolqui la configuració i comprova el missatge d'error amb valors no vàlids.

Exercici 3: metadades i ocultació de secrets

Afegeix el spring-boot-configuration-processor al pom.xml, documenta amb Javadoc tots els components de XarxaProperties i verifica que apareixen al JSON generat. Després crea PassarelaProperties amb url i api-key, amb la clau llegida d'una variable d'entorn sense valor per defecte i un toString() emmascarat, i afegeix un additional-spring-configuration-metadata.json amb suggeriments per a ciclourbana.xarxa.estacions-destacades.


Solucions

Solució 1

package com.ciclourbana.estacions;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMax;
import org.springframework.boot.convert.DurationMin;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;
import java.util.List;

@Validated
@ConfigurationProperties(prefix = "ciclourbana.xarxa")
public record XarxaProperties(

        /** Nom de la ciutat on opera la xarxa. */
        @NotBlank @DefaultValue("Ribalta") String ciutat,

        /** Ancoratges mínims exigits per donar d'alta una estació. */
        @Min(4) @Max(100) @DefaultValue("8") int capacitatMinima,

        /** Percentatge de bateria per sota del qual una bicicleta es retira. */
        @Min(0) @Max(100) @DefaultValue("20") int llindarBateria,

        /** Temps màxim d'un lloguer abans d'aplicar recàrrec. */
        @NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
        @DefaultValue("2h") Duration duradaMaximaLloguer,

        /** Estacions destacades a l'aplicació mòbil. */
        @NotEmpty @DefaultValue({"Plaça Major", "Universitat"})
        List<@NotBlank String> estacionsDestacades,

        /** Si és true, només s'admeten altes d'estacions destacades. */
        @DefaultValue("false") boolean nomesDestacades
) {
}
package com.ciclourbana.estacions;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

@Service
public class EstacioService {

    private static final Logger log = LoggerFactory.getLogger(EstacioService.class);

    private final EstacioRepositori estacioRepositori;
    private final XarxaProperties xarxa;

    public EstacioService(EstacioRepositori estacioRepositori, XarxaProperties xarxa) {
        this.estacioRepositori = estacioRepositori;
        this.xarxa = xarxa;
    }

    public List<Estacio> llistarTotes() {
        return estacioRepositori.cercarTotes().stream()
                .sorted(Comparator.comparing(Estacio::nom))
                .toList();
    }

    public Optional<Estacio> cercarPerId(Long id) {
        return estacioRepositori.cercarPerId(id);
    }

    public Estacio registrar(Estacio estacio) {
        // El llindar ja no està escrit a foc: ve de la configuració
        if (estacio.capacitat() < xarxa.capacitatMinima()) {
            throw new IllegalArgumentException(
                    "Una estació de " + xarxa.ciutat() + " requereix com a mínim "
                            + xarxa.capacitatMinima() + " ancoratges; rebuts: "
                            + estacio.capacitat());
        }

        if (xarxa.nomesDestacades() && !xarxa.estacionsDestacades().contains(estacio.nom())) {
            throw new IllegalStateException(
                    "La xarxa està limitada a estacions destacades; '"
                            + estacio.nom() + "' no ho és");
        }

        Estacio desada = estacioRepositori.desar(estacio);
        log.info("Estació registrada a {}: {} ({} ancoratges)",
                xarxa.ciutat(), desada.nom(), desada.capacitat());
        return desada;
    }

    public int capacitatTotalXarxa() {
        return estacioRepositori.cercarTotes().stream()
                .mapToInt(Estacio::capacitat)
                .sum();
    }

    public long comptar() {
        return estacioRepositori.comptar();
    }
}

Verificació:

# Normal: es carreguen les 4 estacions de demostració
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Carregades 4 estacions, 108 ancoratges en total

# Només destacades: "Estació Nord" i "Parc del Riu" són rebutjades
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.xarxa.nomes-destacades=true
# IllegalStateException: La xarxa està limitada a estacions destacades;
#   'Estació Nord' no ho és

# Llindar més exigent: "Parc del Riu" (18) passa, però no una de 16
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.xarxa.capacitat-minima=20
# IllegalArgumentException: Una estació de Ribalta requereix com a mínim 20 ancoratges;
#   rebuts: 18

Comentari: fixa't que el missatge d'error es construeix amb els valors de configuració. És un detall petit amb un efecte gran: qui llegeix el log entén immediatament quina regla s'ha aplicat i amb quin llindar, sense obrir el codi.

Consell: xarxa.estacionsDestacades().contains(...) és una cerca lineal sobre una llista. Amb quatre elements és irrellevant, però si la llista creixés, convindria convertir-la a Set una sola vegada —en un @PostConstruct del servei, per exemple, aplicant el que hem après a la lliçó 02-03.

Solució 2

package com.ciclourbana.incidencies;

/** Prioritat d'atenció d'una incidència reportada per un usuari. */
public enum Prioritat {
    BAIXA, MITJANA, ALTA
}
package com.ciclourbana.incidencies;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMin;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;
import java.util.List;

/**
 * Configuració del sistema d'incidències de la xarxa de Ribalta.
 * Prefix: ciclourbana.incidencies
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.incidencies")
public record IncidenciaProperties(

        /** Mida màxima de la foto adjunta en reportar una incidència. */
        @NotNull @DefaultValue("5MB") DataSize midaMaximaFoto,

        /** Termini compromès per resoldre una incidència. */
        @NotNull @DurationMin(hours = 1) @DefaultValue("48h") Duration terminiResolucio,

        /** Prioritat assignada a les incidències que no en indiquen cap. */
        @NotNull @DefaultValue("MITJANA") Prioritat prioritatPerDefecte,

        /** Correus de l'equip de manteniment que reben l'avís. */
        @NotEmpty List<@Email String> destinatarisAvis
) {

    /**
     * Bean Validation no cobreix rangs de DataSize, així que el validem
     * al constructor compacte: s'executa durant l'enllaçat.
     */
    public IncidenciaProperties {
        if (midaMaximaFoto != null) {
            long bytes = midaMaximaFoto.toBytes();
            if (bytes < DataSize.ofKilobytes(100).toBytes()
                    || bytes > DataSize.ofMegabytes(20).toBytes()) {
                throw new IllegalArgumentException(
                        "ciclourbana.incidencies.mida-maxima-foto ha d'estar entre "
                                + "100KB i 20MB; rebut: " + midaMaximaFoto);
            }
        }
    }
}
ciclourbana:
  incidencies:
    mida-maxima-foto: 5MB
    termini-resolucio: 48h
    prioritat-per-defecte: mitjana        # relaxat: es resol a MITJANA
    destinataris-avis:
      - [email protected]
      - [email protected]
package com.ciclourbana.incidencies;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class AvisConfiguracioIncidencies implements CommandLineRunner {

    private static final Logger log =
            LoggerFactory.getLogger(AvisConfiguracioIncidencies.class);

    private final IncidenciaProperties propietats;

    public AvisConfiguracioIncidencies(IncidenciaProperties propietats) {
        this.propietats = propietats;
    }

    @Override
    public void run(String... args) {
        log.info("Incidències | foto màx: {} ({} bytes) | termini: {} ({} h) | "
                        + "prioritat per defecte: {} | avisos a: {}",
                propietats.midaMaximaFoto(),
                propietats.midaMaximaFoto().toBytes(),
                propietats.terminiResolucio(),
                propietats.terminiResolucio().toHours(),
                propietats.prioritatPerDefecte(),
                propietats.destinatarisAvis());
    }
}
Incidències | foto màx: 5242880 (5242880 bytes) | termini: PT48H (48 h) |
  prioritat per defecte: MITJANA | avisos a: [[email protected], [email protected]]

I amb valors no vàlids:

java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.incidencies.termini-resolucio=30m \
  --ciclourbana.incidencies.destinataris-avis=aixo-no-es-un-correu
Binding to target com.ciclourbana.incidencies.IncidenciaProperties failed:

    Property: ciclourbana.incidencies.termini-resolucio
    Value: "30m"
    Reason: ha de ser més gran o igual que 1 hores

    Property: ciclourbana.incidencies.destinataris-avis[0]
    Value: "aixo-no-es-un-correu"
    Reason: ha de ser una adreça de correu electrònic amb un format correcte

Comentari: l'exercici combina les tres capacitats de conversió de tipus —DataSize, Duration i enumerat amb relaxació— amb validació en cascada dins d'una llista (List<@Email String>). El constructor compacte del record és el lloc idiomàtic per a validacions que Bean Validation no cobreix: s'executa durant l'enllaçat i la seva excepció s'integra al mateix informe d'error.

Error freqüent: escriure termini-resolucio: 48 sense sufix. S'interpretaria com a 48 mil·lisegons i fallaria la validació de mínim 1 hora... afortunadament. Sense aquesta validació, hauries tingut un termini de resolució de 48 ms sense adonar-te'n.

Solució 3

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>
./mvnw clean compile
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json | head -40
{
    "groups": [
        {
            "name": "ciclourbana.xarxa",
            "type": "com.ciclourbana.estacions.XarxaProperties",
            "sourceType": "com.ciclourbana.estacions.XarxaProperties"
        }
    ],
    "properties": [
        {
            "name": "ciclourbana.xarxa.capacitat-minima",
            "type": "java.lang.Integer",
            "description": "Ancoratges mínims exigits per donar d'alta una estació.",
            "sourceType": "com.ciclourbana.estacions.XarxaProperties",
            "defaultValue": 8
        }
    ]
}

La classe de la passarel·la:

package com.ciclourbana.comu;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * Credencials de la passarel·la de pagament municipal de Ribalta.
 * La clau d'API MAI no es versiona: arriba per variable d'entorn.
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.passarela")
public record PassarelaProperties(

        /** URL base del servei de pagaments de l'ajuntament. */
        @NotBlank @Pattern(regexp = "https://.*",
                message = "La passarel·la ha d'utilitzar HTTPS") String url,

        /** Clau d'API. S'emmascara a toString() i no s'ha de registrar mai. */
        @NotBlank String apiKey
) {

    @Override
    public String toString() {
        return "PassarelaProperties[url=" + url + ", apiKey=" + emmascarar(apiKey) + "]";
    }

    private static String emmascarar(String valor) {
        if (valor == null || valor.length() < 8) {
            return "****";
        }
        return valor.substring(0, 4) + "****" + valor.substring(valor.length() - 2);
    }
}
ciclourbana:
  passarela:
    url: https://pagaments.ribalta.example/api/v1
    # Sense valor per defecte: si falta la variable, l'arrencada falla
    api-key: ${CICLOURBANA_PASSARELA_API_KEY}
// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "ciclourbana.passarela.api-key",
      "type": "java.lang.String",
      "description": "Clau d'API de la passarel·la. Ha d'arribar per la variable d'entorn CICLOURBANA_PASSARELA_API_KEY; no es versiona mai."
    }
  ],
  "hints": [
    {
      "name": "ciclourbana.xarxa.estacions-destacades",
      "values": [
        { "value": "Plaça Major", "description": "24 ancoratges, centre històric." },
        { "value": "Estació Nord", "description": "30 ancoratges, intercanviador." },
        { "value": "Parc del Riu", "description": "18 ancoratges, zona verda." },
        { "value": "Universitat", "description": "36 ancoratges, campus sud." }
      ]
    },
    {
      "name": "ciclourbana.xarxa.estat-per-defecte",
      "values": [
        { "value": "OPERATIVA", "description": "L'estació accepta lloguers." },
        { "value": "MANTENIMENT", "description": "Temporalment tancada." },
        { "value": "FORA_DE_SERVEI", "description": "Tancada indefinidament." }
      ]
    }
  ]
}

Prova final:

# Sense la variable: falla, i és el correcte
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Could not resolve placeholder 'CICLOURBANA_PASSARELA_API_KEY'

# Amb la variable, i comprovant que el log no la revela
CICLOURBANA_PASSARELA_API_KEY='sk_live_9f3a2b1c8d7e' \
  java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Passarel·la configurada: PassarelaProperties[url=https://pagaments.ribalta.example/api/v1, apiKey=sk_l****7e]

Comentari: el @Pattern(regexp = "https://.*") sobre la URL és un detall que val la pena copiar. Impedeix per configuració que algú apunti la passarel·la de pagament a un endpoint sense xifrar, i ho fa a l'arrencada, no pas quan ja s'ha enviat la primera targeta de crèdit en clar.

Sobre l'emmascarat: conservar els quatre primers caràcters és un compromís deliberat. Permet a un operador identificar quina clau està utilitzant (sk_live_ davant de sk_test_) sense revelar la clau. Emmascarar el 100 % és més segur però fa impossible diagnosticar un desplegament amb la credencial equivocada.

Conclusió

La configuració de CicloUrbana ha assolit la seva forma definitiva. Saps que @ConfigurationProperties enllaça un grup de propietats amb prefix comú a un objecte tipat, i coneixes les onze diferències que el fan superior a @Value en tot llevat de SpEL. Saps registrar-lo amb @ConfigurationPropertiesScan en una aplicació i amb @EnableConfigurationProperties en un starter —el necessitaràs a la lliçó vinent—. Saps enllaçar a un record immutable, que a Spring Boot 3 ja no necessita @ConstructorBinding, i donar valors per defecte amb @DefaultValue. Domines les estructures que @Value no assoleix: objectes imbricats, llistes i mapes d'objectes, amb la conseqüència pràctica que afegir una tarifa a la xarxa de Ribalta va passar d'escriure una classe a escriure tres línies de YAML. Saps validar amb @Validated i Bean Validation, inclosa la cascada amb @Valid dins de genèrics, i has vist l'informe d'arrencada que enumera totes les fallades amb propietat, valor i motiu. Coneixes la conversió automàtica de Duration, DataSize, enumerats i col·leccions, i com registrar un convertidor propi amb @ConfigurationPropertiesBinding. Saps generar metadades perquè l'IDE autocompleti les teves propietats a partir del teu propi Javadoc, i ampliar-les a mà amb suggeriments i marques d'obsolescència. I saps protegir un secret en les tres vies per les quals es filtra: el repositori, el log i Actuator.

El projecte compta ara amb TarifesProperties (amb el seu mapa de perfils per tipus d'usuari), XarxaProperties (capacitat mínima, llindar de bateria, durada màxima i estacions destacades), IncidenciaProperties i PassarelaProperties, totes validades, i amb un EstacioService que aplica regles configurables en lloc de constants.

Queda una sola peça del contenidor per obrir, i és la més característica de Spring Boot. Des de la primera lliçó hem dit que l'autoconfiguració "detecta el que hi ha al classpath i registra els beans apropiats", i ho hem acceptat com una caixa negra. Afegim spring-boot-starter-web i apareixen Tomcat, Jackson i el DispatcherServlet sense escriure ni una línia. Com ho decideix exactament? I per què n'hi ha prou amb declarar el teu propi bean perquè Spring Boot s'aparti? La darrera lliçó del mòdul, Autoconfiguració i Starters per Dins, respon això llegint el mecanisme real —el fitxer AutoConfiguration.imports, l'AutoConfigurationImportSelector, les anotacions condicionals— i aprenent a depurar-lo amb l'informe d'autoconfiguració. I acabarem construint el nostre propi starter, ciclourbana-tarifes-spring-boot-starter, amb la configuració tipada que acabem d'escriure.

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