Since the very first lesson we have been repeating that Spring Boot "detects what is on the classpath and registers the appropriate beans". We add spring-boot-starter-web to the pom.xml and Tomcat, Jackson, the DispatcherServlet and a couple of dozen more pieces appear without a single line of configuration. We have accepted it as a black box for the whole module. Now we open it completely. We will see what @EnableAutoConfiguration actually does, where the list of candidates is written, how it is decided condition by condition what gets registered and what does not, why declaring your own bean is enough to make Spring Boot step aside, and how to read the autoconfiguration report to answer the most frustrating question a Spring developer faces: "why has my bean not been created?". And we will finish by building a starter of our own, ciclourbana-fares-spring-boot-starter, packaging Ribalta's fare system so that another application can use it by declaring a single dependency.

Contents

  1. What @EnableAutoConfiguration really does
  2. The AutoConfiguration.imports file
  3. The AutoConfigurationImportSelector step by step
  4. The conditional annotations
  5. @ConditionalOnMissingBean: "define your bean and Spring steps aside"
  6. Reading a real Spring Boot autoconfiguration
  7. Ordering between autoconfigurations
  8. The autoconfiguration report
  9. Excluding autoconfigurations
  10. Building your own starter
  11. Testing the starter with ApplicationContextRunner
  12. Common Mistakes and Tips
  13. Exercises

  1. What @EnableAutoConfiguration really does

In lesson 02-01 we took @SpringBootApplication apart into three annotations and left one pending. Its real declaration is:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)     // <-- everything is in here
public @interface EnableAutoConfiguration {

    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

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

    String[] excludeName() default {};
}

There are only two pieces:

  • @AutoConfigurationPackage registers the annotated class's package (com.ciclourbana) as the "autoconfiguration package". Other autoconfigurations consult it to know where to look: that is how Spring Data JPA will find our entities in module 4 without us telling it where they are.
  • @Import(AutoConfigurationImportSelector.class) is the engine. @Import is a Spring Framework annotation that adds configuration classes to the context; when what you import is an ImportSelector, Spring asks it at startup time which classes it should import. In other words: the list is not written down, it is computed.

The important conclusion: autoconfiguration is not a special or privileged mechanism. It is @Import with a class that decides dynamically what to import. Everything else is the logic of that decision.

  1. The AutoConfiguration.imports file

Where does the selector get its list of candidates from? From a plain text file that each jar may contribute:

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

Open it in your own project. It lives inside the spring-boot-autoconfigure jar:

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

Count how many there are:

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

One hundred and fifty-eight candidate classes. That is the whole "mystery" of autoconfiguration: a list of class names in a text file. What makes only a handful of them apply in CicloUrbana are the conditions we will see in section 4.

Its predecessor: spring.factories

Up to Spring Boot 2.7, the list lived in META-INF/spring.factories, a properties file that served many purposes at once:

# Old format (Spring Boot <= 2.7). No longer used for autoconfiguration.
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, mandatory in 3.x)
Location META-INF/spring.factories META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Format Properties with \ continuations One class name per line
Error-prone Yes (those backslashes) No
Reading cost High: the whole file is processed Lower
Still valid for autoconfiguration Removed in Spring Boot 3 The current one

spring.factories still exists for other extension points (ApplicationListener, EnvironmentPostProcessor...), but not for autoconfiguration. If you migrate an old starter to Boot 3 and do not change the file, your autoconfigurations simply do not apply, with no error at all. It is one of the most common traps in migration.

  1. The AutoConfigurationImportSelector step by step

This is the complete algorithm, from startup to registered beans:

flowchart TD
    A["@EnableAutoConfiguration"] --> B["AutoConfigurationImportSelector"]
    B --> C["1. Read ALL the classpath's<br/>AutoConfiguration.imports files<br/>(158 classes in spring-boot-autoconfigure<br/>+ those of each custom starter)"]
    C --> D["2. Remove duplicates"]
    D --> E["3. Drop the excluded ones<br/>exclude=, spring.autoconfigure.exclude"]
    E --> F["4. Apply the AutoConfigurationImportFilters<br/>OnClassCondition: quickly discards<br/>whatever lacks its classes"]
    F --> G["5. Sort<br/>@AutoConfigureOrder,<br/>@AutoConfigureBefore/After"]
    G --> H["6. Register them as<br/>configuration classes"]
    H --> I["7. Evaluate the conditions<br/>of each class and each @Bean"]
    I --> J{"Are they met?"}
    J -- Yes --> K["Beans registered"]
    J -- No --> L["Discarded:<br/>shows up under Negative matches"]

Step 4 deserves a performance note. Evaluating the conditions of 158 classes would be slow if each one had to be loaded. Spring Boot avoids that with two optimisations: the AutoConfigurationImportFilters (in particular OnClassCondition) discard candidates by reading only the metadata precomputed in META-INF/spring-autoconfigure-metadata.properties, without loading the classes; and the filtering is spread across several threads. That is why a Spring Boot application starts in two seconds and not in twenty.

  1. The conditional annotations

An autoconfiguration class carries annotations expressing under what conditions it should apply. These are the ones you will see over and over:

