Desde la primera lección venimos repitiendo que Spring Boot "detecta lo que hay en el classpath y registra los beans apropiados". Añadimos spring-boot-starter-web al pom.xml y aparecen Tomcat, Jackson, el DispatcherServlet y una veintena de piezas más sin escribir una sola línea de configuración. Lo hemos aceptado como una caja negra el módulo entero. Ahora la abrimos del todo. Veremos qué hace exactamente @EnableAutoConfiguration, dónde está escrita la lista de candidatos, cómo se decide condición a condición qué se registra y qué no, por qué basta con declarar tu propio bean para que Spring Boot se aparte, y cómo leer el informe de autoconfiguración para responder a la pregunta más frustrante del desarrollador de Spring: «¿por qué no se ha creado mi bean?». Y terminaremos construyendo un starter propio, ciclourbana-tarifas-spring-boot-starter, empaquetando el sistema de tarifas de Ribalta para que otra aplicación pueda usarlo con solo declarar una dependencia.

Contenido

  1. Qué hace realmente @EnableAutoConfiguration
  2. El fichero AutoConfiguration.imports
  3. El AutoConfigurationImportSelector paso a paso
  4. Las anotaciones condicionales
  5. @ConditionalOnMissingBean: «define tu bean y Spring se aparta»
  6. Leer una autoconfiguración real de Spring Boot
  7. El orden entre autoconfiguraciones
  8. El informe de autoconfiguración
  9. Excluir autoconfiguraciones
  10. Crear un starter propio
  11. Probar el starter con ApplicationContextRunner
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. Qué hace realmente @EnableAutoConfiguration

En la lección 02-01 desmontamos @SpringBootApplication en tres anotaciones y dejamos una pendiente. Su declaración real es:

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

    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

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

    String[] excludeName() default {};
}

Solo hay dos piezas:

  • @AutoConfigurationPackage registra el paquete de la clase anotada (com.ciclourbana) como "paquete de autoconfiguración". Otras autoconfiguraciones lo consultan para saber dónde buscar: es así como Spring Data JPA encontrará nuestras entidades en el módulo 4 sin que le digamos dónde están.
  • @Import(AutoConfigurationImportSelector.class) es el motor. @Import es una anotación de Spring Framework que añade clases de configuración al contexto; cuando lo que importas es un ImportSelector, Spring le pregunta en tiempo de arranque qué clases debe importar. Es decir: la lista no está escrita, se calcula.

La conclusión importante: la autoconfiguración no es un mecanismo especial ni privilegiado. Es @Import con una clase que decide dinámicamente qué importar. Todo lo demás es la lógica de esa decisión.

  1. El fichero AutoConfiguration.imports

¿De dónde saca el selector la lista de candidatos? De un fichero de texto plano que cada jar puede aportar:

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

Ábrelo en tu propio proyecto. Está dentro 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
...

Cuenta cuántas hay:

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

Ciento cincuenta y ocho clases candidatas. Eso es todo el "misterio" de la autoconfiguración: una lista de nombres de clase en un fichero de texto. Lo que hace que solo unas pocas se apliquen en CicloUrbana son las condiciones que veremos en el apartado 4.

El antecesor: spring.factories

Hasta Spring Boot 2.7, la lista vivía en META-INF/spring.factories, un fichero de propiedades que servía para muchas cosas a la vez:

# Formato antiguo (Spring Boot <= 2.7). Ya NO se usa para autoconfiguración.
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, obligatorio en 3.x)
Ubicación META-INF/spring.factories META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Formato Propiedades con \ de continuación Un nombre de clase por línea
Propenso a errores Sí (las barras invertidas) No
Coste de lectura Alto: se procesa todo el fichero Menor
Vigencia para autoconfiguración Eliminado en Spring Boot 3 El actual

spring.factories sigue existiendo para otros puntos de extensión (ApplicationListener, EnvironmentPostProcessor...), pero no para autoconfiguración. Si migras un starter antiguo a Boot 3 y no cambias el fichero, tus autoconfiguraciones simplemente no se aplican, sin ningún error. Es una de las trampas más habituales de la migración.

  1. El AutoConfigurationImportSelector paso a paso

Este es el algoritmo completo, del arranque a los beans registrados:

flowchart TD
    A["@EnableAutoConfiguration"] --> B["AutoConfigurationImportSelector"]
    B --> C["1. Leer TODOS los ficheros<br/>AutoConfiguration.imports del classpath<br/>(158 clases en spring-boot-autoconfigure<br/>+ las de cada starter propio)"]
    C --> D["2. Eliminar duplicados"]
    D --> E["3. Quitar las excluidas<br/>exclude=, spring.autoconfigure.exclude"]
    E --> F["4. Aplicar los AutoConfigurationImportFilter<br/>OnClassCondition: descarta rápido<br/>lo que no tiene sus clases"]
    F --> G["5. Ordenar<br/>@AutoConfigureOrder,<br/>@AutoConfigureBefore/After"]
    G --> H["6. Registrar como<br/>clases de configuración"]
    H --> I["7. Evaluar las condiciones<br/>de cada clase y de cada @Bean"]
    I --> J{"¿Se cumplen?"}
    J -- Sí --> K["Beans registrados"]
    J -- No --> L["Descartada:<br/>aparece en Negative matches"]

El paso 4 merece una nota de rendimiento. Evaluar las condiciones de 158 clases sería lento si hubiera que cargar cada una. Spring Boot lo evita con dos optimizaciones: los AutoConfigurationImportFilter (en particular OnClassCondition) descartan candidatos leyendo únicamente los metadatos precalculados en META-INF/spring-autoconfigure-metadata.properties, sin cargar las clases; y el filtrado se reparte en varios hilos. Por eso una aplicación Spring Boot arranca en dos segundos y no en veinte.

  1. Las anotaciones condicionales

Una clase de autoconfiguración lleva anotaciones que expresan bajo qué condiciones debe aplicarse. Estas son las que verás una y otra vez:

