01-spring-foundations

Auto-configuration: How a JAR on the Classpath Becomes a Bean

Take apart @SpringBootApplication, follow the AutoConfiguration.imports mechanism, and learn why @ConditionalOnMissingBean means your own bean always wins.

October 8, 2026
spring-bootauto-configurationSpringBootApplicationconditionalscomponent-scanstarters

The Trick Worth Understanding

Create a Spring Boot project with one dependency and one class:

java
@SpringBootApplication
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Run it, and you have an HTTP server listening on port 8080, JSON serialisation configured, a /error endpoint, request logging, graceful shutdown and sensible content negotiation. You wrote none of it. There is no generated code, no XML, no hidden file.

The previous guide explained how the container wires beans given a set of definitions. This guide is about where those definitions came from. The answer is a mechanism simple enough to fully understand in one sitting — and worth understanding, because the day a bean you expected isn't there, or one you didn't expect is, this is the only knowledge that helps.

@SpringBootApplication Is Three Annotations

That single annotation is a composed shortcut:

java
@Configuration          // this class can declare @Bean methods
@EnableAutoConfiguration // bring in auto-configuration from the classpath
@ComponentScan          // find @Component classes in this package and below
public class ShopApplication { }

Each does one job.

@Configuration marks the class as a source of bean definitions — it may contain @Bean methods.

@ComponentScan scans for your own annotated classes. The crucial detail is the default starting point: the package of the annotated class, and everything beneath it. This is why project structure is not cosmetic, and why a class in a sibling package is silently invisible. The next guide covers the layout that follows from this.

@EnableAutoConfiguration is the interesting one. It tells Spring Boot to look at what is on the classpath and configure what it reasonably can.

Where the Candidate List Lives

Auto-configuration is not reflection over every class on the classpath — that would be unusably slow. Each library that wants to participate ships an explicit list.

In Spring Boot 3 and 4, that list is a plain text file inside the JAR:

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

One fully-qualified class name per line:

text
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
⚠️

You will find a great deal of writing — and a great many AI-generated answers — describing this as META-INF/spring.factories with an EnableAutoConfiguration key. That was the Spring Boot 1.x and 2.x mechanism. It was deprecated in 2.7 and removed for auto-configuration in Spring Boot 3.0. If you are writing your own starter and it isn't taking effect, this file name is the first thing to check. (spring.factories still exists in Spring Boot for a few other extension points — it is auto-configuration specifically that moved.)

So: starting up, Spring Boot reads every such file from every JAR, producing a candidate list of a hundred-plus auto-configuration classes. Clearly you do not want all of them. That's what conditions are for.

Conditions: The Part That Makes It Work

Every auto-configuration class is guarded. A condition is evaluated against the current application, and if it fails, the whole class is skipped. A lightly simplified real example:

java
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(DataSource.class)
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
 
    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource(DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }
}

Read it as English: if DataSource is on the classpath, and the application hasn't defined its own DataSource bean, create one from the spring.datasource.* properties.

The conditions you'll meet most:

ConditionFires when
@ConditionalOnClassA class is present on the classpath
@ConditionalOnMissingClassA class is absent
@ConditionalOnBeanA bean of some type already exists
@ConditionalOnMissingBeanNo bean of that type exists
@ConditionalOnPropertyA property has a given value
@ConditionalOnWebApplicationThe app is a web application (servlet or reactive)
@ConditionalOnResourceA resource (e.g. a file) is present

@ConditionalOnClass is how a classpath dependency becomes behaviour. Add spring-boot-starter-web and the servlet and Spring MVC classes appear, so the web auto-configurations begin matching, so you get an embedded Tomcat and a DispatcherServlet. Adding a dependency is the configuration step. That is the whole starter model, and the next guide covers which starters bring what.

💡

@ConditionalOnClass referring to a class that may not exist sounds like it should throw NoClassDefFoundError. It doesn't, because Spring Boot evaluates these conditions by reading bytecode metadata via ASM rather than loading the class. The annotation's value is never resolved as a real class reference during evaluation.

Why Your Own Bean Always Wins

@ConditionalOnMissingBean is the single most important condition, because it establishes the contract that makes auto-configuration safe: auto-configuration only ever fills gaps.

Define your own DataSource, ObjectMapper or RestClient.Builder and the corresponding auto-configuration backs off. You never "fight" the framework or disable something first — you simply declare the bean, and the default stands down.

java
@Configuration
public class JacksonConfig {
 
    @Bean
    public ObjectMapper objectMapper() {     // this one wins
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
    }
}