Annotation Applies if... Real example
@ConditionalOnClass The class is on the classpath @ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnMissingClass The class is not there @ConditionalOnMissingClass("com.other.Engine")
@ConditionalOnBean A bean of that type already exists @ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean No bean of that type exists @ConditionalOnMissingBean(ObjectMapper.class)
@ConditionalOnProperty A property has a certain value @ConditionalOnProperty(name = "ciclourbana.fares.enabled", havingValue = "true")
@ConditionalOnWebApplication It is a web application @ConditionalOnWebApplication(type = SERVLET)
@ConditionalOnNotWebApplication It is not a web application Batch jobs
@ConditionalOnResource A resource exists @ConditionalOnResource(resources = "classpath:fares.json")
@ConditionalOnExpression A SpEL expression is true @ConditionalOnExpression("${ciclourbana.network.minimum-capacity:8} > 4")
@ConditionalOnJava The Java version qualifies @ConditionalOnJava(JavaVersion.TWENTY_ONE)
@ConditionalOnSingleCandidate There is exactly one, or one @Primary @ConditionalOnSingleCandidate(DataSource.class)

They all derive from @Conditional, a Spring Framework annotation that takes an implementation of the Condition interface:

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

You can write your own. For instance, a condition for CicloUrbana that only activates a bean if the network has at least one featured station configured:

package com.ciclourbana.common;

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

/** Met if there is at least one featured station configured. */
public class HasFeaturedStations implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String value = context.getEnvironment()
                .getProperty("ciclourbana.network.featured-stations");
        return value != null && !value.isBlank();
    }
}
@Bean
@Conditional(HasFeaturedStations.class)
public FeaturedPanel featuredPanel(NetworkProperties network) {
    return new FeaturedPanel(network.featuredStations());
}

An important nuance about @ConditionalOnProperty, which is the most used one in application configuration:

@ConditionalOnProperty(
        prefix = "ciclourbana.fares",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)   // if the property is NOT there, it counts as met

matchIfMissing = true is what lets a feature be on by default and be turned off explicitly. Without it, the property would have to be declared for anything to work at all.

  1. @ConditionalOnMissingBean: "define your bean and Spring steps aside"

Of all the conditionals, this is the one that defines Spring Boot's philosophy, and it is worth understanding thoroughly.

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

Read it like this: "if the user has not defined their own ObjectMapper, I provide a reasonable one; if they have, I keep quiet". That is exactly Spring Boot's promise: sensible defaults that never get in your way.

That is what happens when you declare your own bean of an autoconfigured type:

package com.ciclourbana.common;

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

    /**
     * CicloUrbana's own ObjectMapper. Because this bean exists,
     * JacksonAutoConfiguration will NOT register its own.
     */
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .findAndRegisterModules();
    }
}

From that moment on, the autoconfiguration's ObjectMapper disappears from the startup report and moves into the Negative matches section with the reason: "found beans of type ObjectMapper".

Order matters, a great deal

There is a critical subtlety: @ConditionalOnMissingBean is evaluated at the moment that configuration class is processed, not at the end of startup. And autoconfigurations are processed after your classes, precisely so that your beans are already registered when they are evaluated. That ordering is deliberate and it is what makes the mechanism work.

From that follows a golden rule: @ConditionalOnMissingBean is for autoconfigurations, not for your application code. If you use it between two of your own configuration classes, the outcome depends on a processing order you do not control, and you will get apparently random behaviour.

Variants

// By type (the usual case; with no arguments it uses the method's return type)
@ConditionalOnMissingBean(FareCalculator.class)

// By bean name
@ConditionalOnMissingBean(name = "customFareCalculator")

// By an annotation present on some bean
@ConditionalOnMissingBean(annotation = NetworkService.class)

// Ignoring certain types when checking
@ConditionalOnMissingBean(value = FareCalculator.class, ignored = TestFare.class)

  1. Reading a real Spring Boot autoconfiguration

