Des de la primera lliçó anem repetint que Spring Boot "detecta el que hi ha al classpath i registra els beans apropiats". Afegim spring-boot-starter-web al pom.xml i apareixen Tomcat, Jackson, el DispatcherServlet i una vintena de peces més sense escriure ni una sola línia de configuració. Ho hem acceptat com una caixa negra el mòdul sencer. Ara l'obrim del tot. Veurem què fa exactament @EnableAutoConfiguration, on és escrita la llista de candidats, com es decideix condició a condició què es registra i què no, per què n'hi ha prou amb declarar el teu propi bean perquè Spring Boot s'aparti, i com llegir l'informe d'autoconfiguració per respondre la pregunta més frustrant del desenvolupador de Spring: «per què no s'ha creat el meu bean?». I acabarem construint un starter propi, ciclourbana-tarifes-spring-boot-starter, empaquetant el sistema de tarifes de Ribalta perquè una altra aplicació el pugui utilitzar només declarant una dependència.

Contingut

  1. Què fa realment @EnableAutoConfiguration
  2. El fitxer AutoConfiguration.imports
  3. L'AutoConfigurationImportSelector pas a pas
  4. Les anotacions condicionals
  5. @ConditionalOnMissingBean: «defineix el teu bean i Spring s'aparta»
  6. Llegir una autoconfiguració real de Spring Boot
  7. L'ordre entre autoconfiguracions
  8. L'informe d'autoconfiguració
  9. Excloure autoconfiguracions
  10. Crear un starter propi
  11. Provar l'starter amb ApplicationContextRunner
  12. Errors Comuns i Consells
  13. Exercicis

  1. Què fa realment @EnableAutoConfiguration

A la lliçó 02-01 vam desmuntar @SpringBootApplication en tres anotacions i en vam deixar una de pendent. La seva declaració real és:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)     // <-- aquí hi ha tot
public @interface EnableAutoConfiguration {

    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

Només hi ha dues peces:

  • @AutoConfigurationPackage registra el paquet de la classe anotada (com.ciclourbana) com a "paquet d'autoconfiguració". Altres autoconfiguracions el consulten per saber on han de buscar: és així com Spring Data JPA trobarà les nostres entitats al mòdul 4 sense que li diguem on són.
  • @Import(AutoConfigurationImportSelector.class) és el motor. @Import és una anotació de Spring Framework que afegeix classes de configuració al context; quan el que importes és un ImportSelector, Spring li pregunta en temps d'arrencada quines classes ha d'importar. És a dir: la llista no està escrita, es calcula.

La conclusió important: l'autoconfiguració no és un mecanisme especial ni privilegiat. És @Import amb una classe que decideix dinàmicament què importar. Tota la resta és la lògica d'aquesta decisió.

  1. El fitxer AutoConfiguration.imports

D'on treu el selector la llista de candidats? D'un fitxer de text pla que cada jar pot aportar:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Obre'l al teu propi projecte. És dins del jar spring-boot-autoconfigure:

find ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure -name "*.jar" | head -1
unzip -p ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.4.1/spring-boot-autoconfigure-3.4.1.jar \
  "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" | head -20
org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration
org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration
...

Compta quantes n'hi ha:

unzip -p ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.4.1/spring-boot-autoconfigure-3.4.1.jar \
  "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" | wc -l
158

Cent cinquanta-vuit classes candidates. Això és tot el "misteri" de l'autoconfiguració: una llista de noms de classe en un fitxer de text. El que fa que només unes poques s'apliquin a CicloUrbana són les condicions que veurem a l'apartat 4.

L'antecessor: spring.factories

Fins a Spring Boot 2.7, la llista vivia a META-INF/spring.factories, un fitxer de propietats que servia per a moltes coses alhora:

# Format antic (Spring Boot <= 2.7). Ja NO s'utilitza per a autoconfiguració.
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
spring.factories (≤ 2.7) AutoConfiguration.imports (≥ 2.7, obligatori a 3.x)
Ubicació META-INF/spring.factories META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Format Propietats amb \ de continuació Un nom de classe per línia
Propens a errors Sí (les barres invertides) No
Cost de lectura Alt: es processa tot el fitxer Menor
Vigència per a autoconfiguració Eliminat a Spring Boot 3 L'actual

spring.factories continua existint per a altres punts d'extensió (ApplicationListener, EnvironmentPostProcessor...), però no per a autoconfiguració. Si migres un starter antic a Boot 3 i no canvies el fitxer, les teves autoconfiguracions simplement no s'apliquen, sense cap error. És un dels paranys més habituals de la migració.

  1. L'AutoConfigurationImportSelector pas a pas

Aquest és l'algorisme complet, de l'arrencada als beans registrats:

flowchart TD
    A["@EnableAutoConfiguration"] --> B["AutoConfigurationImportSelector"]
    B --> C["1. Llegir TOTS els fitxers<br/>AutoConfiguration.imports del classpath<br/>(158 classes a spring-boot-autoconfigure<br/>+ les de cada starter propi)"]
    C --> D["2. Eliminar duplicats"]
    D --> E["3. Treure les excloses<br/>exclude=, spring.autoconfigure.exclude"]
    E --> F["4. Aplicar els AutoConfigurationImportFilter<br/>OnClassCondition: descarta ràpid<br/>el que no té les seves classes"]
    F --> G["5. Ordenar<br/>@AutoConfigureOrder,<br/>@AutoConfigureBefore/After"]
    G --> H["6. Registrar com a<br/>classes de configuració"]
    H --> I["7. Avaluar les condicions<br/>de cada classe i de cada @Bean"]
    I --> J{"Es compleixen?"}
    J -- Sí --> K["Beans registrats"]
    J -- No --> L["Descartada:<br/>apareix a Negative matches"]

El pas 4 mereix una nota de rendiment. Avaluar les condicions de 158 classes seria lent si calgués carregar cadascuna. Spring Boot ho evita amb dues optimitzacions: els AutoConfigurationImportFilter (en particular OnClassCondition) descarten candidats llegint únicament les metadades precalculades a META-INF/spring-autoconfigure-metadata.properties, sense carregar les classes; i el filtratge es reparteix en diversos fils. Per això una aplicació Spring Boot arrenca en dos segons i no pas en vint.

