How Spring Boot Auto-Configuration Actually Works
Auto-configuration is not magic: it is ordinary @Configuration classes loaded from a manifest and switched on or off by conditions you can inspect.
Spring Boot feels like magic: add a JDBC driver and a DataSource appears, drop in spring-boot-starter-web and an embedded Tomcat boots on port 8080. There is no magic. Auto-configuration is just ordinary @Configuration classes that ship inside library jars, loaded from a manifest, and switched on or off by conditions you can read and override. Once you see the four moving parts, the behavior becomes completely predictable.
1. @SpringBootApplication is three annotations
The annotation you put on your main class is a meta-annotation that composes three others. This is the crisp answer an interviewer is looking for:
@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
// is roughly equivalent to:
@SpringBootConfiguration // itself meta-annotated with @Configuration
@EnableAutoConfiguration
@ComponentScan
public class MyApp { ... }
Each pulls its own weight:
@SpringBootConfigurationis a specialization of@Configuration; it marks the class as a source of bean definitions (and, with the defaultproxyBeanMethods = true, enables CGLIB proxying so inter-bean method calls return the shared singleton).@ComponentScanscans the package of the annotated class and its sub-packages for your@Component,@Service,@Controller, etc. This is why people get bitten when the main class sits in a parent package that does not enclose all their beans.@EnableAutoConfigurationis the interesting one. It does not scan your code at all — it loads configuration classes that third-party libraries ship, and applies them conditionally.
The mental split matters: @ComponentScan finds beans you wrote; @EnableAutoConfiguration brings in beans the framework and starters wrote on your behalf.
2. Where auto-config classes come from
@EnableAutoConfiguration imports AutoConfigurationImportSelector, which asks: "which auto-configuration classes are on the classpath?" It answers by reading a manifest file from every jar.
Before Spring Boot 2.7 this lived in META-INF/spring.factories under the key org.springframework.boot.autoconfigure.EnableAutoConfiguration:
# META-INF/spring.factories (legacy)
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
From 2.7 onward the list moved to a plain newline-delimited file, and in Spring Boot 3.x the spring.factories entry for auto-configuration is no longer honored at all — only this file is read:
# META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
The selector reads these entries from all jars on the classpath, dedupes them, removes anything in the exclude list, and hands the survivors to the context as candidate configuration classes. At this stage nothing has been instantiated — it is just a list of fully-qualified class names. The key insight: a class being on this list does not mean it runs. Whether it runs is decided next, by conditions.
3. Conditions decide what actually loads
Every auto-configuration class — and most of the @Bean methods inside it — is gated by @Conditional annotations. The container evaluates these against the current classpath, environment, and the beans defined so far. The three you will see constantly:
@ConditionalOnClass— apply only if a given type is on the classpath. This is how a library safely references optional dependencies: the condition is evaluated by reading class metadata (via ASM, without loading the class), so a missing class is simply reported absent rather than throwingNoClassDefFoundError.@ConditionalOnMissingBean— define this bean only if the user has not already defined one of that type. This is the back-off mechanism that lets your beans win.@ConditionalOnProperty— apply only when a configuration property has a given value, letting features be toggled fromapplication.yml.
A simplified sketch of the JDBC auto-configuration shows all three working together (the real DataSourceAutoConfiguration delegates pooled-DataSource beans to nested @Configuration classes such as DataSourceConfiguration.Hikari, but the conditions are the same):
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(
name = "spring.datasource.type",
havingValue = "com.zaxxer.hikari.HikariDataSource",
matchIfMissing = true)
public HikariDataSource dataSource(DataSourceProperties props) {
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
}
}
Read it top to bottom and the "magic" evaporates. The whole class is skipped unless a JDBC DataSource type is on the classpath (@ConditionalOnClass). If it survives, the bean is created only when you have not defined your own DataSource (@ConditionalOnMissingBean) and you have not selected a different pool (@ConditionalOnProperty with matchIfMissing = true, so the default applies when the property is absent). That is the entire decision tree.
One subtlety worth knowing for interviews: @ConditionalOnMissingBean only reliably sees beans defined before it is evaluated. Auto-configurations are deliberately ordered to run after user configuration, which is why your beans are visible when the back-off check happens. The ordering is controlled by @AutoConfiguration(before = ..., after = ...) and was historically done with @AutoConfigureBefore / @AutoConfigureAfter.
4. Starters are just curated dependency bundles
A starter contains almost no code. spring-boot-starter-web is essentially a pom.xml with no classes of its own that pulls in a coherent set of transitive dependencies: Spring MVC (spring-webmvc), JSON support via Jackson (spring-boot-starter-json), and embedded Tomcat (spring-boot-starter-tomcat). Note that bean-validation (Hibernate Validator) is not dragged in by the web starter — since Spring Boot 2.3 you add spring-boot-starter-validation explicitly if you want it. A starter's only job is to put the right classes on the classpath so the relevant @ConditionalOnClass guards flip to true.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
That is the clean separation of concerns: starters control the classpath; auto-configurations react to the classpath. Adding spring-boot-starter-web does not "turn on web" by some registration call — it simply makes DispatcherServlet and Tomcat classes available, and the conditions in WebMvcAutoConfiguration and ServletWebServerFactoryAutoConfiguration do the rest. Exclude the Tomcat starter and add spring-boot-starter-jetty instead, and a different set of conditions matches; you changed the classpath, not any wiring code.
5. How your @Bean overrides a default
This is the question that trips people up, and the answer is delightfully simple: you do not "override" anything. The default backs off. Because the framework's bean is guarded by @ConditionalOnMissingBean, defining your own bean of the same type makes the framework's condition evaluate to false, and its definition is never registered.
@Configuration
public class DataConfig {
@Bean
public DataSource dataSource() {
// Spring Boot's DataSourceAutoConfiguration sees this
// bean already exists and backs off entirely.
return new HikariDataSource(myCustomConfig());
}
}
There is no precedence rule, no "user wins" flag — it is the same condition machinery doing its job. This also explains a classic gotcha: @ConditionalOnMissingBean defaults to matching by type, so even if you give your bean a different name, the default still backs off. The failure case is the opposite one: if a condition is keyed on a specific bean name you did not use, or you register two beans of the same type without telling Spring which to prefer, you get a NoUniqueBeanDefinitionException when something injects that type by type alone. (Two beans sharing the same name fail even earlier, because bean-definition overriding is disabled by default since Spring Boot 2.1.) Knowing it is condition-driven, not magic-driven, lets you reason about exactly when back-off happens.
6. Debugging with the conditions report
When something is not wiring the way you expect — a bean you wanted is missing, or one you did not want appeared — do not guess. Run with the debug flag and read the conditions evaluation report:
java -jar app.jar --debug
# or in application.properties:
debug=true
This prints a report at startup with three sections that map directly onto everything above:
- Positive matches — auto-configurations that applied, and why (e.g. "
@ConditionalOnClass found required class DataSource"). - Negative matches — what was skipped and the failing condition (e.g. "
@ConditionalOnMissingBean found beans of type DataSource", which tells you your bean won the back-off). - Exclusions / unconditional classes — what you explicitly turned off or what always runs.
For a JSON-formatted, queryable view of the same data, the Actuator conditions endpoint (/actuator/conditions) exposes it at runtime. In practice, "why is this bean missing?" is almost always answered by searching the negative matches for the class name and reading the one-line reason. That report is the ground truth — it is the literal output of the condition evaluation we have been describing.
Rules of thumb
- It is just conditional
@Configuration. Auto-config classes are listed inAutoConfiguration.imports(or legacyspring.factories) and gated by@Conditional*— being on the list does not mean a class runs. - Starters are classpath, not code. They pull in dependencies so
@ConditionalOnClassguards match; they do not register beans themselves. - You never override a default — it backs off.
@ConditionalOnMissingBean(matched by type) is why your@Beanwins, and auto-config runs after your config so your beans are visible. - When in doubt, read the report.
--debugor/actuator/conditionstells you exactly which conditions matched and why — stop guessing. - Mind the scan boundary. Put your main class in a root package;
@ComponentScanonly sees that package and below, independent of whatever auto-config brings in.