The best way to understand the mechanism is to read a real class. Let us take an abridged version of JacksonAutoConfiguration, the one responsible for our /api/v1/stations endpoint returning 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> customizers) {        // 6

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

Point by point:

  1. @AutoConfiguration (Spring Boot 3) replaces the old @Configuration + @AutoConfigureAfter. It is a meta-annotation that already includes @Configuration(proxyBeanMethods = false) and accepts the before, after and beforeName/afterName attributes.
  2. @ConditionalOnClass(ObjectMapper.class): if Jackson is not on the classpath, the whole class is discarded at once. Here is the answer to "Spring Boot detects what is on the classpath": it is literally this annotation.
  3. Inner configuration classes: they let you group beans with different conditions inside a single autoconfiguration.
  4. @Primary: if the user defines another ObjectMapper under a different name, Spring Boot's remains the preferred one for unqualified injections.
  5. @ConditionalOnMissingBean: Spring Boot's courtesy.
  6. The customizer pattern: instead of forcing you to redefine the entire bean to change one detail, the autoconfiguration collects every Jackson2ObjectMapperBuilderCustomizer bean in the context and applies them.

That last point is a very useful pattern you can take advantage of today. To have CicloUrbana serialise dates in ISO format without replacing the whole ObjectMapper:

package com.ciclourbana.common;

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

    /**
     * Adjusts the autoconfigured ObjectMapper without replacing it:
     * we keep every one of Spring Boot's defaults.
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> builder
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
    }
}

Rule of thumb: when you want to change one detail of something autoconfigured, first look for a *Customizer interface. Replacing the whole bean is the nuclear option and it cuts you off from every future Spring Boot improvement.

  1. Ordering between autoconfigurations

Some autoconfigurations depend on the outcome of others. JpaRepositoriesAutoConfiguration needs a DataSource to exist already, so it must be evaluated after DataSourceAutoConfiguration. Three annotations control this:

Annotation Effect
@AutoConfigureAfter(X.class) Evaluated after X
@AutoConfigureBefore(X.class) Evaluated before X
@AutoConfigureOrder(n) Numeric priority; lower value, earlier

In Spring Boot 3 they are expressed as attributes of @AutoConfiguration:

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

It is essential to understand why the order matters: the @ConditionalOnBean(DataSource.class) condition is only met if the DataSource is already registered when it is evaluated. If your autoconfiguration were evaluated earlier, the condition would fail and your bean would never be created, with no error message whatsoever. This is the source of 90% of "my autoconfiguration does not work".

Hence the rule: @ConditionalOnBean almost always needs an after to go with it.

One further warning: @AutoConfigureAfter orders autoconfigurations only. Your application's configuration classes are always processed before all of them, and that order cannot be changed (nor does it need to be: it is exactly what makes @ConditionalOnMissingBean work).

  1. The autoconfiguration report

When a bean does not appear and you do not know why, this is the tool. Start CicloUrbana with --debug:

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

Or, equivalently:

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

You will get a report with this structure:

============================
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

How to read it:

Section What it holds When you look at it
Positive matches Autoconfigurations applied, with the condition that was met To confirm something was activated and know why
Negative matches Discarded ones, with the condition that failed The most useful: it says why you do not have your bean
Exclusions Explicitly excluded When debugging an exclusion
Unconditional classes Always applied, no conditions Rarely

A diagnostic flow that works:

flowchart TD
    A["My bean does not exist"] --> B["Start with --debug"]
    B --> C["Find the autoconfiguration<br/>under Negative matches"]
    C --> D{"Is it there?"}
    D -- Yes --> E["Read 'Did not match':<br/>it names the exact condition that failed"]
    E --> F1["OnClassCondition:<br/>a dependency is missing<br/>→ check the pom.xml"]
    E --> F2["OnBeanCondition:<br/>a bean already exists, or one<br/>it depends on is missing<br/>→ check the ordering"]
    E --> F3["OnPropertyCondition:<br/>a property is missing<br/>or does not match"]
    D -- No --> G{"Is it under Exclusions?"}
    G -- Yes --> H["Remove the exclusion"]
    G -- No --> I["The class is in no<br/>AutoConfiguration.imports at all:<br/>is the whole dependency missing?"]

Instead of --debug, which is very verbose, you can turn on just the report:

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

And there is an even better alternative once Actuator is available (lesson 07-01): the /actuator/conditions endpoint returns the same report as JSON, filterable and queryable at runtime.

  1. Excluding autoconfigurations

Sometimes you want to disable an autoconfiguration: because you do not need it, because it interferes, or because you want to configure that part by hand. There are three ways.

In the annotation

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

By name (if the class is not on the compile classpath)

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

By property (the most flexible)

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Form Advantage Drawback
exclude in the annotation Type-safe; the compiler validates it Fixed in the code, the same in every environment
excludeName Works without the class on the classpath An unvalidated string literal
spring.autoconfigure.exclude Configurable per environment or profile A typo fails the startup

A real and frequent case: you have added spring-boot-starter-data-jpa to get ready for module 4 but you do not have a database yet. DataSourceAutoConfiguration tries to create a DataSource, finds no URL and the startup fails with "Failed to configure a DataSource". The temporary fix is to exclude it:

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

With one warning: excluding is a patch. It is usually better to remove the dependency until you need it, or —the right answer in this case— configure an in-memory H2 database, which is what we will do in lesson 04-02.

  1. Building your own starter

Here comes the practical part. We are going to package Ribalta's fare system as a reusable starter, so that another municipal application (the e-scooter one, say) can use it by declaring a single dependency.

The naming convention

Kind of starter Convention Example
Official Spring Boot one spring-boot-starter-* spring-boot-starter-web
Third-party *-spring-boot-starter ciclourbana-fares-spring-boot-starter

The spring-boot-starter- prefix is reserved for the official starters. A third-party starter puts its own name first. The analogous convention for the autoconfiguration module is *-spring-boot-autoconfigure.

In a serious starter, two artefacts are kept apart:

flowchart LR
    A["ciclourbana-fares-spring-boot-autoconfigure<br/>The code: autoconfiguration,<br/>properties, service"] --> B["ciclourbana-fares-spring-boot-starter<br/>Just a pom.xml with dependencies"]
    B --> C["Municipal application<br/>declares ONE dependency"]

The starter is an artefact with no code: just a pom.xml gathering the autoconfiguration and the dependencies it needs. For this exercise we will merge them into a single module, which is what is usually done in small projects, but the separation is worth knowing about.

The starter's pom.xml

<?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-fares-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <name>CicloUrbana Fares Starter</name>
    <description>Fare calculation for municipal bike-sharing networks</description>

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

    <dependencies>
        <!-- Core: container, Environment, @ConfigurationProperties.
             We do NOT use spring-boot-starter-web: a starter must not impose
             the application type on whoever consumes it. -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <!-- Validation of the starter's properties -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Generates the metadata for IDE autocompletion -->
        <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>
            <!-- CAREFUL: no spring-boot-maven-plugin.
                 A starter is a LIBRARY, not an executable application:
                 it must not be packaged as a fat jar. -->
        </plugins>
    </build>
</project>

The two comments at the end are the most common mistakes when creating a starter: depending on spring-boot-starter-web (imposing Tomcat on someone who only wanted to work out fares) and leaving the spring-boot-maven-plugin in place, which produces a fat jar whose classes cannot be imported.

The starter's properties

package com.ciclourbana.fares;

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;

/**
 * Configuration of fare calculation for a bike-sharing network.
 * Prefix: ciclourbana.fares
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.fares")
public record FareProperties(

        /** Turns the starter's fare calculation on or off. */
        @DefaultValue("true") boolean enabled,

        /** Currency the amounts are expressed in (ISO 4217 code). */
        @DefaultValue("EUR") String currency,

        /** Fare profiles, indexed by the user type's identifier. */
        @NotEmpty(message = "At least one fare profile must be defined")
        Map<String, @Valid FareProfile> byUserType
) {

    /** Pricing terms for one user type. */
    public record FareProfile(

            /** Fixed unlock charge. */
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal unlock,

            /** Charge per minute of use. */
            @NotNull @DecimalMin("0.00") BigDecimal pricePerMinute,

            /** Opening minutes at no cost. */
            @Min(0) @DefaultValue("0") int freeMinutes
    ) { }
}