  1. Les anotacions condicionals

Una classe d'autoconfiguració porta anotacions que expressen sota quines condicions s'ha d'aplicar. Aquestes són les que veuràs un cop i un altre:

Anotació S'aplica si... Exemple real
@ConditionalOnClass La classe és al classpath @ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnMissingClass La classe no hi és @ConditionalOnMissingClass("com.altre.Motor")
@ConditionalOnBean Ja existeix un bean d'aquest tipus @ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean No existeix un bean d'aquest tipus @ConditionalOnMissingBean(ObjectMapper.class)
@ConditionalOnProperty Una propietat té cert valor @ConditionalOnProperty(name = "ciclourbana.tarifes.enabled", havingValue = "true")
@ConditionalOnWebApplication És una aplicació web @ConditionalOnWebApplication(type = SERVLET)
@ConditionalOnNotWebApplication No és una aplicació web Tasques per lots
@ConditionalOnResource Existeix un recurs @ConditionalOnResource(resources = "classpath:tarifes.json")
@ConditionalOnExpression Una expressió SpEL és certa @ConditionalOnExpression("${ciclourbana.xarxa.capacitat-minima:8} > 4")
@ConditionalOnJava La versió de Java compleix @ConditionalOnJava(JavaVersion.TWENTY_ONE)
@ConditionalOnSingleCandidate N'hi ha exactament un, o un @Primary @ConditionalOnSingleCandidate(DataSource.class)

Totes deriven de @Conditional, una anotació de Spring Framework que accepta una implementació de la interfície Condition:

public interface Condition {
    boolean matches(ConditionContext context, AnnotatedTypeMetadata metadades);
}

En pots escriure una de teva. Per exemple, una condició per a CicloUrbana que només activi un bean si la xarxa té configurada com a mínim una estació destacada:

package com.ciclourbana.comu;

import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;

/** Es compleix si hi ha com a mínim una estació destacada configurada. */
public class HiHaEstacionsDestacades implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadades) {
        String valor = context.getEnvironment()
                .getProperty("ciclourbana.xarxa.estacions-destacades");
        return valor != null && !valor.isBlank();
    }
}
@Bean
@Conditional(HiHaEstacionsDestacades.class)
public PanellDestacades panellDestacades(XarxaProperties xarxa) {
    return new PanellDestacades(xarxa.estacionsDestacades());
}

Un matís important sobre @ConditionalOnProperty, que és la més utilitzada en configuració d'aplicacions:

@ConditionalOnProperty(
        prefix = "ciclourbana.tarifes",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)   // si la propietat NO hi és, es considera complerta

matchIfMissing = true és el que permet que una funcionalitat estigui activa per defecte i es pugui desactivar explícitament. Sense ell, caldria declarar la propietat perquè funcionés res.

  1. @ConditionalOnMissingBean: «defineix el teu bean i Spring s'aparta»

De totes les condicionals, aquesta és la que defineix la filosofia de Spring Boot i val la pena entendre-la a fons.

@Bean
@ConditionalOnMissingBean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder.createXmlMapper(false).build();
}

Llegeix-ho així: «si l'usuari no ha definit el seu propi ObjectMapper, jo en poso un de raonable; si l'ha definit, callo». Aquesta és exactament la promesa de Spring Boot: valors per defecte assenyats que mai no et bloquegen.

És el que passa quan declares el teu propi bean d'un tipus autoconfigurat:

package com.ciclourbana.comu;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {

    /**
     * ObjectMapper propi de CicloUrbana. En existir aquest bean,
     * JacksonAutoConfiguration NO registrarà el seu.
     */
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .findAndRegisterModules();
    }
}

A partir d'aquest moment, l'ObjectMapper de l'autoconfiguració desapareix de l'informe d'arrencada i passa a la secció Negative matches amb el motiu: "found beans of type ObjectMapper".

L'ordre importa, i molt

Hi ha una subtilesa crítica: @ConditionalOnMissingBean s'avalua en el moment en què es processa aquesta classe de configuració, no pas al final de l'arrencada. I les autoconfiguracions es processen després de les teves classes, precisament perquè els teus beans ja estiguin registrats quan s'avaluïn. Aquest ordre és deliberat i és el que fa que el mecanisme funcioni.

D'aquí se'n segueix una regla d'or: @ConditionalOnMissingBean és per a autoconfiguracions, no pas per al codi de la teva aplicació. Si la utilitzes entre dues de les teves pròpies classes de configuració, el resultat depèn de l'ordre de processament, que no controles, i obtindràs un comportament aparentment aleatori.

Variants

// Per tipus (l'habitual; sense arguments utilitza el tipus de retorn del mètode)
@ConditionalOnMissingBean(CalculadoraTarifa.class)

// Per nom de bean
@ConditionalOnMissingBean(name = "calculadoraTarifaPersonalitzada")

// Per anotació present en algun bean
@ConditionalOnMissingBean(annotation = ServeiXarxa.class)

// Ignorant certs tipus en comprovar
@ConditionalOnMissingBean(value = CalculadoraTarifa.class, ignored = TarifaProves.class)

  1. Llegir una autoconfiguració real de Spring Boot

La millor manera d'entendre el mecanisme és llegir una classe real. Prenguem una versió resumida de JacksonAutoConfiguration, responsable que el nostre endpoint /api/v1/estacions retorni JSON:

package org.springframework.boot.autoconfigure.jackson;

@AutoConfiguration                                   // 1
@ConditionalOnClass(ObjectMapper.class)              // 2
public class JacksonAutoConfiguration {

    @Configuration(proxyBeanMethods = false)         // 3
    @ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
    static class JacksonObjectMapperConfiguration {

        @Bean
        @Primary                                     // 4
        @ConditionalOnMissingBean                    // 5
        ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
            return builder.createXmlMapper(false).build();
        }
    }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
    static class JacksonObjectMapperBuilderConfiguration {

        @Bean
        @ConditionalOnMissingBean
        Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(
                ApplicationContext context,
                List<Jackson2ObjectMapperBuilderCustomizer> personalitzadors) {  // 6

            Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
            builder.applicationContext(context);
            personalitzadors.forEach(p -> p.customize(builder));
            return builder;
        }
    }
}