Anotación Se aplica si... Ejemplo real
@ConditionalOnClass La clase está en el classpath @ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnMissingClass La clase no está @ConditionalOnMissingClass("com.otro.Motor")
@ConditionalOnBean Ya existe un bean de ese tipo @ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean No existe un bean de ese tipo @ConditionalOnMissingBean(ObjectMapper.class)
@ConditionalOnProperty Una propiedad tiene cierto valor @ConditionalOnProperty(name = "ciclourbana.tarifas.enabled", havingValue = "true")
@ConditionalOnWebApplication Es una aplicación web @ConditionalOnWebApplication(type = SERVLET)
@ConditionalOnNotWebApplication No es una aplicación web Tareas por lotes
@ConditionalOnResource Existe un recurso @ConditionalOnResource(resources = "classpath:tarifas.json")
@ConditionalOnExpression Una expresión SpEL es cierta @ConditionalOnExpression("${ciclourbana.red.capacidad-minima:8} > 4")
@ConditionalOnJava La versión de Java cumple @ConditionalOnJava(JavaVersion.TWENTY_ONE)
@ConditionalOnSingleCandidate Hay exactamente uno, o uno @Primary @ConditionalOnSingleCandidate(DataSource.class)

Todas derivan de @Conditional, una anotación de Spring Framework que acepta una implementación de la interfaz Condition:

public interface Condition {
    boolean matches(ConditionContext contexto, AnnotatedTypeMetadata metadatos);
}

Puedes escribir la tuya. Por ejemplo, una condición para CicloUrbana que solo active un bean si la red tiene configurada al menos una estación destacada:

package com.ciclourbana.comun;

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

/** Se cumple si hay al menos una estación destacada configurada. */
public class HayEstacionesDestacadas implements Condition {

    @Override
    public boolean matches(ConditionContext contexto, AnnotatedTypeMetadata metadatos) {
        String valor = contexto.getEnvironment()
                .getProperty("ciclourbana.red.estaciones-destacadas");
        return valor != null && !valor.isBlank();
    }
}
@Bean
@Conditional(HayEstacionesDestacadas.class)
public PanelDestacadas panelDestacadas(RedProperties red) {
    return new PanelDestacadas(red.estacionesDestacadas());
}

Un matiz importante sobre @ConditionalOnProperty, que es la más usada en configuración de aplicaciones:

@ConditionalOnProperty(
        prefix = "ciclourbana.tarifas",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)   // si la propiedad NO está, se considera cumplida

matchIfMissing = true es lo que permite que una funcionalidad esté activa por defecto y se pueda desactivar explícitamente. Sin él, habría que declarar la propiedad para que funcionara nada.

  1. @ConditionalOnMissingBean: «define tu bean y Spring se aparta»

De todas las condicionales, esta es la que define la filosofía de Spring Boot y merece entenderla a fondo.

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

Léelo así: «si el usuario no ha definido su propio ObjectMapper, yo pongo uno razonable; si lo ha definido, me callo». Esa es exactamente la promesa de Spring Boot: valores por defecto sensatos que nunca te bloquean.

Es lo que ocurre cuando declaras tu propio bean de un tipo autoconfigurado:

package com.ciclourbana.comun;

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 ConfiguracionJson {

    /**
     * ObjectMapper propio de CicloUrbana. Al existir este bean,
     * JacksonAutoConfiguration NO registrará el suyo.
     */
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .findAndRegisterModules();
    }
}

A partir de ese momento, el ObjectMapper de la autoconfiguración desaparece del informe de arranque y pasa a la sección Negative matches con el motivo: "found beans of type ObjectMapper".

El orden importa, y mucho

Hay una sutileza crítica: @ConditionalOnMissingBean se evalúa en el momento en que se procesa esa clase de configuración, no al final del arranque. Y las autoconfiguraciones se procesan después de tus clases, precisamente para que tus beans ya estén registrados cuando se evalúen. Ese orden es deliberado y es lo que hace que el mecanismo funcione.

De ahí se sigue una regla de oro: @ConditionalOnMissingBean es para autoconfiguraciones, no para el código de tu aplicación. Si la usas entre dos de tus propias clases de configuración, el resultado depende del orden de procesamiento, que no controlas, y obtendrás un comportamiento aparentemente aleatorio.

Variantes

// Por tipo (lo habitual; sin argumentos usa el tipo de retorno del método)
@ConditionalOnMissingBean(CalculadoraTarifa.class)

// Por nombre de bean
@ConditionalOnMissingBean(name = "calculadoraTarifaPersonalizada")

// Por anotación presente en algún bean
@ConditionalOnMissingBean(annotation = ServicioRed.class)

// Ignorando ciertos tipos al comprobar
@ConditionalOnMissingBean(value = CalculadoraTarifa.class, ignored = TarifaPruebas.class)

  1. Leer una autoconfiguración real de Spring Boot