For this to work, ordering matters: user configuration must be processed before auto-configuration gets its turn. Spring Boot guarantees that — auto-configurations are applied last, which is why @ConditionalOnMissingBean sees your beans and not the other way round.

⚠️

The corollary bites people: @ConditionalOnMissingBean inside your own @Configuration class is unreliable, because your classes have no defined order relative to each other. The condition is designed for auto-configuration classes, which run in a well-defined phase. In your own configuration, be explicit instead.

Replacing a whole ObjectMapper is also usually heavier than needed. For Jackson specifically, Spring Boot offers a Jackson2ObjectMapperBuilderCustomizer so you can adjust the default rather than replace it — a pattern worth looking for generally, since most auto-configurations expose a *Customizer interface for exactly this.

Check yourself

You add spring-boot-starter-data-jpa but define no DataSource bean and set no spring.datasource.* properties. There is no database driver on the classpath either. What happens at startup?

Seeing What Actually Happened

Guessing about auto-configuration is unnecessary. Spring Boot will tell you exactly what matched and — more usefully — what didn't and why.

Run with the condition evaluation report:

bash
java -jar app.jar --debug

You get three sections:

  • Positive matches — auto-configurations that applied, with the condition that passed.
  • Negative matches — those that didn't, each with the reason.
  • Exclusions and unconditional classes.

The negative matches are where the answers live. A typical line:

text
DataSourceAutoConfiguration#dataSource:
   Did not match:
      - @ConditionalOnMissingBean found beans of type 'javax.sql.DataSource' dataSource

That is a complete explanation of "why isn't my property taking effect": something already defined a DataSource, so the auto-configured one never ran — and therefore never read spring.datasource.*.

With Actuator on the classpath, the same report is available as JSON at /actuator/conditions.

✅

"Why is this bean not what I expect?" has two reliable answers, and both beat reading source code. The condition report above tells you which configuration classes applied. And ApplicationContext#getBeanDefinitionNames, or Actuator's /actuator/beans, tells you what ended up in the container and where each definition came from.

Turning Things Off

Sometimes you want a default gone rather than replaced — commonly in tests, or when a starter pulls in more than you need.

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

Or as a property, which works well per-profile:

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

Reach for exclusion sparingly. If you are excluding an auto-configuration to then write your own version of the same beans, defining the bean is cleaner — @ConditionalOnMissingBean already handles it. Exclusion is for when you want nothing there.

Properties Are the Supported Seam

Before replacing a bean, check whether a property already does what you need. Auto-configurations read their settings from @ConfigurationProperties classes, and those are deliberately broad:

yaml
server:
  port: 9000
spring:
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
  datasource:
    hikari:
      maximum-pool-size: 20

None of that required a @Bean. This is the layering to internalise, and it's a rough order of preference:

  1. A property — supported, visible, environment-specific.
  2. A *Customizer bean — adjusts the default without owning it.
  3. Your own @Bean — full control, and you now own it across upgrades.

That third option is a real cost, not a hypothetical one: a hand-rolled bean stops inheriting improvements and new defaults when you upgrade Spring Boot. The fourth guide in this phase deals with properties in depth — where they come from, what overrides what, and the type-safe way to read them.

Check yourself

Your app's JSON responses still serialise dates as numeric timestamps even though you set spring.jackson.serialization.write-dates-as-timestamps=false. The --debug report shows JacksonAutoConfiguration#jacksonObjectMapper did not match: '@ConditionalOnMissingBean found beans of type ObjectMapper'. What is going on?

The Mental Model, Restated

  1. @SpringBootApplication = @Configuration + @EnableAutoConfiguration + @ComponentScan — your beans, framework defaults, and where to scan.
  2. Candidates come from AutoConfiguration.imports files in JARs — an explicit list, not classpath scanning. Not spring.factories since Boot 3.0.
  3. Conditions decide what applies. @ConditionalOnClass is why adding a dependency configures a feature.
  4. @ConditionalOnMissingBean means auto-configuration only fills gaps — your bean always wins, because auto-configuration runs last.
  5. --debug prints the condition report. The negative matches explain nearly every "why isn't this configured" question.

Auto-configuration is not magic, and the word does it a disservice. It is a list of classes, a set of conditions, and a rule that your definitions take precedence.

What's Next

Component scanning starts at the package of your main class, and adding a dependency is how you turn features on. Both of those facts have direct consequences for how you lay a project out and which starters you choose. The next guide covers bootstrapping with Spring Initializr, what the starter POMs actually contain, and the package structure that keeps component scanning working with you rather than against you.