Punt per punt:

  1. @AutoConfiguration (Spring Boot 3) substitueix l'antic @Configuration + @AutoConfigureAfter. És una meta-anotació que ja inclou @Configuration(proxyBeanMethods = false) i accepta els atributs before, after i beforeName/afterName.
  2. @ConditionalOnClass(ObjectMapper.class): si Jackson no és al classpath, tota la classe es descarta de cop. Aquí hi ha la resposta a "Spring Boot detecta el que hi ha al classpath": és literalment aquesta anotació.
  3. Classes internes de configuració: permeten agrupar beans amb condicions diferents dins d'una mateixa autoconfiguració.
  4. @Primary: si l'usuari defineix un altre ObjectMapper amb un altre nom, el de Spring Boot continua sent el preferit per a les injeccions sense qualificar.
  5. @ConditionalOnMissingBean: la cortesia de Spring Boot.
  6. El patró customizer: en lloc d'obligar-te a redefinir el bean sencer per canviar un detall, l'autoconfiguració recull tots els beans Jackson2ObjectMapperBuilderCustomizer del context i els aplica.

Aquest darrer punt és un patró molt útil que pots aprofitar avui mateix. Perquè CicloUrbana serialitzi les dates en format ISO sense substituir l'ObjectMapper complet:

package com.ciclourbana.comu;

import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {

    /**
     * Ajusta l'ObjectMapper autoconfigurat sense reemplaçar-lo:
     * conservem tots els valors per defecte de Spring Boot.
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer personalitzadorJson() {
        return builder -> builder
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
    }
}

Regla pràctica: quan vulguis canviar un detall d'alguna cosa autoconfigurada, busca primer si existeix una interfície *Customizer. Reemplaçar el bean sencer és l'opció nuclear i et deixa fora de totes les millores futures de Spring Boot.

  1. L'ordre entre autoconfiguracions

Algunes autoconfiguracions depenen del resultat d'unes altres. JpaRepositoriesAutoConfiguration necessita que ja existeixi un DataSource, així que s'ha d'avaluar després de DataSourceAutoConfiguration. Tres anotacions ho controlen:

Anotació Efecte
@AutoConfigureAfter(X.class) S'avalua després de X
@AutoConfigureBefore(X.class) S'avalua abans de X
@AutoConfigureOrder(n) Prioritat numèrica; menor valor, abans

A Spring Boot 3 s'expressen com a atributs de @AutoConfiguration:

@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class PersistenciaPropiaAutoConfiguration { }

És fonamental entendre per què importa l'ordre: la condició @ConditionalOnBean(DataSource.class) només es compleix si el DataSource ja està registrat quan s'avalua. Si la teva autoconfiguració s'avalués abans, la condició fallaria i el teu bean no es crearia mai, sense cap missatge d'error. Aquest és l'origen del 90 % dels "la meva autoconfiguració no funciona".

D'aquí la regla: @ConditionalOnBean gairebé sempre necessita un after que l'acompanyi.

Un advertiment addicional: @AutoConfigureAfter ordena únicament entre autoconfiguracions. Les classes de configuració de la teva aplicació sempre es processen abans que totes elles, i aquest ordre no es pot alterar (ni cal: és justament el que fa funcionar @ConditionalOnMissingBean).

  1. L'informe d'autoconfiguració

Quan un bean no apareix i no saps per què, aquesta és l'eina. Arrenca CicloUrbana amb --debug:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug

O, de manera equivalent:

debug: true
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --debug

Obtindràs un informe amb aquesta estructura:

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:
-----------------

   DispatcherServletAutoConfiguration matched:
      - @ConditionalOnClass found required class
        'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition)
      - found 'session' scope (OnWebApplicationCondition)

   JacksonAutoConfiguration#jacksonObjectMapper matched:
      - @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
        SearchStrategy: all) did not find any beans (OnBeanCondition)

Negative matches:
-----------------

   DataSourceAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
           'javax.sql.DataSource' (OnClassCondition)

   SecurityAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
           'org.springframework.security.authentication.DefaultAuthenticationEventPublisher'
           (OnClassCondition)

Exclusions:
-----------

    None

Unconditional classes:
----------------------

    org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration

Com llegir-lo:

Secció Què conté Quan la mires
Positive matches Autoconfiguracions aplicades, amb la condició que es va complir Per confirmar que alguna cosa s'ha activat i saber per què
Negative matches Descartades, amb la condició que va fallar La més útil: diu per què no tens el teu bean
Exclusions Excloses explícitament En depurar una exclusió
Unconditional classes S'apliquen sempre, sense condicions Poques vegades

Un flux de diagnòstic que funciona:

flowchart TD
    A["El meu bean no existeix"] --> B["Arrencar amb --debug"]
    B --> C["Buscar l'autoconfiguració<br/>a Negative matches"]
    C --> D{"Hi apareix?"}
    D -- Sí --> E["Llegir 'Did not match':<br/>diu la condició exacta que va fallar"]
    E --> F1["OnClassCondition:<br/>falta una dependència<br/>→ revisa el pom.xml"]
    E --> F2["OnBeanCondition:<br/>ja hi ha un bean, o en falta<br/>un del qual depèn<br/>→ revisa l'ordre"]
    E --> F3["OnPropertyCondition:<br/>falta o no coincideix<br/>una propietat"]
    D -- No --> G{"És a Exclusions?"}
    G -- Sí --> H["Treu l'exclusió"]
    G -- No --> I["La classe no és a cap<br/>AutoConfiguration.imports:<br/>falta la dependència sencera?"]

En lloc de --debug, que és molt verbós, pots activar només l'informe:

logging:
  level:
    org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLogger: DEBUG

I hi ha una alternativa encara millor quan Actuator estigui disponible (lliçó 07-01): l'endpoint /actuator/conditions retorna el mateix informe en JSON, filtrable i consultable en calent.

  1. Excloure autoconfiguracions

De vegades vols desactivar una autoconfiguració: perquè no la necessites, perquè interfereix, o perquè vols configurar aquesta part manualment. Hi ha tres formes.

A l'anotació

@SpringBootApplication(exclude = {
        DataSourceAutoConfiguration.class,
        SecurityAutoConfiguration.class
})
public class CicloUrbanaApplication { }

Per nom (si la classe no és al classpath en compilació)

@SpringBootApplication(excludeName = {
        "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
public class CicloUrbanaApplication { }

Per propietat (la més flexible)

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Forma Avantatge Inconvenient
exclude a l'anotació Amb seguretat de tipus; el compilador la valida Fixa al codi, igual a tots els entorns
excludeName Funciona sense la classe al classpath Cadena de text sense validar
spring.autoconfigure.exclude Configurable per entorn o perfil Un error d'escriptura falla l'arrencada

Un cas real i freqüent: has afegit spring-boot-starter-data-jpa per preparar-te per al mòdul 4 però encara no tens base de dades. DataSourceAutoConfiguration intenta crear un DataSource, no troba URL i l'arrencada falla amb "Failed to configure a DataSource". La solució temporal és excloure-la:

spring:
  autoconfigure:
    exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

Amb un advertiment: excloure és un pedaç. Sol ser millor treure la dependència fins que la necessitis, o —el que és correcte en aquest cas— configurar una base de dades en memòria H2, que és el que farem a la lliçó 04-02.

  1. Crear un starter propi

Arribem a la part pràctica. Empaquetarem el sistema de tarifes de Ribalta com un starter reutilitzable, de manera que una altra aplicació municipal (la de patinets, per exemple) el pugui utilitzar declarant una sola dependència.

La convenció de noms

Tipus d'starter Convenció Exemple
Oficial de Spring Boot spring-boot-starter-* spring-boot-starter-web
De tercers *-spring-boot-starter ciclourbana-tarifes-spring-boot-starter

El prefix spring-boot-starter- està reservat per als starters oficials. Un starter de tercers posa el seu nom al davant. La convenció anàloga per al mòdul d'autoconfiguració és *-spring-boot-autoconfigure.

En un starter seriós se separen dos artefactes:

flowchart LR
    A["ciclourbana-tarifes-spring-boot-autoconfigure<br/>El codi: autoconfiguració,<br/>properties, servei"] --> B["ciclourbana-tarifes-spring-boot-starter<br/>Només un pom.xml amb dependències"]
    B --> C["Aplicació municipal<br/>declara UNA dependència"]

L'starter és un artefacte sense codi: només un pom.xml que agrupa l'autoconfiguració i les dependències que aquesta necessita. Per a aquest exercici els unirem en un únic mòdul, que és l'habitual en projectes petits, però convé conèixer la separació.

El pom.xml de l'starter

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
        <relativePath/>
    </parent>

    <groupId>com.ciclourbana</groupId>
    <artifactId>ciclourbana-tarifes-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <name>CicloUrbana Tarifes Starter</name>
    <description>Càlcul de tarifes per a xarxes municipals de bicicleta compartida</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <!-- Nucli: contenidor, Environment, @ConfigurationProperties.
             NO utilitzem spring-boot-starter-web: un starter no ha d'imposar
             el tipus d'aplicació a qui el consumeix. -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <!-- Validació de les propietats de l'starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Genera les metadades per a l'autocompletat de l'IDE -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- COMPTE: sense spring-boot-maven-plugin.
                 Un starter és una LLIBRERIA, no una aplicació executable:
                 no s'ha d'empaquetar com a fat jar. -->
        </plugins>
    </build>
</project>

Els dos comentaris del final són els errors més habituals en crear un starter: dependre de spring-boot-starter-web (imposant Tomcat a qui només volia calcular tarifes) i deixar el spring-boot-maven-plugin, que genera un fat jar del qual no es poden importar classes.

Les propietats de l'starter

package com.ciclourbana.tarifes;

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;

/**
 * Configuració del càlcul de tarifes d'una xarxa de bicicleta compartida.
 * Prefix: ciclourbana.tarifes
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(

        /** Activa o desactiva el càlcul de tarifes de l'starter. */
        @DefaultValue("true") boolean enabled,

        /** Moneda en què s'expressen els imports (codi ISO 4217). */
        @DefaultValue("EUR") String moneda,

        /** Perfils de tarifa, indexats per l'identificador del tipus d'usuari. */
        @NotEmpty(message = "Cal definir com a mínim un perfil de tarifa")
        Map<String, @Valid PerfilTarifa> perTipusUsuari
) {

    /** Condicions econòmiques d'un tipus d'usuari. */
    public record PerfilTarifa(

            /** Import fix de desbloqueig. */
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueig,

            /** Import per minut d'ús. */
            @NotNull @DecimalMin("0.00") BigDecimal preuMinut,

            /** Minuts inicials sense cost. */
            @Min(0) @DefaultValue("0") int minutsGratis
    ) { }
}