La mejor forma de entender el mecanismo es leer una clase real. Tomemos una versión resumida de JacksonAutoConfiguration, responsable de que nuestro endpoint /api/v1/estaciones devuelva 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 contexto,
                List<Jackson2ObjectMapperBuilderCustomizer> personalizadores) {  // 6

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

Punto por punto:

  1. @AutoConfiguration (Spring Boot 3) sustituye al antiguo @Configuration + @AutoConfigureAfter. Es una meta-anotación que ya incluye @Configuration(proxyBeanMethods = false) y acepta los atributos before, after y beforeName/afterName.
  2. @ConditionalOnClass(ObjectMapper.class): si Jackson no está en el classpath, toda la clase se descarta de golpe. Aquí está la respuesta a "Spring Boot detecta lo que hay en el classpath": es literalmente esta anotación.
  3. Clases internas de configuración: permiten agrupar beans con condiciones distintas dentro de una misma autoconfiguración.
  4. @Primary: si el usuario define otro ObjectMapper con otro nombre, el de Spring Boot sigue siendo el preferido para las inyecciones sin cualificar.
  5. @ConditionalOnMissingBean: la cortesía de Spring Boot.
  6. El patrón customizer: en lugar de obligarte a redefinir el bean entero para cambiar un detalle, la autoconfiguración recoge todos los beans Jackson2ObjectMapperBuilderCustomizer del contexto y los aplica.

Ese último punto es un patrón muy útil que puedes aprovechar hoy mismo. Para que CicloUrbana serialice las fechas en formato ISO sin sustituir el ObjectMapper completo:

package com.ciclourbana.comun;

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 ConfiguracionJson {

    /**
     * Ajusta el ObjectMapper autoconfigurado sin reemplazarlo:
     * conservamos todos los valores por defecto de Spring Boot.
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer personalizadorJson() {
        return builder -> builder
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
    }
}

Regla práctica: cuando quieras cambiar un detalle de algo autoconfigurado, busca primero si existe una interfaz *Customizer. Reemplazar el bean entero es la opción nuclear y te deja fuera de todas las mejoras futuras de Spring Boot.

  1. El orden entre autoconfiguraciones

Algunas autoconfiguraciones dependen del resultado de otras. JpaRepositoriesAutoConfiguration necesita que ya exista un DataSource, así que debe evaluarse después de DataSourceAutoConfiguration. Tres anotaciones lo controlan:

Anotación Efecto
@AutoConfigureAfter(X.class) Se evalúa después de X
@AutoConfigureBefore(X.class) Se evalúa antes de X
@AutoConfigureOrder(n) Prioridad numérica; menor valor, antes

En Spring Boot 3 se expresan como atributos de @AutoConfiguration:

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

Es fundamental entender por qué importa el orden: la condición @ConditionalOnBean(DataSource.class) solo se cumple si el DataSource ya está registrado cuando se evalúa. Si tu autoconfiguración se evaluara antes, la condición fallaría y tu bean nunca se crearía, sin ningún mensaje de error. Este es el origen del 90 % de los "mi autoconfiguración no funciona".

De ahí la regla: @ConditionalOnBean casi siempre necesita un after que lo acompañe.

Una advertencia adicional: @AutoConfigureAfter ordena únicamente entre autoconfiguraciones. Las clases de configuración de tu aplicación siempre se procesan antes que todas ellas, y ese orden no se puede alterar (ni hace falta: es justo lo que hace funcionar a @ConditionalOnMissingBean).

  1. El informe de autoconfiguración

Cuando un bean no aparece y no sabes por qué, esta es la herramienta. Arranca CicloUrbana con --debug:

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

O, de forma equivalente:

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

Obtendrás un informe con esta 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

Cómo leerlo:

Sección Qué contiene Cuándo la miras
Positive matches Autoconfiguraciones aplicadas, con la condición que se cumplió Para confirmar que algo se activó y saber por qué
Negative matches Descartadas, con la condición que falló La más útil: dice por qué no tienes tu bean
Exclusions Excluidas explícitamente Al depurar una exclusión
Unconditional classes Se aplican siempre, sin condiciones Rara vez

Un flujo de diagnóstico que funciona:

flowchart TD
    A["Mi bean no existe"] --> B["Arrancar con --debug"]
    B --> C["Buscar la autoconfiguración<br/>en Negative matches"]
    C --> D{"¿Aparece ahí?"}
    D -- Sí --> E["Leer 'Did not match':<br/>dice la condición exacta que falló"]
    E --> F1["OnClassCondition:<br/>falta una dependencia<br/>→ revisa el pom.xml"]
    E --> F2["OnBeanCondition:<br/>ya hay un bean, o falta<br/>uno del que depende<br/>→ revisa el orden"]
    E --> F3["OnPropertyCondition:<br/>falta o no coincide<br/>una propiedad"]
    D -- No --> G{"¿Está en Exclusions?"}
    G -- Sí --> H["Quita la exclusión"]
    G -- No --> I["La clase no está en ningún<br/>AutoConfiguration.imports:<br/>¿falta la dependencia entera?"]

En lugar de --debug, que es muy verboso, puedes activar solo el informe:

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

Y hay una alternativa aún mejor cuando Actuator esté disponible (lección 07-01): el endpoint /actuator/conditions devuelve el mismo informe en JSON, filtrable y consultable en caliente.

  1. Excluir autoconfiguraciones

A veces quieres desactivar una autoconfiguración: porque no la necesitas, porque interfiere, o porque quieres configurar esa parte manualmente. Hay tres formas.

En la anotación

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

Por nombre (si la clase no está en el classpath en compilación)

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

Por propiedad (la más flexible)

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Forma Ventaja Inconveniente
exclude en la anotación Con seguridad de tipos; el compilador la valida Fija en el código, igual en todos los entornos
excludeName Funciona sin la clase en el classpath Cadena de texto sin validar
spring.autoconfigure.exclude Configurable por entorno o perfil Un error de escritura falla el arranque

Un caso real y frecuente: has añadido spring-boot-starter-data-jpa para prepararte para el módulo 4 pero todavía no tienes base de datos. DataSourceAutoConfiguration intenta crear un DataSource, no encuentra URL y el arranque falla con "Failed to configure a DataSource". La solución temporal es excluirla:

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

Con una advertencia: excluir es un parche. Suele ser mejor quitar la dependencia hasta que la necesites, o —lo correcto en este caso— configurar una base de datos en memoria H2, que es lo que haremos en la lección 04-02.

  1. Crear un starter propio

Llegamos a la parte práctica. Vamos a empaquetar el sistema de tarifas de Ribalta como un starter reutilizable, de modo que otra aplicación municipal (la de patinetes, por ejemplo) pueda usarlo declarando una sola dependencia.

La convención de nombres

Tipo de starter Convención Ejemplo
Oficial de Spring Boot spring-boot-starter-* spring-boot-starter-web
De terceros *-spring-boot-starter ciclourbana-tarifas-spring-boot-starter

El prefijo spring-boot-starter- está reservado para los starters oficiales. Un starter de terceros pone su nombre delante. La convención análoga para el módulo de autoconfiguración es *-spring-boot-autoconfigure.

En un starter serio se separan dos artefactos:

flowchart LR
    A["ciclourbana-tarifas-spring-boot-autoconfigure<br/>El código: autoconfiguración,<br/>properties, servicio"] --> B["ciclourbana-tarifas-spring-boot-starter<br/>Solo un pom.xml con dependencias"]
    B --> C["Aplicación municipal<br/>declara UNA dependencia"]

El starter es un artefacto sin código: solo un pom.xml que agrupa la autoconfiguración y las dependencias que esta necesita. Para este ejercicio los uniremos en un único módulo, que es lo habitual en proyectos pequeños, pero conviene conocer la separación.

El pom.xml del 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-tarifas-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <name>CicloUrbana Tarifas Starter</name>
    <description>Cálculo de tarifas para redes municipales de bicicleta compartida</description>

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

    <dependencies>
        <!-- Núcleo: contenedor, Environment, @ConfigurationProperties.
             NO usamos spring-boot-starter-web: un starter no debe imponer
             el tipo de aplicación a quien lo consume. -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <!-- Validación de las propiedades del starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Genera los metadatos para el autocompletado del 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>
            <!-- OJO: sin spring-boot-maven-plugin.
                 Un starter es una LIBRERÍA, no una aplicación ejecutable:
                 no debe empaquetarse como fat jar. -->
        </plugins>
    </build>
</project>

Los dos comentarios del final son los errores más habituales al crear un starter: depender de spring-boot-starter-web (imponiendo Tomcat a quien solo quería calcular tarifas) y dejar el spring-boot-maven-plugin, que genera un fat jar del que no se pueden importar clases.

Las propiedades del starter

package com.ciclourbana.tarifas;

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ón del cálculo de tarifas de una red de bicicleta compartida.
 * Prefijo: ciclourbana.tarifas
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifas")
public record TarifasProperties(

        /** Activa o desactiva el cálculo de tarifas del starter. */
        @DefaultValue("true") boolean enabled,

        /** Moneda en la que se expresan los importes (código ISO 4217). */
        @DefaultValue("EUR") String moneda,

        /** Perfiles de tarifa, indexados por el identificador del tipo de usuario. */
        @NotEmpty(message = "Debe definirse al menos un perfil de tarifa")
        Map<String, @Valid PerfilTarifa> porTipoUsuario
) {

    /** Condiciones económicas de un tipo de usuario. */
    public record PerfilTarifa(

            /** Importe fijo de desbloqueo. */
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueo,

            /** Importe por minuto de uso. */
            @NotNull @DecimalMin("0.00") BigDecimal precioMinuto,

            /** Minutos iniciales sin coste. */
            @Min(0) @DefaultValue("0") int minutosGratis
    ) { }
}

El servicio que aporta el starter

package com.ciclourbana.tarifas;

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

/**
 * Calcula el importe de un alquiler según el perfil de tarifa configurado.
 * Es una clase POJO: no lleva @Service ni ninguna anotación de Spring,
 * porque quien la registra como bean es la autoconfiguración.
 */
public class CalculadoraTarifas {

    private final TarifasProperties propiedades;

    public CalculadoraTarifas(TarifasProperties propiedades) {
        this.propiedades = propiedades;
    }

    public Set<String> tiposDisponibles() {
        return propiedades.porTipoUsuario().keySet();
    }

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

    public BigDecimal calcular(String tipoUsuario, Duration duracion) {
        TarifasProperties.PerfilTarifa perfil =
                propiedades.porTipoUsuario().get(tipoUsuario);

        if (perfil == null) {
            throw new IllegalArgumentException("Tipo de usuario desconocido: " + tipoUsuario
                    + ". Disponibles: " + tiposDisponibles());
        }

        long facturables = Math.max(0, duracion.toMinutes() - perfil.minutosGratis());
        return perfil.desbloqueo()
                .add(perfil.precioMinuto().multiply(BigDecimal.valueOf(facturables)))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Fíjate en el detalle: la clase no lleva anotaciones de Spring. Es una decisión deliberada. Una clase de librería anotada con @Service solo funcionaría si el usuario escanea nuestro paquete, y eso no lo controlamos. Quien la convierte en bean es la autoconfiguración.

La clase de autoconfiguración

package com.ciclourbana.tarifas;

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ón del starter de tarifas de CicloUrbana.
 *
 * Registra una CalculadoraTarifas si:
 *  - la clase está en el classpath,
 *  - la propiedad ciclourbana.tarifas.enabled no es false,
 *  - y la aplicación no ha definido ya su propia CalculadoraTarifas.
 */
@AutoConfiguration
@ConditionalOnClass(CalculadoraTarifas.class)
@ConditionalOnProperty(
        prefix = "ciclourbana.tarifas",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)               // activo por defecto
@EnableConfigurationProperties(TarifasProperties.class)   // aquí NO hay scan del usuario
public class TarifasAutoConfiguration {

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

    @Bean
    @ConditionalOnMissingBean                // la aplicación puede sustituirla
    public CalculadoraTarifas calculadoraTarifas(TarifasProperties propiedades) {
        log.info("Starter de tarifas activo: {} perfiles configurados ({})",
                propiedades.porTipoUsuario().size(),
                propiedades.porTipoUsuario().keySet());
        return new CalculadoraTarifas(propiedades);
    }
}

Las cuatro anotaciones, y por qué cada una:

Anotación Por qué está
@AutoConfiguration Es una autoconfiguración, no una @Configuration normal. Trae proxyBeanMethods = false.
@ConditionalOnClass Comprobación defensiva: si el jar del starter no está entero, no se aplica.
@ConditionalOnProperty(matchIfMissing = true) Activo por defecto, desactivable con ciclourbana.tarifas.enabled=false.
@EnableConfigurationProperties Imprescindible: en un starter no hay @ConfigurationPropertiesScan del usuario que registre nuestras propiedades.
@ConditionalOnMissingBean La aplicación puede aportar su propia calculadora y ganar.

El fichero .imports

Sin este fichero, nada de lo anterior se aplica. Es el paso que más se olvida:

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

Con una sola línea:

com.ciclourbana.tarifas.TarifasAutoConfiguration

Sin extensión, sin comas, sin barras invertidas: un nombre de clase completo por línea. Cuidado con la ruta: el directorio es META-INF/spring/ y el nombre del fichero es largo y tiene puntos. Un error de escritura no produce ningún error: simplemente el starter no hace nada, que es la peor forma de fallar.

Valores por defecto del starter

Un starter debería funcionar sin configuración alguna. Añade un fichero de valores por defecto:

# src/main/resources/ciclourbana-tarifas-defaults.yaml
ciclourbana:
  tarifas:
    enabled: true
    moneda: EUR
    por-tipo-usuario:
      estandar:
        desbloqueo: 0.50
        precio-minuto: 0.12
        minutos-gratis: 0

Y en la autoconfiguración, impórtalo con @PropertySource o —más idiomático en Boot 3— declara los valores por defecto con @DefaultValue en el record, que es lo que ya hemos hecho.

Usarlo desde CicloUrbana

# En el directorio del starter
./mvnw clean install
<!-- En el pom.xml de ciclourbana -->
<dependency>
    <groupId>com.ciclourbana</groupId>
    <artifactId>ciclourbana-tarifas-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
# application.yaml de la aplicación
ciclourbana:
  tarifas:
    moneda: EUR
    por-tipo-usuario:
      estandar:
        desbloqueo: 0.50
        precio-minuto: 0.12
      estudiante:
        precio-minuto: 0.08
        minutos-gratis: 15
      jubilado:
        precio-minuto: 0.05
        minutos-gratis: 30

Y ya está disponible para inyectar, sin @ComponentScan, sin @Import, sin nada:

package com.ciclourbana.alquileres;

import com.ciclourbana.tarifas.CalculadoraTarifas;
import org.springframework.stereotype.Service;

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

@Service
public class AlquilerService {

    private final CalculadoraTarifas calculadora;   // viene del starter

    public AlquilerService(CalculadoraTarifas calculadora) {
        this.calculadora = calculadora;
    }

    public BigDecimal finalizar(String matricula, String tipoUsuario, Duration duracion) {
        return calculadora.calcular(tipoUsuario, duracion);
    }
}
c.c.tarifas.TarifasAutoConfiguration : Starter de tarifas activo: 3 perfiles
    configurados ([estandar, estudiante, jubilado])

Eso es exactamente lo que ocurre cuando añades spring-boot-starter-web. Ya no es magia.

  1. Probar el starter con ApplicationContextRunner

Un starter tiene una particularidad al probarlo: lo interesante no es solo que el bean funcione, sino bajo qué condiciones aparece y bajo cuáles no. Levantar un contexto completo con @SpringBootTest para cada combinación sería lentísimo.

ApplicationContextRunner resuelve exactamente eso: crea contextos mínimos, en memoria, configurables al vuelo, en milisegundos.

package com.ciclourbana.tarifas;

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 TarifasAutoConfigurationTest {

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

    @Test
    void registraLaCalculadoraConLaConfiguracionMinima() {
        runner.withPropertyValues(
                        "ciclourbana.tarifas.por-tipo-usuario.estandar.precio-minuto=0.12")
                .run(contexto -> {
                    assertThat(contexto).hasSingleBean(CalculadoraTarifas.class);
                    assertThat(contexto).hasSingleBean(TarifasProperties.class);

                    CalculadoraTarifas calculadora = contexto.getBean(CalculadoraTarifas.class);
                    // 30 min * 0,12 = 3,60 (desbloqueo 0.00 por defecto)
                    assertThat(calculadora.calcular("estandar", Duration.ofMinutes(30)))
                            .isEqualByComparingTo(new BigDecimal("3.60"));
                });
    }

    @Test
    void noRegistraNadaSiEstaDesactivado() {
        runner.withPropertyValues(
                        "ciclourbana.tarifas.enabled=false",
                        "ciclourbana.tarifas.por-tipo-usuario.estandar.precio-minuto=0.12")
                .run(contexto -> assertThat(contexto).doesNotHaveBean(CalculadoraTarifas.class));
    }

    @Test
    void laAplicacionPuedeAportarSuPropiaCalculadora() {
        runner.withUserConfiguration(ConfiguracionPropia.class)
                .withPropertyValues(
                        "ciclourbana.tarifas.por-tipo-usuario.estandar.precio-minuto=0.12")
                .run(contexto -> {
                    assertThat(contexto).hasSingleBean(CalculadoraTarifas.class);
                    // Gana la del usuario: @ConditionalOnMissingBean se aparta
                    assertThat(contexto.getBean(CalculadoraTarifas.class))
                            .isInstanceOf(CalculadoraTarifasGratuita.class);
                });
    }

    @Test
    void fallaSiNoHayNingunPerfilDeTarifa() {
        runner.run(contexto -> assertThat(contexto)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("Debe definirse al menos un perfil de tarifa"));
    }

    @Test
    void respetaLaMonedaConfigurada() {
        runner.withPropertyValues(
                        "ciclourbana.tarifas.moneda=USD",
                        "ciclourbana.tarifas.por-tipo-usuario.estandar.precio-minuto=0.15")
                .run(contexto -> assertThat(
                        contexto.getBean(CalculadoraTarifas.class).moneda()).isEqualTo("USD"));
    }

    // --- Configuración de apoyo para el tercer test ---

    @Configuration(proxyBeanMethods = false)
    static class ConfiguracionPropia {

        @Bean
        CalculadoraTarifas calculadoraTarifas() {
            return new CalculadoraTarifasGratuita();
        }
    }

    /** Implementación de prueba: la red municipal en jornada de puertas abiertas. */
    static class CalculadoraTarifasGratuita extends CalculadoraTarifas {

        CalculadoraTarifasGratuita() {
            super(new TarifasProperties(true, "EUR",
                    java.util.Map.of("estandar", new TarifasProperties.PerfilTarifa(
                            BigDecimal.ZERO, BigDecimal.ZERO, 0))));
        }

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

Los métodos que más usarás:

Método Para qué
withConfiguration(AutoConfigurations.of(...)) Añade las autoconfiguraciones a probar
withUserConfiguration(...) Simula beans definidos por la aplicación usuaria
withPropertyValues("clave=valor") Fija propiedades para ese contexto
withClassLoader(new FilteredClassLoader(X.class)) Simula que una clase no está en el classpath
withBean(Tipo.class, proveedor) Registra un bean concreto
run(contexto -> { ... }) Arranca y ejecuta las aserciones

El FilteredClassLoader merece atención: es la forma de probar @ConditionalOnClass sin tocar el pom.xml.

@Test
void noSeAplicaSiFaltaLaClaseDelStarter() {
    runner.withClassLoader(new FilteredClassLoader(CalculadoraTarifas.class))
            .run(contexto -> assertThat(contexto).doesNotHaveBean(CalculadoraTarifas.class));
}

Y las aserciones sobre el contexto, que vienen de AssertJ integrado con Spring Boot:

assertThat(contexto).hasSingleBean(CalculadoraTarifas.class);
assertThat(contexto).doesNotHaveBean(CalculadoraTarifas.class);
assertThat(contexto).getBean("calculadoraTarifas").isNotNull();
assertThat(contexto).hasFailed();
assertThat(contexto).getFailure().hasMessageContaining("...");

Estas cinco pruebas se ejecutan en menos de un segundo en total, porque cada contexto contiene tres beans y ningún servidor. El módulo 6 cubre las pruebas en profundidad; aquí ApplicationContextRunner aparece porque es la herramienta específica para lo que estamos construyendo.

Errores Comunes y Consejos

Olvidar el fichero AutoConfiguration.imports. El starter compila, se instala, se declara como dependencia... y no hace absolutamente nada, sin un solo mensaje de error. Es el error número uno. Verifica siempre la ruta completa: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.

Usar spring.factories en Spring Boot 3. Fue eliminado para autoconfiguración. Mismo síntoma silencioso.

Poner @Component o @Service en las clases de un starter. Solo funcionan si el usuario escanea tu paquete, cosa que no controlas ni debes exigir. Las clases de un starter son POJOs; los registra la autoconfiguración.

Olvidar @EnableConfigurationProperties en la autoconfiguración. En una aplicación, @ConfigurationPropertiesScan cubre todo. En un starter no hay tal escaneo: sin esa anotación, tus propiedades no son un bean y la autoconfiguración falla con NoSuchBeanDefinitionException.

Dejar el spring-boot-maven-plugin en el pom.xml del starter. Genera un fat jar reempaquetado cuyas clases están en BOOT-INF/classes/ y no son importables como librería. Un starter es una librería.

Depender de spring-boot-starter-web desde un starter. Impones Tomcat y toda la pila web a quien solo quería tu funcionalidad. Depende de lo mínimo (spring-boot-starter) y usa @ConditionalOnClass para las capacidades opcionales.

Usar @ConditionalOnBean sin @AutoConfigureAfter. La condición se evalúa antes de que exista el bean esperado, falla y tu autoconfiguración se descarta en silencio. Van siempre juntas.

Usar @ConditionalOnMissingBean en el código de la aplicación. Depende de un orden de procesamiento que no controlas. Es una herramienta para autoconfiguraciones.

Excluir autoconfiguraciones a la ligera. Antes de excluir, mira el informe --debug y entiende por qué se está aplicando. A menudo el problema real es una dependencia sobrante en el pom.xml.

Consejo: cuando algo no funciona, arranca con --debug antes de buscar en internet. El informe de evaluación de condiciones responde a la mayoría de las preguntas en treinta segundos, y con la razón exacta.

Consejo: busca un *Customizer antes de reemplazar un bean autoconfigurado. Sustituir el bean entero te deja fuera de las mejoras futuras de Spring Boot y de las integraciones que dependen de él.

Consejo: incluye el spring-boot-configuration-processor en tu starter. Quien lo use tendrá autocompletado y documentación de tus propiedades en el IDE. Es la diferencia entre un starter agradable y uno que obliga a leer el código fuente.

Consejo: prueba tu starter con ApplicationContextRunner desde el primer día. Las condiciones son lógica, y la lógica sin pruebas se rompe. Cinco pruebas de un segundo te ahorran horas de depuración en la aplicación consumidora.

Ejercicios

Ejercicio 1: leer el informe de autoconfiguración

Arranca CicloUrbana con --debug y responde, citando la línea concreta del informe: (a) ¿por qué se aplicó DispatcherServletAutoConfiguration?; (b) ¿por qué no se aplicó DataSourceAutoConfiguration?; (c) ¿cuántas autoconfiguraciones aparecen en Positive matches y cuántas en Negative matches? Después define tu propio bean ObjectMapper y comprueba que JacksonAutoConfiguration#jacksonObjectMapper cambia de sección.

Ejercicio 2: una autoconfiguración condicional dentro de la aplicación

Sin salir del proyecto ciclourbana, crea una clase AuditoriaAutoConfiguration con su fichero .imports en src/main/resources, que registre un bean RegistroAuditoria solo si: la propiedad ciclourbana.auditoria.enabled es true, la aplicación es de tipo servlet, y no existe ya un bean de ese tipo. Comprueba con --debug que aparece en Positive matches al activarla y en Negative matches al desactivarla.

Ejercicio 3: el starter completo, probado

Crea el módulo ciclourbana-tarifas-spring-boot-starter con todo lo del apartado 10: TarifasProperties validado, CalculadoraTarifas, TarifasAutoConfiguration y el fichero .imports. Añade una capacidad nueva: un recargo configurable (ciclourbana.tarifas.recargo-exceso) que se aplique a los minutos que superen ciclourbana.tarifas.duracion-maxima. Escribe al menos cinco pruebas con ApplicationContextRunner que cubran: configuración mínima, desactivación, sustitución por el usuario, fallo de validación y el nuevo recargo.


Soluciones

Solución 1

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

(a) DispatcherServletAutoConfiguration se aplicó porque:

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

La clase DispatcherServlet está en el classpath porque spring-boot-starter-web la aporta, y la aplicación es de tipo servlet.

(b) DataSourceAutoConfiguration no se aplicó porque:

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

Todavía no tenemos spring-boot-starter-data-jpa ni ningún driver JDBC. Eso cambiará en el módulo 4.

(c) Para contar:

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

En un CicloUrbana con solo spring-boot-starter-web obtendrás del orden de 25-30 coincidencias positivas y unas 120 negativas. La cifra concreta depende de la versión de Spring Boot, pero la proporción es siempre la misma: la inmensa mayoría de las autoconfiguraciones no se aplican. Ese es justo el diseño: 158 candidatas, y solo se activan las que tienen sentido para tus dependencias.

Y al definir el ObjectMapper propio:

package com.ciclourbana.comun;

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 ConfiguracionJson {

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

La entrada se mueve 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)

Comentario: el mensaje dice literalmente "found beans of type ... objectMapper". Es @ConditionalOnMissingBean funcionando: Spring Boot vio tu bean y se apartó. Consejo: en este caso concreto, sustituir el ObjectMapper entero es mala idea —pierdes toda la configuración de Spring Boot, incluidos los módulos detectados automáticamente. Lo correcto es el Jackson2ObjectMapperBuilderCustomizer del apartado 6.

Solución 2

package com.ciclourbana.auditoria;

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

import java.time.Instant;

/** Registro simple de acciones sobre la red. POJO, sin anotaciones. */
public class RegistroAuditoria {

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

    private final String destino;

    public RegistroAuditoria(String destino) {
        this.destino = destino;
    }

    public void registrar(String accion, String detalle) {
        log.info("[AUDITORÍA -> {}] {} | {} | {}", destino, Instant.now(), accion, detalle);
    }
}
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")     // sin matchIfMissing: desactivado por defecto
public class AuditoriaAutoConfiguration {

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

Con la auditoría 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#registroAuditoria matched:
      - @ConditionalOnMissingBean (types: com.ciclourbana.auditoria.RegistroAuditoria;
        SearchStrategy: all) did not find any beans (OnBeanCondition)

Y sin activarla:

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

Comentario: el detalle interesante es que una autoconfiguración dentro de la propia aplicación funciona exactamente igual que una de un starter externo. Es un patrón útil para funcionalidades opcionales dentro de un monolito.

Error frecuente: crear el fichero .imports en src/main/java en lugar de src/main/resources. Maven no lo copia al jar y la autoconfiguración desaparece sin avisar. Consejo: verifica siempre con unzip -l target/*.jar | grep imports que el fichero llegó al artefacto.

Solución 3

Las propiedades ampliadas con el recargo:

package com.ciclourbana.tarifas;

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.tarifas")
public record TarifasProperties(

        /** Activa o desactiva el cálculo de tarifas del starter. */
        @DefaultValue("true") boolean enabled,

        /** Moneda de los importes (código ISO 4217). */
        @DefaultValue("EUR") String moneda,

        /** Duración a partir de la cual se aplica el recargo por exceso. */
        @NotNull @DurationMin(minutes = 5) @DefaultValue("2h") Duration duracionMaxima,

        /** Importe adicional por cada minuto que exceda la duración máxima. */
        @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal recargoExceso,

        /** Perfiles de tarifa por tipo de usuario. */
        @NotEmpty(message = "Debe definirse al menos un perfil de tarifa")
        Map<String, @Valid PerfilTarifa> porTipoUsuario
) {

    public record PerfilTarifa(
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueo,
            @NotNull @DecimalMin("0.00") BigDecimal precioMinuto,
            @Min(0) @DefaultValue("0") int minutosGratis
    ) { }
}

La calculadora con el recargo:

package com.ciclourbana.tarifas;

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

public class CalculadoraTarifas {

    private final TarifasProperties propiedades;

    public CalculadoraTarifas(TarifasProperties propiedades) {
        this.propiedades = propiedades;
    }

    public Set<String> tiposDisponibles() {
        return propiedades.porTipoUsuario().keySet();
    }

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

    public BigDecimal calcular(String tipoUsuario, Duration duracion) {
        TarifasProperties.PerfilTarifa perfil = propiedades.porTipoUsuario().get(tipoUsuario);
        if (perfil == null) {
            throw new IllegalArgumentException("Tipo de usuario desconocido: " + tipoUsuario
                    + ". Disponibles: " + tiposDisponibles());
        }

        long minutos = duracion.toMinutes();
        long facturables = Math.max(0, minutos - perfil.minutosGratis());

        BigDecimal importe = perfil.desbloqueo()
                .add(perfil.precioMinuto().multiply(BigDecimal.valueOf(facturables)));

        // Recargo por exceso sobre la duración máxima
        long minutosExceso = Math.max(0, minutos - propiedades.duracionMaxima().toMinutes());
        if (minutosExceso > 0) {
            importe = importe.add(
                    propiedades.recargoExceso().multiply(BigDecimal.valueOf(minutosExceso)));
        }

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

Y las pruebas:

package com.ciclourbana.tarifas;

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 TarifasAutoConfigurationTest {

    private static final String[] CONFIG_MINIMA = {
            "ciclourbana.tarifas.por-tipo-usuario.estandar.desbloqueo=0.50",
            "ciclourbana.tarifas.por-tipo-usuario.estandar.precio-minuto=0.12"
    };

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

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

    @Test
    void noRegistraNadaSiEstaDesactivado() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withPropertyValues("ciclourbana.tarifas.enabled=false")
                .run(contexto -> assertThat(contexto).doesNotHaveBean(CalculadoraTarifas.class));
    }

    @Test
    void laAplicacionPuedeAportarSuPropiaCalculadora() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withUserConfiguration(ConfiguracionPropia.class)
                .run(contexto -> {
                    assertThat(contexto).hasSingleBean(CalculadoraTarifas.class);
                    assertThat(contexto.getBean(CalculadoraTarifas.class)
                            .calcular("estandar", Duration.ofHours(5)))
                            .isEqualByComparingTo(BigDecimal.ZERO);
                });
    }

    @Test
    void fallaSiNoHayNingunPerfilDeTarifa() {
        runner.run(contexto -> assertThat(contexto)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("Debe definirse al menos un perfil de tarifa"));
    }

    @Test
    void aplicaElRecargoPorExceso() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withPropertyValues(
                        "ciclourbana.tarifas.duracion-maxima=2h",
                        "ciclourbana.tarifas.recargo-exceso=0.30")
                .run(contexto -> {
                    CalculadoraTarifas calculadora = contexto.getBean(CalculadoraTarifas.class);

                    // 90 min: por debajo del máximo, sin recargo
                    // 0,50 + 90 * 0,12 = 11,30
                    assertThat(calculadora.calcular("estandar", Duration.ofMinutes(90)))
                            .isEqualByComparingTo(new BigDecimal("11.30"));

                    // 150 min: 30 minutos de exceso
                    // 0,50 + 150 * 0,12 + 30 * 0,30 = 0,50 + 18,00 + 9,00 = 27,50
                    assertThat(calculadora.calcular("estandar", Duration.ofMinutes(150)))
                            .isEqualByComparingTo(new BigDecimal("27.50"));
                });
    }

    @Test
    void noSeAplicaSiFaltaLaClaseDelStarter() {
        runner.withPropertyValues(CONFIG_MINIMA)
                .withClassLoader(new FilteredClassLoader(CalculadoraTarifas.class))
                .run(contexto -> assertThat(contexto)
                        .doesNotHaveBean(CalculadoraTarifas.class));
    }

    @Configuration(proxyBeanMethods = false)
    static class ConfiguracionPropia {

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

Comentario: seis pruebas que levantan seis contextos distintos en menos de un segundo de ejecución efectiva. Compáralo con lo que costaría hacer lo mismo con @SpringBootTest, que arrancaría Tomcat seis veces.

Detalle sobre las aserciones: se usa isEqualByComparingTo y no isEqualTo. En BigDecimal, new BigDecimal("4.10").equals(new BigDecimal("4.1")) es false, porque equals compara también la escala. Es un error clásico que produce fallos de prueba desconcertantes; isEqualByComparingTo usa compareTo y compara solo el valor numérico.

Consejo final sobre el diseño del starter: fíjate en que el recargo se añadió sin romper a nadie. recargoExceso tiene @DefaultValue("0.00") y duracionMaxima tiene @DefaultValue("2h"), así que una aplicación que ya usaba la versión 1.0.0 sigue obteniendo exactamente los mismos importes tras actualizar. Esa es la disciplina que hace usable un starter: toda propiedad nueva llega con un valor por defecto que preserva el comportamiento anterior.

Conclusión

Con esto se cierra el módulo 2 y, con él, la caja negra del contenedor. Sabes que @EnableAutoConfiguration es simplemente @Import de un ImportSelector que calcula qué configuraciones cargar, y que la lista de candidatas no es magia sino un fichero de texto —META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports— con 158 nombres de clase, uno por línea, sustituto del spring.factories que Spring Boot 3 eliminó. Conoces el recorrido completo del AutoConfigurationImportSelector, incluidas las optimizaciones que hacen que evaluar 158 candidatas cueste milisegundos. Dominas las anotaciones condicionales y entiendes por qué @ConditionalOnMissingBean es el corazón de la filosofía de Spring Boot: valores por defecto sensatos que se apartan en cuanto tú tomas el control. Has leído una autoconfiguración real de Spring Boot línea a línea y has descubierto el patrón customizer, que casi siempre es mejor que reemplazar un bean entero. Sabes por qué @ConditionalOnBean necesita compañía de @AutoConfigureAfter. Y sobre todo sabes depurar: arrancar con --debug, ir a Negative matches y leer la condición exacta que falló, que es la respuesta a la pregunta más frustrante del desarrollador de Spring. Para terminar, has construido un starter completo, ciclourbana-tarifas-spring-boot-starter, con su autoconfiguración condicional, sus propiedades tipadas y validadas, su fichero .imports y seis pruebas con ApplicationContextRunner que verifican no solo que el bean funciona, sino bajo qué condiciones aparece y bajo cuáles se aparta.

Mira atrás un momento. Al empezar el módulo, CicloUrbana era una clase principal, un record, un controlador y un almacén en memoria que hacía de todo, con anotaciones copiadas por imitación. Ahora tiene capas bien separadas —EstacionController, EstacionService, EstacionRepositorio con su implementación en memoria—, un sistema de tarifas extensible por configuración, una caché con ciclo de vida gestionado, propiedades tipadas y validadas que fallan el arranque si alguien configura un precio negativo, y un starter propio publicable. Y no queda ni una sola anotación en el proyecto que no sepas explicar.

Ha llegado el momento de volver a la superficie. El módulo 3, Construyendo Servicios Web RESTful, sale del contenedor y entra en la API que verán los ciudadanos de Ribalta: qué significa REST de verdad y qué no, cómo diseñar los recursos y las URLs de CicloUrbana, cómo escribir controladores completos con @GetMapping, @PostMapping, @PutMapping y @DeleteMapping, cómo recibir y validar datos de entrada, cómo separar las entidades de los DTOs que se exponen al exterior, cómo convertir una excepción en una respuesta HTTP correcta y bien formada, y cómo documentar todo ello con OpenAPI para que otros equipos puedan integrarse. Nuestro único endpoint, GET /api/v1/estaciones, está a punto de convertirse en una API completa.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados