Starters and auto-configuration

What spring-boot-starter-web brings, how @ConditionalOnClass decides, and how to read the conditions report when a bean is not there.

13 min read🚀 Spring Boot

Add one dependency and a web server, a JSON mapper, an error page and a logging setup appear. Nothing about that is magic, and a senior engineer is expected to know exactly where each of those beans came from, why one of them did not appear, and how to replace one without fighting the framework. Spring Boot is Spring plus two ideas: a starter is a shopping list, and auto-configuration is a set of @Configuration classes that switch themselves on and off based on what is on the classpath and what you have already declared.

What a starter is

A starter contains no code. spring-boot-starter-web is a POM whose job is to pull in the right versions of the right libraries:

spring-boot-starter-web, transitivelyplaintext
spring-boot-starter            → spring-boot, auto-configure, logging (Logback), SnakeYAML
spring-boot-starter-json       → Jackson, plus the JSR-310 and parameter-names modules
spring-boot-starter-tomcat     → embedded Tomcat
spring-web, spring-webmvc      → the framework itself

The value is in the versions. spring-boot-dependencies is a bill of materials that pins several hundred libraries to versions known to work together. That is why a Boot project declares dependencies without a version, and why upgrading Boot is one number in one place. Declaring your own version of Jackson "to get a fix" opts out of that guarantee for that library; do it knowingly and remove it at the next Boot upgrade.

Auto-configuration classes

An auto-configuration is an ordinary @Configuration class with conditions on it. Boot finds them through a file the auto-configure jar ships: META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, one class name per line. This is a simplified version of the real one for a DataSource:

A shape like DataSourceAutoConfigurationjava
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
 
    @Bean
    @ConditionalOnMissingBean(DataSource.class)
    HikariDataSource dataSource(DataSourceProperties props) {
        return props.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }
}

Read the conditions top down, because that is the order they are evaluated:

ConditionAnswers
@ConditionalOnClassIs the library on the classpath? Evaluated from bytecode metadata, so the class does not need to load.
@ConditionalOnMissingBeanHas the application already declared one of these? If so, stand down.
@ConditionalOnBeanIs a bean this one depends on present?
@ConditionalOnPropertyIs a feature switched on, e.g. spring.jpa.open-in-view?
@ConditionalOnWebApplicationServlet or reactive, or not a web app at all?

The crucial detail is when these run. Auto-configuration classes are imported through a deferred import selector, which means they are processed after every @Configuration and @Component in your own application. That ordering is what makes @ConditionalOnMissingBean work: your DataSource bean is already registered by the time the auto-configuration asks whether one exists, so it backs off. Put a @ConditionalOnMissingBean on a bean in your own @Configuration and it will fire at the wrong time, because your classes are not deferred; it is an annotation for auto-configuration, not for application code.

Reading the conditions evaluation report

"The bean is not there" is the most common Boot question, and the framework already wrote the answer down. Start the application with --debug (or debug=true in properties) and the log prints the CONDITIONS EVALUATION REPORT; with Actuator, /actuator/conditions returns the same thing as JSON. It has four sections:

A negative match, as the report prints itplaintext
Negative matches:
-----------------
   KafkaAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class 'org.springframework.kafka.core.KafkaTemplate' (OnClassCondition)
 
   DataSourceAutoConfiguration.PooledDataSourceConfiguration#dataSource:
      Did not match:
         - @ConditionalOnMissingBean (types: javax.sql.DataSource; SearchStrategy: all) found beans of type 'javax.sql.DataSource' reportingDataSource (OnBeanCondition)
  • Positive matches: configurations that applied, with the condition that let them.
  • Negative matches: configurations that did not, with the first failing condition. Conditions short-circuit, so a class missing on the classpath hides every later condition.
  • Exclusions: what you excluded explicitly.
  • Unconditional classes: the handful that always apply.

Search the report for the bean you expected. In the second example above the answer is already there: a reportingDataSource was declared, so Boot's pooled data source stood aside, which is exactly right. When the report says a class was not found, the fix is a starter; when it says a bean was found, the fix is to decide which one you meant.

Overriding an auto-configured bean

Declare your own. That is the whole mechanism, and it is why almost every auto-configured bean carries @ConditionalOnMissingBean:

ObjectMapperConfig.javajava
@Configuration
class ObjectMapperConfig {
    @Bean
    ObjectMapper objectMapper() {                    // Boot's JacksonAutoConfiguration now backs off
        return JsonMapper.builder()
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
    }
}

But notice what you gave up: Boot's ObjectMapper was built from spring.jackson.* properties and from every Jackson2ObjectMapperBuilderCustomizer bean in the context. Replacing it wholesale throws that away, and the date format you set in application.yml silently stops applying. Most auto-configurations offer a customizer hook for exactly this reason, and it is the better tool: a Jackson2ObjectMapperBuilderCustomizer bean adjusts Boot's mapper instead of replacing it. The same pattern exists for RestClient.Builder, Tomcat (WebServerFactoryCustomizer), Hikari and most others. Replace when you need a different thing; customise when you need the same thing configured differently.