The service the starter contributes

package com.ciclourbana.fares;

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

/**
 * Works out the amount of a rental from the configured fare profile.
 * It is a plain POJO: it carries no @Service and no Spring annotation
 * at all, because the one registering it as a bean is the autoconfiguration.
 */
public class FaresCalculator {

    private final FareProperties properties;

    public FaresCalculator(FareProperties properties) {
        this.properties = properties;
    }

    public Set<String> availableTypes() {
        return properties.byUserType().keySet();
    }

    public String currency() {
        return properties.currency();
    }

    public BigDecimal calculate(String userType, Duration duration) {
        FareProperties.FareProfile profile =
                properties.byUserType().get(userType);

        if (profile == null) {
            throw new IllegalArgumentException("Unknown user type: " + userType
                    + ". Available: " + availableTypes());
        }

        long billable = Math.max(0, duration.toMinutes() - profile.freeMinutes());
        return profile.unlock()
                .add(profile.pricePerMinute().multiply(BigDecimal.valueOf(billable)))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Note the detail: the class carries no Spring annotations. That is a deliberate decision. A library class annotated with @Service would only work if the user scanned our package, and that is not something we control. The one turning it into a bean is the autoconfiguration.

The autoconfiguration class

package com.ciclourbana.fares;

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;

/**
 * Autoconfiguration for CicloUrbana's fares starter.
 *
 * It registers a FaresCalculator if:
 *  - the class is on the classpath,
 *  - the ciclourbana.fares.enabled property is not false,
 *  - and the application has not already defined its own FaresCalculator.
 */
@AutoConfiguration
@ConditionalOnClass(FaresCalculator.class)
@ConditionalOnProperty(
        prefix = "ciclourbana.fares",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true)               // on by default
@EnableConfigurationProperties(FareProperties.class)   // there is NO user scan here
public class FaresAutoConfiguration {

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

    @Bean
    @ConditionalOnMissingBean                // the application may replace it
    public FaresCalculator faresCalculator(FareProperties properties) {
        log.info("Fares starter active: {} profiles configured ({})",
                properties.byUserType().size(),
                properties.byUserType().keySet());
        return new FaresCalculator(properties);
    }
}

The four annotations, and why each one is there:

Annotation Why it is there
@AutoConfiguration It is an autoconfiguration, not a plain @Configuration. It brings proxyBeanMethods = false.
@ConditionalOnClass A defensive check: if the starter's jar is not complete, it does not apply.
@ConditionalOnProperty(matchIfMissing = true) On by default, switchable off with ciclourbana.fares.enabled=false.
@EnableConfigurationProperties Indispensable: in a starter there is no user @ConfigurationPropertiesScan to register our properties.
@ConditionalOnMissingBean The application can supply its own calculator and win.

The .imports file

Without this file, none of the above applies. It is the step most often forgotten:

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

With a single line:

com.ciclourbana.fares.FaresAutoConfiguration

No extension, no commas, no backslashes: one fully qualified class name per line. Mind the path: the directory is META-INF/spring/ and the file name is long and full of dots. A typo produces no error at all: the starter simply does nothing, which is the worst way to fail.

The starter's defaults

A starter should work with no configuration at all. Add a defaults file:

# src/main/resources/ciclourbana-fares-defaults.yaml
ciclourbana:
  fares:
    enabled: true
    currency: EUR
    by-user-type:
      standard:
        unlock: 0.50
        price-per-minute: 0.12
        free-minutes: 0

And in the autoconfiguration, import it with @PropertySource or —more idiomatic in Boot 3— declare the defaults with @DefaultValue in the record, which is what we have already done.

Using it from CicloUrbana

# In the starter's directory
./mvnw clean install
<!-- In ciclourbana's pom.xml -->
<dependency>
    <groupId>com.ciclourbana</groupId>
    <artifactId>ciclourbana-fares-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
# the application's application.yaml
ciclourbana:
  fares:
    currency: EUR
    by-user-type:
      standard:
        unlock: 0.50
        price-per-minute: 0.12
      student:
        price-per-minute: 0.08
        free-minutes: 15
      senior:
        price-per-minute: 0.05
        free-minutes: 30

And it is available for injection, with no @ComponentScan, no @Import, nothing:

package com.ciclourbana.rentals;

import com.ciclourbana.fares.FaresCalculator;
import org.springframework.stereotype.Service;

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

@Service
public class RentalService {

    private final FaresCalculator calculator;   // comes from the starter

    public RentalService(FaresCalculator calculator) {
        this.calculator = calculator;
    }

    public BigDecimal finish(String plate, String userType, Duration duration) {
        return calculator.calculate(userType, duration);
    }
}
c.c.fares.FaresAutoConfiguration : Fares starter active: 3 profiles
    configured ([standard, student, senior])

That is exactly what happens when you add spring-boot-starter-web. It is no longer magic.

  1. Testing the starter with ApplicationContextRunner

A starter has a quirk when it comes to testing: what is interesting is not only that the bean works, but under which conditions it appears and under which it does not. Bringing up a full context with @SpringBootTest for each combination would be desperately slow.

ApplicationContextRunner solves exactly that: it creates minimal, in-memory contexts, configurable on the fly, in milliseconds.

package com.ciclourbana.fares;

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

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

    @Test
    void registersTheCalculatorWithTheMinimalConfiguration() {
        runner.withPropertyValues(
                        "ciclourbana.fares.by-user-type.standard.price-per-minute=0.12")
                .run(context -> {
                    assertThat(context).hasSingleBean(FaresCalculator.class);
                    assertThat(context).hasSingleBean(FareProperties.class);

                    FaresCalculator calculator = context.getBean(FaresCalculator.class);
                    // 30 min * 0.12 = 3.60 (unlock defaults to 0.00)
                    assertThat(calculator.calculate("standard", Duration.ofMinutes(30)))
                            .isEqualByComparingTo(new BigDecimal("3.60"));
                });
    }

    @Test
    void registersNothingWhenDisabled() {
        runner.withPropertyValues(
                        "ciclourbana.fares.enabled=false",
                        "ciclourbana.fares.by-user-type.standard.price-per-minute=0.12")
                .run(context -> assertThat(context).doesNotHaveBean(FaresCalculator.class));
    }

    @Test
    void theApplicationCanSupplyItsOwnCalculator() {
        runner.withUserConfiguration(OwnConfiguration.class)
                .withPropertyValues(
                        "ciclourbana.fares.by-user-type.standard.price-per-minute=0.12")
                .run(context -> {
                    assertThat(context).hasSingleBean(FaresCalculator.class);
                    // The user's one wins: @ConditionalOnMissingBean steps aside
                    assertThat(context.getBean(FaresCalculator.class))
                            .isInstanceOf(FreeFaresCalculator.class);
                });
    }

    @Test
    void failsWhenThereIsNoFareProfile() {
        runner.run(context -> assertThat(context)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("At least one fare profile must be defined"));
    }

    @Test
    void honoursTheConfiguredCurrency() {
        runner.withPropertyValues(
                        "ciclourbana.fares.currency=USD",
                        "ciclourbana.fares.by-user-type.standard.price-per-minute=0.15")
                .run(context -> assertThat(
                        context.getBean(FaresCalculator.class).currency()).isEqualTo("USD"));
    }

    // --- Supporting configuration for the third test ---

    @Configuration(proxyBeanMethods = false)
    static class OwnConfiguration {

        @Bean
        FaresCalculator faresCalculator() {
            return new FreeFaresCalculator();
        }
    }

    /** Test implementation: the municipal network on an open-doors day. */
    static class FreeFaresCalculator extends FaresCalculator {

        FreeFaresCalculator() {
            super(new FareProperties(true, "EUR",
                    java.util.Map.of("standard", new FareProperties.FareProfile(
                            BigDecimal.ZERO, BigDecimal.ZERO, 0))));
        }

        @Override
        public BigDecimal calculate(String userType, Duration duration) {
            return BigDecimal.ZERO;
        }
    }
}

The methods you will use most:

Method What for
withConfiguration(AutoConfigurations.of(...)) Adds the autoconfigurations under test
withUserConfiguration(...) Simulates beans defined by the consuming application
withPropertyValues("key=value") Sets properties for that context
withClassLoader(new FilteredClassLoader(X.class)) Simulates a class not being on the classpath
withBean(Type.class, supplier) Registers a specific bean
run(context -> { ... }) Starts up and runs the assertions

FilteredClassLoader deserves attention: it is how you test @ConditionalOnClass without touching the pom.xml.

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

And the assertions on the context, which come from AssertJ integrated with Spring Boot:

assertThat(context).hasSingleBean(FaresCalculator.class);
assertThat(context).doesNotHaveBean(FaresCalculator.class);
assertThat(context).getBean("faresCalculator").isNotNull();
assertThat(context).hasFailed();
assertThat(context).getFailure().hasMessageContaining("...");

These five tests run in under a second in total, because each context holds three beans and no server. Module 6 covers testing in depth; ApplicationContextRunner appears here because it is the specific tool for what we are building.

Common Mistakes and Tips

Forgetting the AutoConfiguration.imports file. The starter compiles, installs, gets declared as a dependency... and does absolutely nothing, without a single error message. It is mistake number one. Always verify the full path: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.

Using spring.factories in Spring Boot 3. It was removed for autoconfiguration. Same silent symptom.

Putting @Component or @Service on a starter's classes. They only work if the user scans your package, which you neither control nor should demand. A starter's classes are POJOs; the autoconfiguration registers them.

Forgetting @EnableConfigurationProperties in the autoconfiguration. In an application, @ConfigurationPropertiesScan covers everything. In a starter there is no such scan: without that annotation, your properties are not a bean and the autoconfiguration fails with NoSuchBeanDefinitionException.

Leaving the spring-boot-maven-plugin in the starter's pom.xml. It produces a repackaged fat jar whose classes live under BOOT-INF/classes/ and cannot be imported as a library. A starter is a library.

Depending on spring-boot-starter-web from a starter. You impose Tomcat and the whole web stack on someone who only wanted your functionality. Depend on the bare minimum (spring-boot-starter) and use @ConditionalOnClass for optional capabilities.

Using @ConditionalOnBean without @AutoConfigureAfter. The condition is evaluated before the expected bean exists, it fails and your autoconfiguration is silently discarded. They always go together.

Using @ConditionalOnMissingBean in application code. It depends on a processing order you do not control. It is a tool for autoconfigurations.

Excluding autoconfigurations lightly. Before excluding, look at the --debug report and understand why it is being applied. Often the real problem is a stray dependency in the pom.xml.

Tip: when something does not work, start with --debug before searching the internet. The condition evaluation report answers most questions in thirty seconds, and with the exact reason.

Tip: look for a *Customizer before replacing an autoconfigured bean. Replacing the whole bean cuts you off from Spring Boot's future improvements and from the integrations that depend on it.

Tip: include the spring-boot-configuration-processor in your starter. Whoever uses it will get autocompletion and documentation for your properties in the IDE. It is the difference between a pleasant starter and one that forces people to read the source.

Tip: test your starter with ApplicationContextRunner from day one. Conditions are logic, and logic without tests breaks. Five one-second tests save you hours of debugging in the consuming application.

Exercises

Exercise 1: read the autoconfiguration report

Start CicloUrbana with --debug and answer, quoting the specific line from the report: (a) why was DispatcherServletAutoConfiguration applied?; (b) why was DataSourceAutoConfiguration not applied?; (c) how many autoconfigurations appear under Positive matches and how many under Negative matches? Then define your own ObjectMapper bean and check that JacksonAutoConfiguration#jacksonObjectMapper changes section.

Exercise 2: a conditional autoconfiguration inside the application

Without leaving the ciclourbana project, create an AuditAutoConfiguration class with its .imports file in src/main/resources that registers an AuditLog bean only if: the ciclourbana.audit.enabled property is true, the application is of servlet type, and no bean of that type already exists. Check with --debug that it appears under Positive matches when enabled and under Negative matches when disabled.

Exercise 3: the complete starter, tested

Create the ciclourbana-fares-spring-boot-starter module with everything from section 10: a validated FareProperties, FaresCalculator, FaresAutoConfiguration and the .imports file. Add a new capability: a configurable surcharge (ciclourbana.fares.overtime-surcharge) applied to the minutes that go beyond ciclourbana.fares.maximum-duration. Write at least five tests with ApplicationContextRunner covering: minimal configuration, being disabled, replacement by the user, a validation failure and the new surcharge.


Solutions

Solution 1

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

(a) DispatcherServletAutoConfiguration was applied because:

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

The DispatcherServlet class is on the classpath because spring-boot-starter-web brings it in, and the application is of servlet type.

(b) DataSourceAutoConfiguration was not applied because:

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

We do not yet have spring-boot-starter-data-jpa or any JDBC driver. That will change in module 4.

(c) To count them:

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

In a CicloUrbana with only spring-boot-starter-web you will get in the order of 25-30 positive matches and around 120 negative ones. The exact figure depends on the Spring Boot version, but the proportion is always the same: the vast majority of autoconfigurations do not apply. That is precisely the design: 158 candidates, and only the ones that make sense for your dependencies get activated.

And once you define your own ObjectMapper:

package com.ciclourbana.common;

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

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

The entry moves to 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)