El servei que aporta l'starter

package com.ciclourbana.tarifes;

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

/**
 * Calcula l'import d'un lloguer segons el perfil de tarifa configurat.
 * És una classe POJO: no porta @Service ni cap anotació de Spring,
 * perquè qui la registra com a bean és l'autoconfiguració.
 */
public class CalculadoraTarifes {

    private final TarifesProperties propietats;

    public CalculadoraTarifes(TarifesProperties propietats) {
        this.propietats = propietats;
    }

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

    public String moneda() {
        return propietats.moneda();
    }

    public BigDecimal calcular(String tipusUsuari, Duration durada) {
        TarifesProperties.PerfilTarifa perfil =
                propietats.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);
    }
}

Fixa't en el detall: la classe no porta anotacions de Spring. És una decisió deliberada. Una classe de llibreria anotada amb @Service només funcionaria si l'usuari escaneja el nostre paquet, i això no ho controlem. Qui la converteix en bean és l'autoconfiguració.

La classe d'autoconfiguració

package com.ciclourbana.tarifes;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

/**
 * Autoconfiguració de l'starter de tarifes de CicloUrbana.
 *
 * Registra una CalculadoraTarifes si:
 *  - la classe és al classpath,
 *  - la propietat ciclourbana.tarifes.enabled no és false,
 *  - i l'aplicació no ha definit ja la seva pròpia CalculadoraTarifes.
 */
@AutoConfiguration
@ConditionalOnClass(CalculadoraTarifes.class)
@ConditionalOnProperty(
        prefix = "ciclourbana.tarifes",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)               // actiu per defecte
@EnableConfigurationProperties(TarifesProperties.class)   // aquí NO hi ha scan de l'usuari
public class TarifesAutoConfiguration {

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

    @Bean
    @ConditionalOnMissingBean                // l'aplicació la pot substituir
    public CalculadoraTarifes calculadoraTarifes(TarifesProperties propietats) {
        log.info("Starter de tarifes actiu: {} perfils configurats ({})",
                propietats.perTipusUsuari().size(),
                propietats.perTipusUsuari().keySet());
        return new CalculadoraTarifes(propietats);
    }
}

Les quatre anotacions, i per què hi és cadascuna:

Anotació Per què hi és
@AutoConfiguration És una autoconfiguració, no pas una @Configuration normal. Porta proxyBeanMethods = false.
@ConditionalOnClass Comprovació defensiva: si el jar de l'starter no és sencer, no s'aplica.
@ConditionalOnProperty(matchIfMissing = true) Actiu per defecte, desactivable amb ciclourbana.tarifes.enabled=false.
@EnableConfigurationProperties Imprescindible: en un starter no hi ha @ConfigurationPropertiesScan de l'usuari que registri les nostres propietats.
@ConditionalOnMissingBean L'aplicació pot aportar la seva pròpia calculadora i guanyar.