Overriding by name is different from overriding by type, and Boot refuses it: spring.main.allow-bean-definition-overriding is false, so two bean definitions with the same name fail startup with a clear message rather than one silently winning. Leave that setting alone; a codebase that needs it has two configurations fighting.

Excluding auto-configuration

Sometimes you want a whole auto-configuration gone, not a bean replaced. The classic case is a service that has a database driver on the classpath for a batch job but should not open a pool at boot:

java
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class Application { }

or, in properties, spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration. The property form lets a profile decide, which matters for test contexts. Exclusions appear in their own section of the report, so nobody has to guess later why there is no DataSource.

Excluding is a blunt tool. Before reaching for it, check whether the auto-configuration has a property switch (spring.jpa.open-in-view, spring.sql.init.mode, management.health.db.enabled) because a switch is documented, discoverable and survives an upgrade. Exclusions reference a class by name and break loudly when Boot renames or splits one, which the Boot 4 line did when it broke the single auto-configure jar into per-technology modules.

Writing your own

An internal platform team ships a starter for the company's tracing headers, and it is the same three parts: a *-autoconfigure module with @AutoConfiguration classes and the AutoConfiguration.imports file, @ConfigurationProperties for its settings, and a *-starter POM that depends on both. Every bean carries @ConditionalOnMissingBean so any consuming service can override it, and @ConditionalOnClass guards on the library the feature needs. Add spring-boot-configuration-processor so the IDE completes the properties. The result behaves exactly like Boot's own, and the conditions report explains it the same way.

Under the hood: how the imports file becomes beans

@SpringBootApplication is three annotations, and the one that matters here is @EnableAutoConfiguration, which carries @Import(AutoConfigurationImportSelector.class). That selector is a DeferredImportSelector, and "deferred" is the whole design. When the context refreshes, ConfigurationClassPostProcessor parses your @Configuration classes and everything component scanning found, registering their bean definitions (not instances) first. Only after that pass does it call the deferred selectors, which read every AutoConfiguration.imports file on the classpath, drop anything in spring.autoconfigure.exclude, sort the survivors by @AutoConfigureBefore/@AutoConfigureAfter/@AutoConfigureOrder, and parse them as configuration classes too.

Conditions are evaluated in two phases, and each annotation knows which. @ConditionalOnClass and @ConditionalOnProperty run in PARSE_CONFIGURATION, before the class is even read as configuration: a missing class means the auto-configuration is skipped without loading it, which is why Boot can ship a Kafka auto-configuration in every jar without Kafka being present. @ConditionalOnBean and @ConditionalOnMissingBean run in REGISTER_BEAN, when the bean-definition registry is as complete as it will get, which is what lets them see your DataSource. That second phase is also why they inspect bean definitions, matching on declared type and never instantiating anything: a bean whose type is only known after a factory method runs (Object createIt()) is invisible to them, which is one of the few times the report and the running context disagree.

1. your configuration @Configuration + scan → bean definitions 2. deferred imports read the .imports files skip: OnClass fails (parse phase) sort, parse; OnMissingBean checks registry (bean phase) 3. instantiate singletons created dependency order your DataSource definition exists after step 1, so Boot's @ConditionalOnMissingBean(DataSource) sees it at step 2 and stands down
Deferred means "after yours": the ordering that makes back-off work, and the reason the same annotation misfires in application code.

The imports file itself replaced spring.factories in Boot 2.7 and 3.0, and it is why an auto-configuration must be listed rather than merely present on the classpath: component scanning never finds auto-configurations, by design, because a scanned @Configuration would be parsed in step 1 and lose its deferral. Startup cost is proportional to the number of candidates: a Boot 3 jar lists about 150, most rejected in the parse phase by a cheap class-presence check against the jar's index. That is the work the AOT engine (spring-boot-starter-parent with the process-aot goal, and the GraalVM native path) removes by evaluating conditions at build time and generating plain Java bean registrations, which is also why a native image cannot change its auto-configuration by flipping a property at run time.

Walkthrough: the second ObjectMapper

A service upgraded a shared internal library and its date fields began serialising as epoch numbers instead of ISO strings.