Comment: the message literally says "found beans of type ... objectMapper". That is @ConditionalOnMissingBean at work: Spring Boot saw your bean and stepped aside. Tip: in this particular case, replacing the whole ObjectMapper is a bad idea —you lose all of Spring Boot's configuration, including the automatically detected modules. The right move is the Jackson2ObjectMapperBuilderCustomizer from section 6.

Solution 2

package com.ciclourbana.audit;

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

import java.time.Instant;

/** Simple log of actions on the network. A POJO, with no annotations. */
public class AuditLog {

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

    private final String target;

    public AuditLog(String target) {
        this.target = target;
    }

    public void record(String action, String detail) {
        log.info("[AUDIT -> {}] {} | {} | {}", target, Instant.now(), action, detail);
    }
}
package com.ciclourbana.audit;

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.audit",
        name = "enabled",
        havingValue = "true")     // no matchIfMissing: off by default
public class AuditAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AuditLog auditLog(Environment environment) {
        return new AuditLog(
                environment.getProperty("ciclourbana.audit.target", "console"));
    }
}
# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.ciclourbana.audit.AuditAutoConfiguration

With auditing enabled:

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

   AuditAutoConfiguration#auditLog matched:
      - @ConditionalOnMissingBean (types: com.ciclourbana.audit.AuditLog;
        SearchStrategy: all) did not find any beans (OnBeanCondition)