El fitxer .imports

Sense aquest fitxer, res del que hem fet abans s'aplica. És el pas que més s'oblida:

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Amb una sola línia:

com.ciclourbana.tarifes.TarifesAutoConfiguration

Sense extensió, sense comes, sense barres invertides: un nom de classe complet per línia. Compte amb la ruta: el directori és META-INF/spring/ i el nom del fitxer és llarg i té punts. Un error d'escriptura no produeix cap error: simplement l'starter no fa res, que és la pitjor manera de fallar.

Valors per defecte de l'starter

Un starter hauria de funcionar sense cap configuració. Afegeix un fitxer de valors per defecte:

# src/main/resources/ciclourbana-tarifes-defaults.yaml
ciclourbana:
  tarifes:
    enabled: true
    moneda: EUR
    per-tipus-usuari:
      estandard:
        desbloqueig: 0.50
        preu-minut: 0.12
        minuts-gratis: 0

I a l'autoconfiguració, importa'l amb @PropertySource o —més idiomàtic a Boot 3— declara els valors per defecte amb @DefaultValue al record, que és el que ja hem fet.

Utilitzar-lo des de CicloUrbana

# Al directori de l'starter
./mvnw clean install
<!-- Al pom.xml de ciclourbana -->
<dependency>
    <groupId>com.ciclourbana</groupId>
    <artifactId>ciclourbana-tarifes-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
# application.yaml de l'aplicació
ciclourbana:
  tarifes:
    moneda: EUR
    per-tipus-usuari:
      estandard:
        desbloqueig: 0.50
        preu-minut: 0.12
      estudiant:
        preu-minut: 0.08
        minuts-gratis: 15
      jubilat:
        preu-minut: 0.05
        minuts-gratis: 30

I ja està disponible per injectar, sense @ComponentScan, sense @Import, sense res:

package com.ciclourbana.lloguers;

import com.ciclourbana.tarifes.CalculadoraTarifes;
import org.springframework.stereotype.Service;

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

@Service
public class LloguerService {

    private final CalculadoraTarifes calculadora;   // ve de l'starter

    public LloguerService(CalculadoraTarifes calculadora) {
        this.calculadora = calculadora;
    }

    public BigDecimal finalitzar(String matricula, String tipusUsuari, Duration durada) {
        return calculadora.calcular(tipusUsuari, durada);
    }
}
c.c.tarifes.TarifesAutoConfiguration : Starter de tarifes actiu: 3 perfils
    configurats ([estandard, estudiant, jubilat])

Això és exactament el que passa quan afegeixes spring-boot-starter-web. Ja no és màgia.

  1. Provar l'starter amb ApplicationContextRunner

Un starter té una particularitat en provar-lo: l'interessant no és només que el bean funcioni, sinó sota quines condicions apareix i sota quines no. Aixecar un context complet amb @SpringBootTest per a cada combinació seria lentíssim.

ApplicationContextRunner resol exactament això: crea contextos mínims, en memòria, configurables al vol, en mil·lisegons.

package com.ciclourbana.tarifes;

import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

import static org.assertj.core.api.Assertions.assertThat;

class TarifesAutoConfigurationTest {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(TarifesAutoConfiguration.class));

    @Test
    void registraLaCalculadoraAmbLaConfiguracioMinima() {
        runner.withPropertyValues(
                        "ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
                .run(context -> {
                    assertThat(context).hasSingleBean(CalculadoraTarifes.class);
                    assertThat(context).hasSingleBean(TarifesProperties.class);

                    CalculadoraTarifes calculadora = context.getBean(CalculadoraTarifes.class);
                    // 30 min * 0,12 = 3,60 (desbloqueig 0.00 per defecte)
                    assertThat(calculadora.calcular("estandard", Duration.ofMinutes(30)))
                            .isEqualByComparingTo(new BigDecimal("3.60"));
                });
    }

    @Test
    void noRegistraResSiEstaDesactivat() {
        runner.withPropertyValues(
                        "ciclourbana.tarifes.enabled=false",
                        "ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
                .run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
    }

    @Test
    void laAplicacioPotAportarLaSevaPropiaCalculadora() {
        runner.withUserConfiguration(ConfiguracioPropia.class)
                .withPropertyValues(
                        "ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
                .run(context -> {
                    assertThat(context).hasSingleBean(CalculadoraTarifes.class);
                    // Guanya la de l'usuari: @ConditionalOnMissingBean s'aparta
                    assertThat(context.getBean(CalculadoraTarifes.class))
                            .isInstanceOf(CalculadoraTarifesGratuita.class);
                });
    }

    @Test
    void fallaSiNoHiHaCapPerfilDeTarifa() {
        runner.run(context -> assertThat(context)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("Cal definir com a mínim un perfil de tarifa"));
    }

    @Test
    void respectaLaMonedaConfigurada() {
        runner.withPropertyValues(
                        "ciclourbana.tarifes.moneda=USD",
                        "ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.15")
                .run(context -> assertThat(
                        context.getBean(CalculadoraTarifes.class).moneda()).isEqualTo("USD"));
    }

    // --- Configuració de suport per al tercer test ---

    @Configuration(proxyBeanMethods = false)
    static class ConfiguracioPropia {

        @Bean
        CalculadoraTarifes calculadoraTarifes() {
            return new CalculadoraTarifesGratuita();
        }
    }

    /** Implementació de prova: la xarxa municipal en jornada de portes obertes. */
    static class CalculadoraTarifesGratuita extends CalculadoraTarifes {

        CalculadoraTarifesGratuita() {
            super(new TarifesProperties(true, "EUR",
                    java.util.Map.of("estandard", new TarifesProperties.PerfilTarifa(
                            BigDecimal.ZERO, BigDecimal.ZERO, 0))));
        }

        @Override
        public BigDecimal calcular(String tipusUsuari, Duration durada) {
            return BigDecimal.ZERO;
        }
    }
}

Els mètodes que més utilitzaràs:

Mètode Per a què
withConfiguration(AutoConfigurations.of(...)) Afegeix les autoconfiguracions a provar
withUserConfiguration(...) Simula beans definits per l'aplicació usuària
withPropertyValues("clau=valor") Fixa propietats per a aquest context
withClassLoader(new FilteredClassLoader(X.class)) Simula que una classe no és al classpath
withBean(Tipus.class, proveïdor) Registra un bean concret
run(context -> { ... }) Arrenca i executa les asseveracions

El FilteredClassLoader mereix atenció: és la manera de provar @ConditionalOnClass sense tocar el pom.xml.

@Test
void noAplicaSiFaltaLaClasseDelStarter() {
    runner.withClassLoader(new FilteredClassLoader(CalculadoraTarifes.class))
            .run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
}

I les asseveracions sobre el context, que vénen d'AssertJ integrat amb Spring Boot:

assertThat(context).hasSingleBean(CalculadoraTarifes.class);
assertThat(context).doesNotHaveBean(CalculadoraTarifes.class);
assertThat(context).getBean("calculadoraTarifes").isNotNull();
assertThat(context).hasFailed();
assertThat(context).getFailure().hasMessageContaining("...");

Aquestes cinc proves s'executen en menys d'un segon en total, perquè cada context conté tres beans i cap servidor. El mòdul 6 cobreix les proves en profunditat; aquí ApplicationContextRunner apareix perquè és l'eina específica per al que estem construint.

Errors Comuns i Consells

Oblidar el fitxer AutoConfiguration.imports. L'starter compila, s'instal·la, es declara com a dependència... i no fa absolutament res, sense ni un sol missatge d'error. És l'error número u. Verifica sempre la ruta completa: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.

Utilitzar spring.factories a Spring Boot 3. Va ser eliminat per a autoconfiguració. Mateix símptoma silenciós.

Posar @Component o @Service a les classes d'un starter. Només funcionen si l'usuari escaneja el teu paquet, cosa que no controles ni has d'exigir. Les classes d'un starter són POJO; les registra l'autoconfiguració.

Oblidar @EnableConfigurationProperties a l'autoconfiguració. En una aplicació, @ConfigurationPropertiesScan ho cobreix tot. En un starter no hi ha aquest escaneig: sense aquesta anotació, les teves propietats no són un bean i l'autoconfiguració falla amb NoSuchBeanDefinitionException.

Deixar el spring-boot-maven-plugin al pom.xml de l'starter. Genera un fat jar reempaquetat les classes del qual són a BOOT-INF/classes/ i no són importables com a llibreria. Un starter és una llibreria.

Dependre de spring-boot-starter-web des d'un starter. Imposes Tomcat i tota la pila web a qui només volia la teva funcionalitat. Depèn del mínim (spring-boot-starter) i utilitza @ConditionalOnClass per a les capacitats opcionals.

Utilitzar @ConditionalOnBean sense @AutoConfigureAfter. La condició s'avalua abans que existeixi el bean esperat, falla i la teva autoconfiguració es descarta en silenci. Van sempre juntes.

Utilitzar @ConditionalOnMissingBean al codi de l'aplicació. Depèn d'un ordre de processament que no controles. És una eina per a autoconfiguracions.

Excloure autoconfiguracions a la lleugera. Abans d'excloure, mira l'informe --debug i entén per què s'està aplicant. Sovint el problema real és una dependència sobrant al pom.xml.

Consell: quan alguna cosa no funciona, arrenca amb --debug abans de buscar a internet. L'informe d'avaluació de condicions respon la majoria de les preguntes en trenta segons, i amb la raó exacta.

Consell: busca un *Customizer abans de reemplaçar un bean autoconfigurat. Substituir el bean sencer et deixa fora de les millores futures de Spring Boot i de les integracions que en depenen.

Consell: inclou el spring-boot-configuration-processor al teu starter. Qui l'utilitzi tindrà autocompletat i documentació de les teves propietats a l'IDE. És la diferència entre un starter agradable i un que obliga a llegir el codi font.

Consell: prova el teu starter amb ApplicationContextRunner des del primer dia. Les condicions són lògica, i la lògica sense proves es trenca. Cinc proves d'un segon t'estalvien hores de depuració a l'aplicació consumidora.

Exercicis

Exercici 1: llegir l'informe d'autoconfiguració

Arrenca CicloUrbana amb --debug i respon, citant la línia concreta de l'informe: (a) per què es va aplicar DispatcherServletAutoConfiguration?; (b) per què no es va aplicar DataSourceAutoConfiguration?; (c) quantes autoconfiguracions apareixen a Positive matches i quantes a Negative matches? Després defineix el teu propi bean ObjectMapper i comprova que JacksonAutoConfiguration#jacksonObjectMapper canvia de secció.

Exercici 2: una autoconfiguració condicional dins de l'aplicació

Sense sortir del projecte ciclourbana, crea una classe AuditoriaAutoConfiguration amb el seu fitxer .imports a src/main/resources, que registri un bean RegistreAuditoria només si: la propietat ciclourbana.auditoria.enabled és true, l'aplicació és de tipus servlet, i no existeix ja un bean d'aquest tipus. Comprova amb --debug que apareix a Positive matches en activar-la i a Negative matches en desactivar-la.

Exercici 3: l'starter complet, provat

Crea el mòdul ciclourbana-tarifes-spring-boot-starter amb tot el de l'apartat 10: TarifesProperties validat, CalculadoraTarifes, TarifesAutoConfiguration i el fitxer .imports. Afegeix una capacitat nova: un recàrrec configurable (ciclourbana.tarifes.recarrec-exces) que s'apliqui als minuts que superin ciclourbana.tarifes.durada-maxima. Escriu com a mínim cinc proves amb ApplicationContextRunner que cobreixin: configuració mínima, desactivació, substitució per l'usuari, fallada de validació i el nou recàrrec.


Solucions

Solució 1

./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug > /tmp/arrencada.log 2>&1

(a) DispatcherServletAutoConfiguration es va aplicar perquè:

   DispatcherServletAutoConfiguration matched:
      - @ConditionalOnClass found required class
        'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition)
      - found 'session' scope (OnWebApplicationCondition)

La classe DispatcherServlet és al classpath perquè spring-boot-starter-web l'aporta, i l'aplicació és de tipus servlet.

(b) DataSourceAutoConfiguration no es va aplicar perquè:

   DataSourceAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
           'javax.sql.DataSource' (OnClassCondition)

Encara no tenim spring-boot-starter-data-jpa ni cap driver JDBC. Això canviarà al mòdul 4.

(c) Per comptar:

awk '/^Positive matches:/,/^Negative matches:/' /tmp/arrencada.log | grep -c " matched:"
awk '/^Negative matches:/,/^Exclusions:/' /tmp/arrencada.log | grep -c "^   [A-Z].*:$"

En un CicloUrbana amb només spring-boot-starter-web obtindràs de l'ordre de 25-30 coincidències positives i unes 120 de negatives. La xifra concreta depèn de la versió de Spring Boot, però la proporció és sempre la mateixa: la immensa majoria de les autoconfiguracions no s'apliquen. Aquest és justament el disseny: 158 candidates, i només s'activen les que tenen sentit per a les teves dependències.

I en definir l'ObjectMapper propi:

package com.ciclourbana.comu;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper().registerModule(new JavaTimeModule());
    }
}