acme-json-support, an internal libraryjava
@Configuration
public class AcmeJsonConfig {
    @Bean ObjectMapper acmeObjectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
  1. The library added a @Configuration with an ObjectMapper bean and was picked up by the service's component scan, since it lived under the company's root package. Step 1 registered acmeObjectMapper.
  2. At step 2, JacksonAutoConfiguration's @ConditionalOnMissingBean(ObjectMapper.class) found a definition of that type and stood down. Boot's mapper, the one configured from spring.jackson.* and every Jackson2ObjectMapperBuilderCustomizer, was never created.
  3. Spring MVC's message converters took the only ObjectMapper in the context: the library's. WRITE_DATES_AS_TIMESTAMPS, which Boot disables, was back to Jackson's default of on. Every API response changed shape.
  4. Nothing failed: no exception, no warning, a green build, and a consumer whose date parsing broke an hour after deploy. The conditions report told the story in one entry:
plaintext
JacksonAutoConfiguration#jacksonObjectMapper:
   Did not match:
      - @ConditionalOnMissingBean (types: ObjectMapper) found beans of type 'ObjectMapper' acmeObjectMapper
  1. The library was fixed to contribute a Jackson2ObjectMapperBuilderCustomizer (the modules it wanted, on Boot's mapper) instead of a mapper, and the service added a test asserting that Instant serialises as a string, so the next library upgrade would fail in CI rather than in a consumer.

The general form: a library that declares a bean of a type Boot auto-configures silently replaces Boot's, and takes Boot's properties with it. Libraries should customise; only applications should replace.

Try it yourself

Which bean wins?

An application declares @Bean DataSource reporting() in a @Configuration and has spring-boot-starter-data-jpa plus the Postgres driver on the classpath. No other data source is declared. What does DataSourceAutoConfiguration do, and what does JPA use?

Answer

Boot's pooled DataSource bean has @ConditionalOnMissingBean(DataSource.class); at the register-bean phase it finds reporting and stands down, so the application has exactly one DataSource, the reporting one, and JPA's EntityManagerFactory injects it by type. Whether that is what was wanted is the question; the report's negative match names reporting as the reason. To keep Boot's pool and a second one, mark one @Primary and give the second a qualifier; then Boot still backs off (a DataSource exists) and both must be declared by the application.

Why does the condition fire at the wrong time?

A team puts @ConditionalOnMissingBean(Clock.class) on a @Bean Clock clock() in their own AppConfig, expecting a test's @TestConfiguration Clock to replace it. It sometimes works and sometimes not. Why?

Answer

AppConfig is not deferred: it is parsed in step 1, in whatever order the scanner visits classes, and the condition sees only the definitions registered before it. If the test configuration happened to register first, the condition backs off; if not, both exist and the test gets Boot's NoUniqueBeanDefinitionException or the wrong one. @ConditionalOnMissingBean has defined semantics only in auto-configuration. The fix is @MockitoBean/@TestConfiguration with @Primary, or a real auto-configuration module for the default.

Read the report

plaintext
KafkaAutoConfiguration:
   Did not match:
      - @ConditionalOnClass did not find required class 'org.apache.kafka.clients.KafkaClient'

The team has spring-kafka in the POM. What is the most likely cause, and how do you confirm it?

Answer

The class check runs against the classpath the application actually started with, so spring-kafka is declared but not present at run time: a <scope>test</scope> or provided, a dependency excluded transitively by another module, or a fat jar built from a different profile. Confirm with mvn dependency:tree -Dincludes=org.apache.kafka (or gradle dependencies) and, on the running JVM, jar tf app.jar | grep kafka-clients. The report is telling the truth about the classpath; the POM is telling a story about intent.

Misconceptions

  • "Auto-configuration is component scanning of Boot's jars." Auto-configurations are listed in an imports file and imported through a deferred selector, precisely so they are parsed after your classes; scanning them would break back-off.
  • "@ConditionalOnMissingBean works anywhere." Only in auto-configuration, where the registry is complete. In application code its result depends on scan order.
  • "A starter contains the auto-configuration." A starter is a POM. The auto-configuration lives in spring-boot-autoconfigure (or a library's *-autoconfigure module) and is guarded by @ConditionalOnClass on what the starter brings.
  • "Declaring my own bean adds to Boot's." It replaces Boot's, and drops the properties and customizers Boot would have applied. Customizers add; declarations replace.
  • "Excluding an auto-configuration is the clean way to disable a feature." It is a class-name reference that breaks on rename; a property switch is the documented way, when one exists.

Going deeper

  • Spring Boot reference, "Developing with Spring Boot → Auto-configuration" and "Creating Your Own Auto-configuration".
  • AutoConfigurationImportSelector and ConfigurationClassParser in the Boot and Framework sources: the deferred pass is about two hundred lines.
  • ConfigurationCondition.ConfigurationPhase Javadoc, the two phases.
  • Boot's spring-boot-autoconfigure jar, META-INF/spring/…AutoConfiguration.imports: read the list once.
  • Spring Boot reference, "GraalVM Native Images → Understanding Spring AOT", for what build-time condition evaluation changes.
Progress is saved on this device and to your account when signed in.