And without enabling it:

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

Comment: the interesting detail is that an autoconfiguration inside the application itself works exactly like one from an external starter. It is a useful pattern for optional features inside a monolith.

Frequent mistake: creating the .imports file under src/main/java instead of src/main/resources. Maven does not copy it into the jar and the autoconfiguration disappears without warning. Tip: always verify with unzip -l target/*.jar | grep imports that the file made it into the artefact.

Solution 3

The properties extended with the surcharge:

package com.ciclourbana.fares;

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.fares")
public record FareProperties(

        /** Turns the starter's fare calculation on or off. */
        @DefaultValue("true") boolean enabled,

        /** Currency of the amounts (ISO 4217 code). */
        @DefaultValue("EUR") String currency,

        /** Duration beyond which the overtime surcharge applies. */
        @NotNull @DurationMin(minutes = 5) @DefaultValue("2h") Duration maximumDuration,

        /** Extra amount for every minute beyond the maximum duration. */
        @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal overtimeSurcharge,

        /** Fare profiles per user type. */
        @NotEmpty(message = "At least one fare profile must be defined")
        Map<String, @Valid FareProfile> byUserType
) {

    public record FareProfile(
            @NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal unlock,
            @NotNull @DecimalMin("0.00") BigDecimal pricePerMinute,
            @Min(0) @DefaultValue("0") int freeMinutes
    ) { }
}

