How does Spring Boot auto-configuration actually work?
Nothing scans your code. Jars ship a text file listing auto-configuration classes; Boot loads that list, drops every class whose @ConditionalOnClass is absent from the classpath, and applies what survives after your own configuration — which is why @ConditionalOnMissingBean sees your bean and backs off. --debug prints the report.
The Answer
Say this in the room. 45 seconds.
@SpringBootApplicationis three annotations, and the one that does this is@EnableAutoConfiguration. Open it and there is one thing inside:@Import(AutoConfigurationImportSelector.class).- That selector reads a text file out of every jar on the classpath —
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, one fully-qualified class name per line. - So nothing scans your code for intent. The candidate list is fixed by which jars you depend on, and it was written by the people who built those jars. If you say one sentence, say that one.
- Each candidate is a configuration class guarded by
@Conditional. The decisive one is@ConditionalOnClass, and it is a classloader lookup — is this type present? That is the whole of "add the jar and it configures itself": the jar is the input. - The selector is a
DeferredImportSelector, so auto-configuration is registered after all of your own configuration. That ordering is the reason@ConditionalOnMissingBeanworks at all: by the time Boot's conditions are evaluated, your beans are already defined, so Boot's back off. - Don't believe any of it — look. Run with
--debug, or hit/actuator/conditions. You get positive matches, negative matches with the reason each was rejected, and the exclusions.
Understand It
It is a list in a file, not a scan
The word "auto" invites everyone to imagine something clever, and the mechanism is deliberately dull. Every jar that wants to contribute configuration ships a plain text file at a fixed path, and Boot reads it:
# One fully-qualified class name per line. Read it yourself — it is just a file.
unzip -p ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.3.2/spring-boot-autoconfigure-3.3.2.jar \
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
# How many candidates is your application considering? Count them.
unzip -p spring-boot-autoconfigure-3.3.2.jar \
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | wc -l
That path is the Boot 2.7-and-later form. Before it, the same list lived in META-INF/spring.factories under the key org.springframework.boot.autoconfigure.EnableAutoConfiguration, read by SpringFactoriesLoader — Spring's general-purpose "who implements this interface" lookup. Declaring auto-configuration there was deprecated in 2.7 and stopped being read in Boot 3.0, which is why a library shipping only the old file silently contributes nothing to a Boot 3 application. No error, no warning; its auto-configuration is simply never a candidate.
Be precise about what changed, because this is a question interviewers use to catch people repeating a headline. spring.factories was not removed, and neither was SpringFactoriesLoader. Both are alive in Boot 3 and still how you register an ApplicationContextInitializer, an EnvironmentPostProcessor, a SpringApplicationRunListener or a failure analyzer. Only the auto-configuration key moved out, into its own file read by ImportCandidates — one list, given one dedicated file, so the most-read entry point is no longer a lookup into a general registry.
@ConditionalOnClass is one classloader lookup
A candidate is a configuration class with conditions on it. Strip away Spring and the mechanism is small enough to run:
Registry registry = new Registry();
System.out.println("auto-configuration report — 5 candidates, bare JDK classpath:");
printReport(autoConfigure(registry));
System.out.println();
System.out.println("the container ended up with: " + registry.names());auto-configuration report — 5 candidates, bare JDK classpath:
DataSourceAutoConfiguration applied dataSource = HikariDataSource (Boot's default)
RestClientAutoConfiguration applied restClient = RestClient over the JDK HttpClient
HibernateJpaAutoConfiguration skipped @ConditionalOnClass: no org.hibernate.SessionFactory
JacksonAutoConfiguration skipped @ConditionalOnClass: no com.fasterxml.jackson.databind.ObjectMapper
WebMvcAutoConfiguration skipped @ConditionalOnClass: no org.springframework.web.servlet.DispatcherServlet
the container ended up with: [dataSource, restClient]Two applied and three were skipped, and the three were skipped for the only reason that matters: the class their condition named is not on this classpath. javax.sql.DataSource and java.net.http.HttpClient ship with the JDK, so those two conditions are satisfied; Hibernate, Jackson and Spring MVC are not here, so those three candidates never contribute a bean.
That is the real behaviour, and it explains the thing people find magical. You did not configure Jackson — you added a dependency, and a condition that had been failing every startup since the project began started passing.
The classes in that report are a model, not Spring's. The names are Boot's and the rules are Boot's, but each
@ConditionalOnClasshas been re-pointed at a class that is or is not in the JDK, so the report is produced by a realClass.forNameagainst a real classpath rather than by a lookup table. That the whole mechanism fits in about a hundred lines of plain Java is roughly the point — use it to understand why a bean is or is not there, and--debugto find out which classes actually were.
Your bean wins because it was registered first
@ConditionalOnMissingBean is the annotation that makes Boot feel co-operative: define a DataSource and Boot's default quietly steps aside. People describe this as Boot "preferring" your bean, which makes it sound like a policy. It is not a policy — it is an ordering.
Registry registry = new Registry();
// Your own @Bean. Boot processes your configuration before any of its own.
registry.register("dataSource", "MyDataSource (yours)");
System.out.println("with a dataSource of your own already registered:");
printReport(autoConfigure(registry));
System.out.println();
System.out.println("dataSource -> " + registry.get("dataSource"));with a dataSource of your own already registered:
DataSourceAutoConfiguration skipped @ConditionalOnMissingBean: you already defined dataSource
RestClientAutoConfiguration applied restClient = RestClient over the JDK HttpClient
HibernateJpaAutoConfiguration skipped @ConditionalOnClass: no org.hibernate.SessionFactory
JacksonAutoConfiguration skipped @ConditionalOnClass: no com.fasterxml.jackson.databind.ObjectMapper
WebMvcAutoConfiguration skipped @ConditionalOnClass: no org.springframework.web.servlet.DispatcherServlet
dataSource -> MyDataSource (yours)DataSourceAutoConfiguration is reported as skipped, and the reason names your bean. Nothing overrode anything: the condition asked "does a dataSource bean exist yet?", the answer was yes, and Boot's @Bean method was never called.
Reverse the order and it is a startup failure, not a silent loss
If that ordering were merely cosmetic, getting it wrong would give you Boot's bean instead of yours. It is not cosmetic:
Registry registry = new Registry();
autoConfigure(registry); // auto-configuration first — the bug
System.out.println("auto-configuration ran first, so it saw nothing missing:");
System.out.println(" dataSource -> " + registry.get("dataSource"));
try {
registry.register("dataSource", "MyDataSource (yours)");
} catch (IllegalStateException e) {
System.out.println("and now your own bean cannot be registered at all:");
System.out.println(" " + e.getMessage());
}auto-configuration ran first, so it saw nothing missing:
dataSource -> HikariDataSource (Boot's default)
and now your own bean cannot be registered at all:
Invalid bean definition with name 'dataSource': there is already [HikariDataSource (Boot's default)] boundSince Boot 2.1, spring.main.allow-bean-definition-override defaults to false, so a genuine duplicate definition is a startup failure rather than a last-writer-wins race. Two beans of the same name cannot coexist — which means the deferred ordering is not a convenience, it is the thing that keeps your configuration and Boot's from colliding at all.
The condition can only see what has already been registered
This is where the mechanism has a sharp edge, and it follows directly from the above. @ConditionalOnMissingBean is a question about the registry at the moment it is asked. That gives a reliable answer in exactly one direction:
- Your configuration versus an auto-configuration — reliable. All user configuration is processed before any auto-configuration, so the condition always sees your beans.
- One auto-configuration versus another — not reliable by default. Both are in the deferred batch, and the one evaluated first cannot see beans the second has not defined yet. This is what
@AutoConfigureBefore,@AutoConfigureAfterand@AutoConfigureOrderexist for, and why omitting them produces a bug that depends on file ordering. - Your configuration versus your own other configuration — never do it. Spring's own documentation restricts
@ConditionalOnMissingBeanto auto-configuration classes for this reason: there is no defined processing order between two of your@Configurationclasses, so the condition's answer is whatever happened to be registered first.
That last bullet is the practical one. A @ConditionalOnMissingBean on a bean in your own application code works on your machine and fails in CI, and the reason is that it was never a defined question.
Why a hundred-plus candidates do not cost you a hundred-plus class loads
Boot could evaluate conditions by loading each candidate and reading its annotations reflectively. At well over a hundred candidates that would be measurable startup cost, most of it spent loading classes that are about to be discarded.
It does not. spring-boot-autoconfigure also ships META-INF/spring-autoconfigure-metadata.properties, generated at its build time, recording each candidate's class conditions as plain strings. An AutoConfigurationImportFilter checks that metadata first and drops the candidates that cannot possibly match, before those classes are ever loaded. The survivors then get the full treatment.
Two things follow. Startup is not linear in the size of the candidate list — it is closer to linear in what your classpath actually supports. And the --debug report distinguishes candidates filtered by metadata from candidates evaluated and rejected, which is a hint about where to look when a condition does not behave.
Reference
The shapes, what each costs, and how to see the result. Copy from here.
What @SpringBootApplication expands to
// These three, and nothing else. Replacing the shorthand with them changes nothing.
@SpringBootConfiguration // @Configuration, plus "this is the primary config" for tests
@EnableAutoConfiguration // @Import(AutoConfigurationImportSelector.class) + @AutoConfigurationPackage
@ComponentScan // scans YOUR packages, from this class down — unrelated to the above
public class Application { }
The distinction in that last line is the one interviews probe. @ComponentScan finds your @Service and @Component classes by scanning your package tree. @EnableAutoConfiguration finds library configuration by reading a file. Different mechanisms, different inputs, and conflating them is the most common wrong answer to this question.
The conditions worth knowing
| Condition | Asks | Typical use |
|---|---|---|
@ConditionalOnClass | Is this type loadable? | "Jackson is on the classpath, so configure JSON" |
@ConditionalOnMissingClass | Is this type absent? | Pick a fallback implementation |
@ConditionalOnBean | Does a bean of this type exist? | Configure something that needs a DataSource |
@ConditionalOnMissingBean | Does it not exist? | Back off if the application defined its own |
@ConditionalOnProperty | Is a property set to a value? | Feature flags; matchIfMissing sets the default |
@ConditionalOnResource | Is a file on the classpath? | Configure only if mybatis-config.xml is present |
@ConditionalOnWebApplication | Servlet, reactive, or neither? | Web-only beans |
@ConditionalOnSingleCandidate | Exactly one, or one @Primary? | Avoid ambiguity errors |
@ConditionalOnExpression | A SpEL expression | Last resort; hard to read and hard to test |
Excluding one
// By class, when you have the type available.
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
// By name, when you do not want a compile dependency on it.
@SpringBootApplication(excludeName = "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration")
# Or from configuration, which is the form that works without a rebuild.
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
Boot fails fast if you exclude a class that is not actually an auto-configuration candidate — "The following classes could not be excluded because they are not auto-configuration classes". A typo is a startup error, not a silently ignored line, which is the right trade.
Writing your own
// Boot 3: @AutoConfiguration, not @Configuration. It implies
// proxyBeanMethods = false and carries the ordering attributes.
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(AuditWriter.class)
@ConditionalOnProperty(prefix = "audit", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(AuditProperties.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean // let the application replace it
AuditWriter auditWriter(AuditProperties properties, DataSource dataSource) {
return new JdbcAuditWriter(dataSource, properties.table());
}
}
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.audit.AuditAutoConfiguration
The class must be listed in that file and must not be in a package your application's @ComponentScan reaches. An auto-configuration that also gets component-scanned is processed twice, in the wrong phase, and its @ConditionalOnMissingBean stops meaning anything.
Seeing what happened
# The full condition evaluation report: positive matches, negative matches
# with reasons, exclusions, and unconditional classes.
java -jar app.jar --debug
# Narrow it to one candidate.
java -jar app.jar --debug 2>&1 | grep -A4 DataSourceAutoConfiguration
# Or expose it as an endpoint and read it as JSON.
management.endpoints.web.exposure.include=conditions
# -> GET /actuator/conditions
--debug is not "more logging" — it specifically triggers the ConditionEvaluationReport. Most people who have used Boot for years have never seen it, and it answers "why is this bean not there" in one command.
Scenarios
Real situations, with the decision and the argument.
"It works locally and there is no DataSource in production"
Someone has a spring-boot-starter-jdbc dependency at runtime scope in one module and not another, or a profile that excludes it. The classpath differs, so the candidate list differs, so the bean set differs. Nothing in the code changed.
Run the failing environment with --debug and read the negative matches. You are looking for DataSourceAutoConfiguration with a @ConditionalOnClass rejection — which tells you it is a packaging problem, not a configuration problem, and sends you to mvn dependency:tree rather than to application.yml.
A library's auto-configuration stopped working after the Boot 3 upgrade
The library ships META-INF/spring.factories. Boot 3 does not read it. There is no error because there is no candidate — the list simply does not contain the class, and a class that is not a candidate cannot report a condition failure.
Confirm it in ten seconds: unzip -l the-library.jar | grep -i 'spring.factories\|AutoConfiguration.imports'. If only the former is present, the fix is the library's, and until it ships you can @Import the configuration class directly as a workaround — accepting that you have also opted out of its ordering and conditions.
"Auto-configuration is slowing down our startup"
Almost always false, and the metadata filter above is why. Measure before you act: --debug reports timing, and -XX:+PrintCompilation or a startup profiler will show the real cost, which is usually classpath scanning of your packages, connection-pool warmup, or entity-manager initialisation.
If you genuinely need to cut the candidate list, spring.autoconfigure.exclude is the tool — but only for candidates that were actually applied. Excluding a candidate the metadata filter was already rejecting for free buys nothing.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
"@ConditionalOnClass(DataSource.class) names a class that might not be on the classpath. Why doesn't that throw NoClassDefFoundError?"
This is the best question in the topic, because it separates people who have read the mechanism from people who have read about it.
Annotation attribute values live in the class file as strings — the constant pool holds the type's descriptor, not a resolved Class object. Boot never calls getAnnotation() on a loaded candidate to evaluate class conditions; it reads the annotation's attributes out of the bytecode with ASM and compares strings. No resolution, so nothing to fail.
If it did go through reflection, it would fail: getAnnotation() on an annotation with a Class-valued attribute naming an absent type throws TypeNotPresentException. That is exactly why @ConditionalOnClass also has a name = "..." String form — for the cases where even the safe path is not safe enough, such as a condition on a class in the same annotation-processing round.
"Where does the ordering between two auto-configurations come from?"
@AutoConfigureOrder for coarse priority, @AutoConfigureBefore / @AutoConfigureAfter for specific relationships, both folded into @AutoConfiguration's before and after attributes in Boot 3. Absent those, order is unspecified in any way you should rely on, and a @ConditionalOnMissingBean that crosses two auto-configurations is a latent bug.
Code traps
// Trap 1: @ConditionalOnMissingBean in your own configuration.
@Configuration
public class MyConfig {
@Bean
@ConditionalOnMissingBean // against WHAT? Nothing defines the order.
Clock clock() { return Clock.systemUTC(); }
}
// Trap 2: by type or by name?
@Bean
@ConditionalOnMissingBean // no attributes -> the method's RETURN TYPE
DataSource dataSource() { ... }
@Bean
@ConditionalOnMissingBean(name = "auditDataSource") // by bean name instead
DataSource auditDataSource() { ... }
An attribute-less @ConditionalOnMissingBean on a @Bean method is a condition on the return type, not the method name. Declare a DataSource under any name at all and the condition fails. That is usually what you want, and it is not what the code looks like it says.
Common wrong answers
- "It scans the classpath for beans." It does not scan anything for beans. It reads a list of class names from a file. Scanning is
@ComponentScan, it looks at your packages, and it is a separate annotation with a separate job. - "The starter contains the auto-configuration." A starter usually contains no code at all — it is a POM listing dependencies at compatible versions. The auto-configuration lives in
spring-boot-autoconfigureor in the library's own*-autoconfigurejar, which the starter pulls in. - "
@ConditionalOnMissingBeanmeans Boot prefers your bean." It means Boot's condition ran second and found yours. Change the order and it is a startup failure, not a different preference. - "
@EnableAutoConfigurationis required on the main class." It has to be somewhere in the imported configuration;@SpringBootApplicationis the shorthand. Its other half,@AutoConfigurationPackage, is what records the base package used by entity and repository scanning — which is why moving the main class breaks JPA in a way that looks unrelated.
Check Yourself
Q1. You add a dependency and a bean appears that you did not write. Name the file that made that happen, and say who wrote it.
Answer
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, inside the jar you added — written by that library's authors at their build time, not by your application and not by any scan of your code. Your dependency list is the only input you controlled.Q2. Your @Bean and Boot's @Bean are both named dataSource. Explain why your application starts rather than failing with a duplicate definition.
Answer
Boot's was never defined.AutoConfigurationImportSelector is a DeferredImportSelector, so auto-configuration is processed after all user configuration; by then your dataSource is registered, @ConditionalOnMissingBean fails, and Boot's @Bean method is never invoked. Had the order been reversed you would get a failure, because bean-definition overriding has been off by default since Boot 2.1.Q3. A colleague puts @ConditionalOnMissingBean on a @Bean in your application's own @Configuration class. It passes locally and fails in CI. Why?
Answer
The condition asks what is in the registry at the moment it is evaluated, and there is no defined processing order between two user@Configuration classes. Locally one order happened; in CI another did. The annotation is documented as being for auto-configuration classes only, precisely because user configuration has no ordering guarantee to hang it on.Q4. A library's auto-configuration works on Boot 2.6 and contributes nothing on Boot 3, with no error in the log. What happened?
Answer
It declares its auto-configuration inMETA-INF/spring.factories, which was deprecated in Boot 2.7 and removed in 3.0. Boot 3 reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports instead. The class is never a candidate, and a non-candidate cannot report a condition failure — so the silence is expected, which is what makes it hard to find.Where this question goes next
- spring boot starters — not written yet
- spring boot configuration precedence — not written yet
- spring boot startup flow — not written yet
- How does Spring AOP work, and what are its limits?
Questions that lead here
How does Spring AOP work, and what are its limits?
Spring builds a proxy around your bean — a JDK dynamic proxy if it has an interface, a generated subclass otherwise — and the advice lives in the proxy, not in your class. Every limit follows: self-invoked, private, final and static methods never cross the proxy, so an annotation on them does nothing and nothing warns you.
Asked constantlyintermediate2–10 yrs11 min readSpring aop
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-09-11.