L'entrada es mou a Negative matches:

   JacksonAutoConfiguration#jacksonObjectMapper:
      Did not match:
         - @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
           SearchStrategy: all) found beans of type
           'com.fasterxml.jackson.databind.ObjectMapper' objectMapper (OnBeanCondition)

Comentari: el missatge diu literalment "found beans of type ... objectMapper". És @ConditionalOnMissingBean funcionant: Spring Boot va veure el teu bean i es va apartar. Consell: en aquest cas concret, substituir l'ObjectMapper sencer és mala idea —perds tota la configuració de Spring Boot, inclosos els mòduls detectats automàticament. El correcte és el Jackson2ObjectMapperBuilderCustomizer de l'apartat 6.

Solució 2

package com.ciclourbana.auditoria;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Instant;

/** Registre simple d'accions sobre la xarxa. POJO, sense anotacions. */
public class RegistreAuditoria {

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

    private final String desti;

    public RegistreAuditoria(String desti) {
        this.desti = desti;
    }

    public void registrar(String accio, String detall) {
        log.info("[AUDITORIA -> {}] {} | {} | {}", desti, Instant.now(), accio, detall);
    }
}
package com.ciclourbana.auditoria;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;

@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
        prefix = "ciclourbana.auditoria",
        name = "enabled",
        havingValue = "true")     // sense matchIfMissing: desactivat per defecte
public class AuditoriaAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public RegistreAuditoria registreAuditoria(Environment entorn) {
        return new RegistreAuditoria(
                entorn.getProperty("ciclourbana.auditoria.desti", "consola"));
    }
}
# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.ciclourbana.auditoria.AuditoriaAutoConfiguration

Amb l'auditoria activada:

java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --debug --ciclourbana.auditoria.enabled=true \
  | grep -A 4 "AuditoriaAutoConfiguration"
   AuditoriaAutoConfiguration matched:
      - @ConditionalOnProperty (ciclourbana.auditoria.enabled=true) matched (OnPropertyCondition)
      - found 'session' scope (OnWebApplicationCondition)

   AuditoriaAutoConfiguration#registreAuditoria matched:
      - @ConditionalOnMissingBean (types: com.ciclourbana.auditoria.RegistreAuditoria;
        SearchStrategy: all) did not find any beans (OnBeanCondition)

I sense activar-la:

   AuditoriaAutoConfiguration:
      Did not match:
         - @ConditionalOnProperty (ciclourbana.auditoria.enabled) did not find
           property 'enabled' (OnPropertyCondition)

Comentari: el detall interessant és que una autoconfiguració dins de la mateixa aplicació funciona exactament igual que una d'un starter extern. És un patró útil per a funcionalitats opcionals dins d'un monòlit.

Error freqüent: crear el fitxer .imports a src/main/java en lloc de src/main/resources. Maven no el copia al jar i l'autoconfiguració desapareix sense avisar. Consell: verifica sempre amb unzip -l target/*.jar | grep imports que el fitxer ha arribat a l'artefacte.

Solució 3

Les propietats ampliades amb el recàrrec:

package com.ciclourbana.tarifes;

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.boot.convert.DurationMin;
import org.springframework.validation.annotation.Validated;

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

@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(

        /** Activa o desactiva el càlcul de tarifes de l'starter. */
        @DefaultValue("true") boolean enabled,

        /** Moneda dels imports (codi ISO 4217). */
        @DefaultValue("EUR") String moneda,

        /** Durada a partir de la qual s'aplica el recàrrec per excés. */
        @NotNull @DurationMin(minutes = 5) @DefaultValue("2h") Duration duradaMaxima,

        /** Import addicional per cada minut que excedeixi la durada màxima. */
        @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal recarrecExces,

        /** Perfils de tarifa per tipus d'usuari. */
        @NotEmpty(message = "Cal definir com a mínim un perfil de tarifa")
        Map<String, @Valid PerfilTarifa> perTipusUsuari
) {

    public record PerfilTarifa(
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueig,
            @NotNull @DecimalMin("0.00") BigDecimal preuMinut,
            @Min(0) @DefaultValue("0") int minutsGratis
    ) { }
}

La calculadora amb el recàrrec:

package com.ciclourbana.tarifes;

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

public class CalculadoraTarifes {

    private final TarifesProperties propietats;

    public CalculadoraTarifes(TarifesProperties propietats) {
        this.propietats = propietats;
    }

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

    public String moneda() {
        return propietats.moneda();
    }

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

        long minuts = durada.toMinutes();
        long facturables = Math.max(0, minuts - perfil.minutsGratis());

        BigDecimal importTotal = perfil.desbloqueig()
                .add(perfil.preuMinut().multiply(BigDecimal.valueOf(facturables)));

        // Recàrrec per excés sobre la durada màxima
        long minutsExces = Math.max(0, minuts - propietats.duradaMaxima().toMinutes());
        if (minutsExces > 0) {
            importTotal = importTotal.add(
                    propietats.recarrecExces().multiply(BigDecimal.valueOf(minutsExces)));
        }

        return importTotal.setScale(2, RoundingMode.HALF_UP);
    }
}

I les proves:

package com.ciclourbana.tarifes;

import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

import static org.assertj.core.api.Assertions.assertThat;

class TarifesAutoConfigurationTest {

    private static final String[] CONFIG_MINIMA = {
            "ciclourbana.tarifes.per-tipus-usuari.estandard.desbloqueig=0.50",
            "ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12"
    };

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(TarifesAutoConfiguration.class));

    @Test
    void registraLaCalculadoraAmbLaConfiguracioMinima() {
        runner.withPropertyValues(CONFIG_MINIMA).run(context -> {
            assertThat(context).hasSingleBean(CalculadoraTarifes.class);
            // 0,50 + 30 * 0,12 = 4,10
            assertThat(context.getBean(CalculadoraTarifes.class)
                    .calcular("estandard", Duration.ofMinutes(30)))
                    .isEqualByComparingTo(new BigDecimal("4.10"));
        });
    }

    @Test
    void noRegistraResSiEstaDesactivat() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withPropertyValues("ciclourbana.tarifes.enabled=false")
                .run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
    }

    @Test
    void laAplicacioPotAportarLaSevaPropiaCalculadora() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withUserConfiguration(ConfiguracioPropia.class)
                .run(context -> {
                    assertThat(context).hasSingleBean(CalculadoraTarifes.class);
                    assertThat(context.getBean(CalculadoraTarifes.class)
                            .calcular("estandard", Duration.ofHours(5)))
                            .isEqualByComparingTo(BigDecimal.ZERO);
                });
    }

    @Test
    void fallaSiNoHiHaCapPerfilDeTarifa() {
        runner.run(context -> assertThat(context)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("Cal definir com a mínim un perfil de tarifa"));
    }

    @Test
    void aplicaElRecarrecPerExces() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withPropertyValues(
                        "ciclourbana.tarifes.durada-maxima=2h",
                        "ciclourbana.tarifes.recarrec-exces=0.30")
                .run(context -> {
                    CalculadoraTarifes calculadora = context.getBean(CalculadoraTarifes.class);

                    // 90 min: per sota del màxim, sense recàrrec
                    // 0,50 + 90 * 0,12 = 11,30
                    assertThat(calculadora.calcular("estandard", Duration.ofMinutes(90)))
                            .isEqualByComparingTo(new BigDecimal("11.30"));

                    // 150 min: 30 minuts d'excés
                    // 0,50 + 150 * 0,12 + 30 * 0,30 = 0,50 + 18,00 + 9,00 = 27,50
                    assertThat(calculadora.calcular("estandard", Duration.ofMinutes(150)))
                            .isEqualByComparingTo(new BigDecimal("27.50"));
                });
    }

    @Test
    void noAplicaSiFaltaLaClasseDelStarter() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withClassLoader(new FilteredClassLoader(CalculadoraTarifes.class))
                .run(context -> assertThat(context)
                        .doesNotHaveBean(CalculadoraTarifes.class));
    }

    @Configuration(proxyBeanMethods = false)
    static class ConfiguracioPropia {

        @Bean
        CalculadoraTarifes calculadoraTarifes() {
            TarifesProperties gratis = new TarifesProperties(
                    true, "EUR", Duration.ofHours(24), BigDecimal.ZERO,
                    Map.of("estandard", new TarifesProperties.PerfilTarifa(
                            BigDecimal.ZERO, BigDecimal.ZERO, 0)));
            return new CalculadoraTarifes(gratis);
        }
    }
}
./mvnw test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time:  4.812 s

Comentari: sis proves que aixequen sis contextos diferents en menys d'un segon d'execució efectiva. Compara-ho amb el que costaria fer el mateix amb @SpringBootTest, que arrencaria Tomcat sis vegades.

Detall sobre les asseveracions: s'utilitza isEqualByComparingTo i no pas isEqualTo. A BigDecimal, new BigDecimal("4.10").equals(new BigDecimal("4.1")) és false, perquè equals compara també l'escala. És un error clàssic que produeix fallades de prova desconcertants; isEqualByComparingTo utilitza compareTo i compara només el valor numèric.

Consell final sobre el disseny de l'starter: fixa't que el recàrrec s'ha afegit sense trencar ningú. recarrecExces té @DefaultValue("0.00") i duradaMaxima té @DefaultValue("2h"), així que una aplicació que ja utilitzava la versió 1.0.0 continua obtenint exactament els mateixos imports després d'actualitzar. Aquesta és la disciplina que fa usable un starter: tota propietat nova arriba amb un valor per defecte que preserva el comportament anterior.

Conclusió

Amb això es tanca el mòdul 2 i, amb ell, la caixa negra del contenidor. Saps que @EnableAutoConfiguration és simplement @Import d'un ImportSelector que calcula quines configuracions cal carregar, i que la llista de candidates no és màgia sinó un fitxer de text —META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports— amb 158 noms de classe, un per línia, substitut del spring.factories que Spring Boot 3 va eliminar. Coneixes el recorregut complet de l'AutoConfigurationImportSelector, incloses les optimitzacions que fan que avaluar 158 candidates costi mil·lisegons. Domines les anotacions condicionals i entens per què @ConditionalOnMissingBean és el cor de la filosofia de Spring Boot: valors per defecte assenyats que s'aparten tan bon punt tu prens el control. Has llegit una autoconfiguració real de Spring Boot línia a línia i has descobert el patró customizer, que gairebé sempre és millor que reemplaçar un bean sencer. Saps per què @ConditionalOnBean necessita companyia de @AutoConfigureAfter. I sobretot saps depurar: arrencar amb --debug, anar a Negative matches i llegir la condició exacta que va fallar, que és la resposta a la pregunta més frustrant del desenvolupador de Spring. Per acabar, has construït un starter complet, ciclourbana-tarifes-spring-boot-starter, amb la seva autoconfiguració condicional, les seves propietats tipades i validades, el seu fitxer .imports i sis proves amb ApplicationContextRunner que verifiquen no només que el bean funciona, sinó sota quines condicions apareix i sota quines s'aparta.

Mira enrere un moment. En començar el mòdul, CicloUrbana era una classe principal, un record, un controlador i un magatzem en memòria que ho feia tot, amb anotacions copiades per imitació. Ara té capes ben separades —EstacioController, EstacioService, EstacioRepositori amb la seva implementació en memòria—, un sistema de tarifes extensible per configuració, una memòria cau amb cicle de vida gestionat, propietats tipades i validades que fan fallar l'arrencada si algú configura un preu negatiu, i un starter propi publicable. I no queda ni una sola anotació al projecte que no sàpigues explicar.

Ha arribat el moment de tornar a la superfície. El mòdul 3, Construint Serveis Web RESTful, surt del contenidor i entra a l'API que veuran els ciutadans de Ribalta: què significa REST de debò i què no, com dissenyar els recursos i les URL de CicloUrbana, com escriure controladors complets amb @GetMapping, @PostMapping, @PutMapping i @DeleteMapping, com rebre i validar dades d'entrada, com separar les entitats dels DTO que s'exposen a l'exterior, com convertir una excepció en una resposta HTTP correcta i ben formada, i com documentar-ho tot amb OpenAPI perquè altres equips s'hi puguin integrar. El nostre únic endpoint, GET /api/v1/estacions, està a punt de convertir-se en una API completa.

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