The calculator with the surcharge:

package com.ciclourbana.fares;

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

public class FaresCalculator {

    private final FareProperties properties;

    public FaresCalculator(FareProperties properties) {
        this.properties = properties;
    }

    public Set<String> availableTypes() {
        return properties.byUserType().keySet();
    }

    public String currency() {
        return properties.currency();
    }

    public BigDecimal calculate(String userType, Duration duration) {
        FareProperties.FareProfile profile = properties.byUserType().get(userType);
        if (profile == null) {
            throw new IllegalArgumentException("Unknown user type: " + userType
                    + ". Available: " + availableTypes());
        }

        long minutes = duration.toMinutes();
        long billable = Math.max(0, minutes - profile.freeMinutes());

        BigDecimal amount = profile.unlock()
                .add(profile.pricePerMinute().multiply(BigDecimal.valueOf(billable)));

        // Surcharge for going beyond the maximum duration
        long overtimeMinutes = Math.max(0, minutes - properties.maximumDuration().toMinutes());
        if (overtimeMinutes > 0) {
            amount = amount.add(
                    properties.overtimeSurcharge().multiply(BigDecimal.valueOf(overtimeMinutes)));
        }

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

And the tests:

package com.ciclourbana.fares;

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

    private static final String[] MINIMAL_CONFIG = {
            "ciclourbana.fares.by-user-type.standard.unlock=0.50",
            "ciclourbana.fares.by-user-type.standard.price-per-minute=0.12"
    };

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

    @Test
    void registersTheCalculatorWithTheMinimalConfiguration() {
        runner.withPropertyValues(MINIMAL_CONFIG).run(context -> {
            assertThat(context).hasSingleBean(FaresCalculator.class);
            // 0.50 + 30 * 0.12 = 4.10
            assertThat(context.getBean(FaresCalculator.class)
                    .calculate("standard", Duration.ofMinutes(30)))
                    .isEqualByComparingTo(new BigDecimal("4.10"));
        });
    }

    @Test
    void registersNothingWhenDisabled() {
        runner.withPropertyValues(MINIMAL_CONFIG)
                .withPropertyValues("ciclourbana.fares.enabled=false")
                .run(context -> assertThat(context).doesNotHaveBean(FaresCalculator.class));
    }

    @Test
    void theApplicationCanSupplyItsOwnCalculator() {
        runner.withPropertyValues(MINIMAL_CONFIG)
                .withUserConfiguration(OwnConfiguration.class)
                .run(context -> {
                    assertThat(context).hasSingleBean(FaresCalculator.class);
                    assertThat(context.getBean(FaresCalculator.class)
                            .calculate("standard", Duration.ofHours(5)))
                            .isEqualByComparingTo(BigDecimal.ZERO);
                });
    }

    @Test
    void failsWhenThereIsNoFareProfile() {
        runner.run(context -> assertThat(context)
                .hasFailed()
                .getFailure()
                .hasMessageContaining("At least one fare profile must be defined"));
    }

    @Test
    void appliesTheOvertimeSurcharge() {
        runner.withPropertyValues(MINIMAL_CONFIG)
                .withPropertyValues(
                        "ciclourbana.fares.maximum-duration=2h",
                        "ciclourbana.fares.overtime-surcharge=0.30")
                .run(context -> {
                    FaresCalculator calculator = context.getBean(FaresCalculator.class);

                    // 90 min: under the maximum, no surcharge
                    // 0.50 + 90 * 0.12 = 11.30
                    assertThat(calculator.calculate("standard", Duration.ofMinutes(90)))
                            .isEqualByComparingTo(new BigDecimal("11.30"));

                    // 150 min: 30 minutes of overtime
                    // 0.50 + 150 * 0.12 + 30 * 0.30 = 0.50 + 18.00 + 9.00 = 27.50
                    assertThat(calculator.calculate("standard", Duration.ofMinutes(150)))
                            .isEqualByComparingTo(new BigDecimal("27.50"));
                });
    }

    @Test
    void doesNotApplyWhenTheStarterClassIsMissing() {
        runner.withPropertyValues(MINIMAL_CONFIG)
                .withClassLoader(new FilteredClassLoader(FaresCalculator.class))
                .run(context -> assertThat(context)
                        .doesNotHaveBean(FaresCalculator.class));
    }

    @Configuration(proxyBeanMethods = false)
    static class OwnConfiguration {

        @Bean
        FaresCalculator faresCalculator() {
            FareProperties free = new FareProperties(
                    true, "EUR", Duration.ofHours(24), BigDecimal.ZERO,
                    Map.of("standard", new FareProperties.FareProfile(
                            BigDecimal.ZERO, BigDecimal.ZERO, 0)));
            return new FaresCalculator(free);
        }
    }
}
./mvnw test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time:  4.812 s

Comment: six tests that bring up six different contexts in under a second of effective execution. Compare that with what the same thing would cost using @SpringBootTest, which would start Tomcat six times.

A detail about the assertions: isEqualByComparingTo is used, not isEqualTo. With BigDecimal, new BigDecimal("4.10").equals(new BigDecimal("4.1")) is false, because equals also compares the scale. It is a classic mistake that produces baffling test failures; isEqualByComparingTo uses compareTo and compares only the numeric value.

A final tip on starter design: notice that the surcharge was added without breaking anyone. overtimeSurcharge has @DefaultValue("0.00") and maximumDuration has @DefaultValue("2h"), so an application that was already using version 1.0.0 gets exactly the same amounts after upgrading. That is the discipline that makes a starter usable: every new property arrives with a default value that preserves the previous behaviour.

Conclusion

That closes module 2 and, with it, the container's black box. You know that @EnableAutoConfiguration is simply an @Import of an ImportSelector that computes which configurations to load, and that the list of candidates is not magic but a text file —META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports— holding 158 class names, one per line, the successor to the spring.factories that Spring Boot 3 removed. You know the AutoConfigurationImportSelector's complete journey, including the optimisations that make evaluating 158 candidates cost milliseconds. You have mastered the conditional annotations and you understand why @ConditionalOnMissingBean is the heart of Spring Boot's philosophy: sensible defaults that step aside the moment you take control. You have read a real Spring Boot autoconfiguration line by line and discovered the customizer pattern, which is almost always better than replacing a whole bean. You know why @ConditionalOnBean needs @AutoConfigureAfter for company. And above all you know how to debug: start with --debug, go to Negative matches and read the exact condition that failed, which is the answer to the most frustrating question a Spring developer faces. To finish, you have built a complete starter, ciclourbana-fares-spring-boot-starter, with its conditional autoconfiguration, its typed and validated properties, its .imports file and six ApplicationContextRunner tests that verify not only that the bean works, but under which conditions it appears and under which it steps aside.

Look back for a moment. When the module began, CicloUrbana was a main class, a record, a controller and an in-memory store that did everything, with annotations copied by imitation. It now has properly separated layers —StationController, StationService, StationRepository with its in-memory implementation—, a fare system extensible through configuration, a cache with a managed lifecycle, typed and validated properties that fail the startup if someone configures a negative price, and a publishable starter of its own. And there is not a single annotation left in the project that you cannot explain.

It is time to come back up to the surface. Module 3, Building RESTful Web Services, leaves the container and enters the API the citizens of Ribalta will see: what REST really means and what it does not, how to design CicloUrbana's resources and URLs, how to write complete controllers with @GetMapping, @PostMapping, @PutMapping and @DeleteMapping, how to receive and validate incoming data, how to separate entities from the DTOs exposed to the outside world, how to turn an exception into a correct, well-formed HTTP response, and how to document all of it with OpenAPI so that other teams can integrate. Our single endpoint, GET /api/v1/stations, is about to become a complete API.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved