Spring Boot Internals: How Auto-Configuration Really Works
A deep dive into @SpringBootApplication, @Conditional mechanics, DeferredImportSelector, AOT processing, and the full lifecycle that fires before your main() returns — written for engineers with 8+ years of Spring experience.
# What happens before main() returns
When SpringApplication.run(MyApp.class, args) executes, a precise sequence unfolds before your first business bean is ready. Understanding this call chain tells you exactly when and why auto-configuration fires — and why you cannot influence it from a regular @Bean method in a user-defined @Configuration class that is scanned late.
startup lifecycle1. SpringApplication constructor
├─ deduceMainApplicationClass() // walks stack trace
├─ deduceWebApplicationType() // SERVLET | REACTIVE | NONE
├─ load SpringApplicationRunListeners // from spring.factories
├─ load ApplicationContextInitializers
└─ load ApplicationListeners
2. run()
├─ prepareEnvironment() // properties, env, CLI args
│ └─ ConfigFileApplicationListener // loads application.yml
├─ createApplicationContext() // AnnotationConfigServletWebServerApplicationContext
├─ prepareContext()
│ ├─ applyInitializers()
│ └─ load() // registers primary source (MyApp.class)
└─ refreshContext() // AbstractApplicationContext.refresh()
├─ invokeBeanFactoryPostProcessors()
│ └─ ConfigurationClassPostProcessor.processConfigBeanDefinitions()
│ └─ AutoConfigurationImportSelector.selectImports() ← ★
├─ registerBeanPostProcessors()
├─ finishBeanFactoryInitialization() // instantiates singletons
└─ startWebServer() // Tomcat binds to port
Auto-configuration runs during invokeBeanFactoryPostProcessors(), before any singleton is instantiated. Conditions are evaluated against bean definitions — not bean instances. This is why @ConditionalOnMissingBean can work: by the time auto-config runs, the user's bean definitions have already been registered.
# @SpringBootApplication: the full meta-annotation
java@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration // itself @Configuration(proxyBeanMethods = true)
@EnableAutoConfiguration // imports AutoConfigurationImportSelector
@ComponentScan(excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication {
Class<?>[] exclude() default {};
String[] excludeName() default {};
String[] scanBasePackages() default {};
}
Key details seniors must know
AutoConfigurationExcludeFilterprevents classes already loaded as auto-configurations from also being picked up by@ComponentScan. Without this, you'd get duplicate bean definitions when an auto-config class lives under your base package.proxyBeanMethods = trueon@SpringBootConfigurationmeans inter-@Beancalls are intercepted by CGLIB so they return the singleton. Your own auto-configs should useproxyBeanMethods = false(lite mode) to avoid this overhead at scale.@EnableAutoConfigurationimportsAutoConfigurationImportSelectorvia@Import, which is aDeferredImportSelector— critical for ordering.scanBasePackages()defaults to the package of the annotated class. Putting@SpringBootApplicationin a leaf package limits what gets scanned — a common source of "my bean isn't being picked up" issues.
# AutoConfigurationImportSelector: the engine
AutoConfigurationImportSelector implements DeferredImportSelector rather than ImportSelector. This is intentional: deferred selectors run after all @Configuration classes in the application have been parsed, ensuring user-defined beans take priority over auto-configured ones.
java// Simplified internals of AutoConfigurationImportSelector
@Override
public String[] selectImports(AnnotationMetadata metadata) {
if (!isEnabled(metadata)) return NO_IMPORTS;
AutoConfigurationEntry entry = getAutoConfigurationEntry(metadata);
return StringUtils.toStringArray(entry.getConfigurations());
}
protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata metadata) {
// Step 1: Load all candidates from AutoConfiguration.imports
List<String> configurations = getCandidateConfigurations(metadata, attributes);
// Step 2: Remove duplicates (same class from multiple JARs)
configurations = removeDuplicates(configurations);
// Step 3: Apply exclusions from @SpringBootApplication(exclude=...)
Set<String> exclusions = getExclusions(metadata, attributes);
configurations.removeAll(exclusions);
// Step 4: Apply AutoConfigurationImportFilter — OnClassCondition bulk check
configurations = getConfigurationClassFilter().filter(configurations);
// Step 5: Fire AutoConfigurationImportEvent for listeners
fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationEntry(configurations, exclusions);
}
OnClassCondition (which backs @ConditionalOnClass) can be evaluated cheaply using ASM bytecode scanning against the classpath index — before any class is loaded. This filters out hundreds of irrelevant auto-configs (MongoDB, Cassandra, Kafka, etc.) in bulk without triggering class-loading or static initializers. A typical Spring Boot app with 150+ auto-config candidates usually has 20-30 that actually activate.
# Registration mechanisms: spring.factories vs AutoConfiguration.imports
Spring Boot 2.x — legacy format
META-INF/spring.factoriesorg.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.FooAutoConfiguration,\
com.example.BarAutoConfiguration
Spring Boot 3.x — preferred format
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.FooAutoConfiguration
com.example.BarAutoConfiguration
Why the change?
- Simpler to parse — one class per line, no key overhead, no escape characters.
- Faster classpath scanning via indexed imports.
- Removes
spring.factoriesambiguity — that file historically hosted many different extension points (listeners, initializers, failure analyzers, etc.) all keyed by interface name. - Backward compatible — Spring Boot 3 still reads
spring.factoriesfor non-auto-config extensions.
# @Conditional variants: cost and behaviour
Not all conditions are equal. The evaluation cost and timing differ significantly — and understanding the two-phase evaluation is critical for writing starters that work correctly.
| Annotation | Evaluated at | Cost | Notes |
|---|---|---|---|
@ConditionalOnClass | Parse time (bulk) | Very low | ASM scan, no class loading |
@ConditionalOnMissingClass | Parse time (bulk) | Very low | Inverse of above |
@ConditionalOnBean | Bean definition time | Medium | Requires BeanFactory scan |
@ConditionalOnMissingBean | Bean definition time | Medium | Order-sensitive! |
@ConditionalOnProperty | Parse time | Low | Checks Environment |
@ConditionalOnWebApplication | Parse time | Low | Checks ApplicationContext type |
@ConditionalOnExpression | Parse time | Medium | SpEL evaluation |
@ConditionalOnResource | Parse time | Low | Classpath resource check |
@ConditionalOnJava | Parse time | Very low | Checks JVM version |
@ConditionalOnSingleCandidate | Bean definition time | Medium | Exactly one qualifying bean |
@ConditionalOnCloudPlatform | Parse time | Low | Kubernetes, Heroku, etc. |
This condition is evaluated when the @Configuration class is being processed. Because user-defined @Configuration classes are processed before auto-configuration (thanks to DeferredImportSelector), a user bean registered via @Bean will always cause @ConditionalOnMissingBean to skip the auto-configured bean. This is the primary backoff mechanism in Spring Boot.
java — the classic backoff pattern// In DataSourceAutoConfiguration — this bean is SKIPPED if you define your own DataSource
@Bean
@ConditionalOnMissingBean
public DataSource dataSource(DataSourceProperties properties) {
return DataSourceBuilder.create()
.driverClassName(properties.determineDriverClassName())
.url(properties.determineUrl())
.username(properties.determineUsername())
.password(properties.determinePassword())
.build();
}
# Class-level vs method-level conditions
Where you place a condition matters enormously. Class-level conditions short-circuit the entire configuration class — Spring Boot never even loads it. Method-level conditions allow partial configuration.
java// Class-level @ConditionalOnClass — the entire class is skipped if
// DataSource is not on the classpath. No bean methods are evaluated.
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
// Method-level @ConditionalOnMissingBean — evaluated per-bean,
// allows partial configuration (some beans created, others skipped)
@Bean
@ConditionalOnMissingBean
public DataSource dataSource(DataSourceProperties properties) { ... }
@Bean
@ConditionalOnMissingBean
public DataSourceInitializer dataSourceInitializer(...) { ... }
}
Use class-level conditions as a coarse guard — "is the technology even present?" — and method-level conditions for fine-grained backoff — "has the user already configured this specific bean?" This separation lets you write starters that degrade gracefully.
# Auto-configuration ordering
When multiple auto-configuration classes interact — and they frequently do — order matters. Spring Boot provides declarative ordering:
java@AutoConfiguration(before = DataSourceAutoConfiguration.class)
public class XADataSourceAutoConfiguration { ... }
@AutoConfiguration(after = { DataSourceAutoConfiguration.class,
TransactionAutoConfiguration.class })
public class JpaAutoConfiguration { ... }
@AutoConfiguration is the preferred annotation in Spring Boot 3 (over @Configuration + @AutoConfigureBefore/@AutoConfigureAfter). It implicitly sets proxyBeanMethods = false, saving CGLIB proxy overhead. It also makes the "this is an auto-config, not a user config" distinction explicit.
Ordering is resolved via topological sort over the dependency graph before any classes are instantiated. If there's a cycle, Spring Boot throws an AutoConfigurationSorter exception at startup with the cycle path.
# Writing a production-quality Spring Boot starter
A real starter consists of two Maven modules — separation matters for downstream consumers who want to depend on just one or the other.
Module 1 — the autoconfigure module
java — MyLibProperties.java@ConfigurationProperties(prefix = "mylib")
@Validated
public class MyLibProperties {
@NotBlank
private String apiUrl = "http://localhost:8080";
@Min(100) @Max(30000)
private int timeoutMs = 5000;
private boolean enabled = true;
// getters/setters
}
java — MyLibAutoConfiguration.java@AutoConfiguration
@ConditionalOnClass(MyLibClient.class)
@ConditionalOnProperty(prefix = "mylib", name = "enabled",
havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MyLibProperties.class)
public class MyLibAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public MyLibClient myLibClient(MyLibProperties props) {
return MyLibClient.builder()
.apiUrl(props.getApiUrl())
.timeout(Duration.ofMillis(props.getTimeoutMs()))
.build();
}
}
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.mylib.autoconfigure.MyLibAutoConfiguration
META-INF/spring-configuration-metadata.json — for IDE autocomplete{
"groups": [{ "name": "mylib", "type": "com.example.mylib.autoconfigure.MyLibProperties" }],
"properties": [
{ "name": "mylib.api-url", "type": "java.lang.String", "defaultValue": "http://localhost:8080" },
{ "name": "mylib.timeout-ms", "type": "java.lang.Integer", "defaultValue": 5000 },
{ "name": "mylib.enabled", "type": "java.lang.Boolean", "defaultValue": true }
]
}
Module 2 — the starter module (almost empty)
xml — mylib-spring-boot-starter/pom.xml<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>mylib-spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>mylib-core</artifactId>
</dependency>
</dependencies>
Applications that want only the autoconfigure module — without pulling in the actual library — can do so. This is how Spring Boot itself ships: spring-boot-starter-data-jpa pulls in spring-boot-autoconfigure + Hibernate, but you can depend on spring-boot-autoconfigure alone and control your own JPA version.
# @ConfigurationProperties and relaxed binding
@ConfigurationProperties supports relaxed binding — a single property can be specified in multiple formats and they all bind to the same field.
| Source | Format | Example |
|---|---|---|
application.properties | kebab-case | mylib.api-url=... |
application.yml | camelCase | mylib: apiUrl: ... |
| Environment variable | SCREAMING_SNAKE | MYLIB_API_URL=... |
| JVM system property | dot-notation | -Dmylib.apiUrl=... |
Validation is supported via JSR-303. Binding failures are reported at startup as BindException with the full property path — much friendlier than @Value, which silently uses defaults or throws NullPointerException at runtime.
# Debugging: ConditionEvaluationReport
Run with --debug to see a full condition evaluation report in the console:
console output============================
CONDITIONS EVALUATION REPORT
============================
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found class 'javax.sql.DataSource' (OnClassCondition)
DataSourceAutoConfiguration#dataSource matched:
- @ConditionalOnMissingBean (types: javax.sql.DataSource) did not find any beans (OnBeanCondition)
Negative matches:
-----------------
MongoAutoConfiguration:
- @ConditionalOnClass did not find required class 'com.mongodb.MongoClient' (OnClassCondition)
Exclusions:
-----------
org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration
Via Actuator at runtime: GET /actuator/conditions returns the same data as JSON — invaluable for diagnosing issues in deployed applications without a restart.
# Startup optimization techniques
1. Lazy initialization
application.propertiesspring.main.lazy-initialization=true
Risk: first request is slower. Startup errors surface at runtime, not at boot time — which is a real production hazard.
2. Explicit exclusion
java@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
SecurityAutoConfiguration.class
})
Or declaratively:
propertiesspring.autoconfigure.exclude=\
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
3. AppCDS — Application Class Data Sharing
bash# Training run
java -Xshare:dump -XX:SharedArchiveFile=app.jsa -jar myapp.jar \
--spring.context.exit=onRefresh
# Production run
java -Xshare:on -XX:SharedArchiveFile=app.jsa -jar myapp.jar
Typical improvement: 20–40% startup reduction with zero code changes.
4. GraalVM Native Image
bash./mvnw -Pnative native:compile
./target/myapp # starts in < 100ms
All conditions are evaluated at build time by Spring AOT. Result: < 50ms startup, ~80% lower memory. AOT processing generates BeanFactory initialization code directly, bypassing reflection-based condition evaluation entirely at runtime.
# Common pitfalls
1. @ConditionalOnMissingBean order dependency
java// WRONG — if this auto-config runs before yours, the bean exists and yours is skipped
@Bean
@ConditionalOnMissingBean(MyService.class)
public MyService defaultService() { ... }
// RIGHT — ensure your @Configuration is a user config, not another auto-config.
// User configs always win because DeferredImportSelector runs last.
2. Classpath pollution triggering unexpected auto-configs
Adding spring-boot-starter-security to pom.xml immediately protects all endpoints with HTTP Basic auth. Always check /actuator/conditions after adding dependencies.
3. proxyBeanMethods = true in auto-configuration
java// WRONG — CGLIB proxy overhead on every auto-config class
@Configuration
public class MyAutoConfiguration { ... }
// RIGHT — lite mode; inter-@Bean calls should use parameters, not method calls
@AutoConfiguration
public class MyAutoConfiguration { ... }
4. Registering the same auto-config from multiple JARs
If two JARs on the classpath both declare the same auto-configuration class, removeDuplicates() handles it — but it's a sign of incorrect dependency structure. Only the autoconfigure module should declare its auto-configs.
5. Using @ComponentScan inside an auto-config
java// WRONG — scanning from an auto-config will pick up user classes if packages overlap
@Configuration
@ComponentScan("com.example")
public class BadAutoConfiguration { ... }
// RIGHT — register beans explicitly via @Bean methods
# Senior interview questions — answered in depth
15 principal-engineer-level questions, each answered as you'd expect in a 45-minute Spring Boot deep-dive interview. Click to expand.
AutoConfigurationImportSelector implement DeferredImportSelector instead of ImportSelector? What behaviour does this enable?
▶
Short answer
DeferredImportSelector defers its import selection until after all regular @Configuration classes in the application have been parsed. Spring Boot relies on this timing to guarantee that user-defined beans always take priority over auto-configured ones.
The mechanism in detail
During ConfigurationClassParser.parse(), Spring walks the configuration graph in two passes:
- Immediate imports from
ImportSelectorare processed inline as each@Configurationis visited. - Deferred imports from
DeferredImportSelectorare collected into aDeferredImportSelectorHandlerand processed last, after the entire user configuration graph has been parsed and all user bean definitions registered.
Why this matters for @ConditionalOnMissingBean
Consider this scenario:
java// User code
@Configuration
public class MyAppConfig {
@Bean public DataSource customDataSource() { ... }
}
// Spring Boot auto-config
@AutoConfiguration
public class DataSourceAutoConfiguration {
@Bean @ConditionalOnMissingBean
public DataSource dataSource() { ... }
}
Because AutoConfigurationImportSelector is deferred, by the time DataSourceAutoConfiguration's bean definitions are being evaluated, customDataSource is already in the BeanDefinitionRegistry. OnBeanCondition finds it and returns false, causing the auto-configured bean to back off.
Secondary benefit — grouping
DeferredImportSelector.Group lets related selectors share context. Spring Boot uses AutoConfigurationGroup to collect all auto-configuration imports together, then sort them via AutoConfigurationSorter (topological sort using @AutoConfigureBefore/@AutoConfigureAfter) before they're handed back to Spring.
What would break without deferred semantics?
If AutoConfigurationImportSelector was a plain ImportSelector, the order of @Configuration class processing would determine which bean wins — which is effectively undefined (depends on classpath order, component scan order). Every Spring Boot app would have flaky bean-override behaviour. Deferred selectors make Spring Boot's "user always wins" contract robust.
@ConditionalOnClass evaluated differently from @ConditionalOnMissingBean?
▶
The two phases
Spring Boot evaluates conditions in two distinct phases driven by the ConfigurationPhase enum:
PARSE_CONFIGURATION— runs when the@Configurationclass metadata is being parsed. No class loading, no bean factory access — just annotation metadata and classpath presence checks.REGISTER_BEAN— runs after all configuration parsing is complete, when bean definitions are being finalized. Has access to the fullBeanFactoryand can inspect existing bean definitions.
Why @ConditionalOnClass runs in PARSE_CONFIGURATION
OnClassCondition uses ASM bytecode scanning of the classpath index (spring.components) — it never triggers class loading. This is deliberate: loading com.mongodb.MongoClient just to check if MongoDB is present would pull in the entire Mongo driver's static initializers (thread pools, logging frameworks, etc.), which is catastrophic for startup time.
The check is essentially:
java — simplifiedprivate boolean isPresent(String className, ClassLoader cl) {
try {
ClassUtils.forName(className, cl); // fails fast without init
return true;
} catch (Throwable ex) {
return false;
}
}
Further, OnClassCondition implements AutoConfigurationImportFilter, so this check happens in bulk during Step 4 of AutoConfigurationImportSelector — before the auto-config classes are even passed to Spring for processing. Hundreds of candidates are filtered in milliseconds.
Why @ConditionalOnMissingBean must run in REGISTER_BEAN
To answer "does a bean of type DataSource exist?", Spring needs to consult the BeanFactory — which doesn't exist during parse time. It needs:
- The full set of bean definitions (including user configs that haven't been parsed yet at class-declaration time).
- Knowledge of factory method return types — which requires resolving
@Beanmethod signatures. - Generic type resolution for parameterized beans (e.g.
Repository<User>).
The performance contract
This two-phase split is what makes Spring Boot's startup viable. If every condition had to run in REGISTER_BEAN (requiring a fully-populated BeanFactory), you'd have to parse and register every candidate auto-config's bean definitions just to decide whether to skip it. Instead, classpath-based conditions filter 80% of candidates before they ever enter the parsing phase.
Pitfall — conditions that read from BeanFactory at parse time
You can override getConfigurationPhase() in a custom Condition. If you return REGISTER_BEAN and the condition depends on bean existence, it works. If you try to query the BeanFactory from a PARSE_CONFIGURATION condition, you'll get a half-built registry or an exception. Getting this wrong causes the dreaded "condition matches on some runs but not others" bug.
spring-boot-starter-data-jpa to a project. Which auto-configurations fire, and in what order?
▶
Transitively pulled dependencies
spring-boot-starter-data-jpa brings in (via POM):
spring-boot-starter-jdbc→ HikariCP,spring-jdbchibernate-corespring-data-jpaspring-aspects(for@TransactionalAOP)jakarta.persistence-api
Activated auto-configurations (in firing order)
DataSourceAutoConfiguration— class-level@ConditionalOnClass(DataSource.class)passes becausespring-jdbcis on the classpath. Imports nested configsHikari,Tomcat,Dbcp2,OracleUcp— but onlyHikarimatches because HikariCP is the only pool on the classpath.JdbcTemplateAutoConfiguration—@AutoConfigureAfter(DataSourceAutoConfiguration.class). CreatesJdbcTemplateandNamedParameterJdbcTemplate.HibernateJpaAutoConfiguration—@AutoConfigureAfter({DataSourceAutoConfiguration.class, ...}). CreatesEntityManagerFactory, picks Hibernate as the JPA provider.JpaBaseConfiguration(imported by above) — createsJpaVendorAdapter,PersistenceManagedTypes,PlatformTransactionManager.DataSourceTransactionManagerAutoConfiguration— backs off becauseJpaBaseConfigurationalready registered aJpaTransactionManager(@ConditionalOnMissingBean(PlatformTransactionManager.class)).TransactionAutoConfiguration— createsTransactionalOperator, enables@TransactionalviaProxyTransactionManagementConfiguration.JpaRepositoriesAutoConfiguration— scans for@Repository/JpaRepositoryinterfaces, creates proxies.DataSourceInitializationAutoConfiguration— runsschema.sql,data.sqlif present.SqlInitializationAutoConfiguration(Spring Boot 2.5+) — newer schema init mechanism withspring.sql.init.*properties.
Ordering guarantees
The critical ordering is DataSourceAutoConfiguration → HibernateJpaAutoConfiguration → JpaRepositoriesAutoConfiguration. This is enforced via @AutoConfigureAfter declarations, resolved at parse time by AutoConfigurationSorter using topological sort.
What you can verify
Run with --debug and search for "Positive matches". You'll see HibernateJpaAutoConfiguration with conditions like:
@ConditionalOnClassfoundorg.hibernate.SessionFactory@ConditionalOnSingleCandidate(DataSource.class)found exactly one@ConditionalOnMissingBean(JpaTransactionManager.class)did not find any
What commonly goes wrong
If you define your own DataSource bean but don't configure a JpaVendorAdapter, HibernateJpaAutoConfiguration still fires because @ConditionalOnMissingBean on its beans still matches. This can cause subtle bugs where you think you disabled JPA but you actually replaced only the pool. The fix is explicit exclusion: exclude = HibernateJpaAutoConfiguration.class.
The four breaking changes to handle
- Registration file — Boot 2 uses
spring.factories, Boot 3 usesAutoConfiguration.imports. javax.*→jakarta.*— servlet, persistence, validation APIs all moved.- Minimum Java version — Boot 2.7 requires Java 8+; Boot 3 requires Java 17+.
@AutoConfigurationannotation — Boot 3 preferred, Boot 2.7+ also supports it.
Strategy 1 — dual-JAR (recommended)
Ship two separate artifacts:
mylib-spring-boot-2-autoconfigure— usesspring.factories,javax.*mylib-spring-boot-3-autoconfigure— usesAutoConfiguration.imports,jakarta.*
Use Maven profiles or Gradle source sets to build both from a shared codebase with javax-to-jakarta substitution. This is how Hibernate 6 and Jackson ship dual artifacts.
Strategy 2 — single JAR with dual registration
Put the file in both locations:
project layoutsrc/main/resources/META-INF/
├── spring.factories # Boot 2
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports # Boot 3
Works because Boot 2 ignores the new file and Boot 3 prefers the new file but still reads the old. But you can only pick one of javax or jakarta — which forces you to pick a minimum Boot version.
Strategy 3 — runtime detection (advanced)
Use @ConditionalOnClass to branch between code paths that depend on jakarta.servlet.* vs javax.servlet.*. You'll need separate auto-config classes per namespace, each conditional on the presence of the right Servlet API. The starter then loads the correct one.
java@AutoConfiguration
@ConditionalOnClass(name = "jakarta.servlet.Servlet")
public class MyLibJakartaAutoConfiguration { ... }
@AutoConfiguration
@ConditionalOnClass(name = "javax.servlet.Servlet")
@ConditionalOnMissingClass("jakarta.servlet.Servlet")
public class MyLibJavaxAutoConfiguration { ... }
Compatibility matrix to publish
| Starter version | Boot version | Java version |
|---|---|---|
| 1.x | 2.5 - 2.7 | 8+ |
| 2.x | 3.0+ | 17+ |
Document this clearly and use semantic versioning — never bump the minor version when dropping Boot 2 support. Always bump major.
@AutoConfiguration and @Configuration in the context of auto-configs? Why does proxyBeanMethods = false matter?
▶
@AutoConfiguration in one sentence
A Spring Boot 3-introduced meta-annotation equivalent to @Configuration(proxyBeanMethods = false) + @AutoConfigureBefore + @AutoConfigureAfter, designed specifically for auto-configuration classes.
Full expansion
java — the annotation definition@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {
@AliasFor(annotation = Configuration.class)
String value() default "";
Class<?>[] before() default {};
String[] beforeName() default {};
Class<?>[] after() default {};
String[] afterName() default {};
}
Why proxyBeanMethods = false matters
By default, @Configuration uses proxyBeanMethods = true, meaning Spring wraps the configuration class in a CGLIB subclass. Inside this subclass, every @Bean method is intercepted so that calling serviceA() from within serviceB() returns the singleton instance, not a new invocation.
java@Configuration // proxyBeanMethods = true by default
public class MyConfig {
@Bean public Foo foo() { return new Foo(); }
@Bean public Bar bar() { return new Bar(foo()); }
// foo() here is intercepted — returns the singleton, not a new Foo
}
The cost of CGLIB proxies
- Class loading — CGLIB generates a new subclass at runtime for every
@Configuration. - Method interception overhead — every call to a
@Beanmethod goes through the CGLIB callback. - Reflection cost — no-args constructor must be public; the class can't be final.
- GraalVM hostility — CGLIB proxies require runtime bytecode generation, which native-image cannot do at build time.
Why auto-configs don't need it
Well-written auto-configs take dependencies via method parameters, not via inter-method calls:
java@AutoConfiguration // proxyBeanMethods = false
public class MyAutoConfig {
@Bean public Foo foo() { return new Foo(); }
@Bean public Bar bar(Foo foo) { return new Bar(foo); }
// bar() receives the singleton foo via parameter injection
}
Spring still wires foo correctly because it's resolved from the BeanFactory, not via method call.
Measured impact
The Spring team measured ~15% startup reduction when Spring Boot 2.2 switched internal auto-configs to proxyBeanMethods = false. That's why @AutoConfiguration bakes this default in — it's the right setting 99% of the time for auto-configs.
DataSource bean is being overridden by Spring Boot's auto-configuration. How do you diagnose and fix this?
▶
Diagnosis — step by step
- Confirm the symptom — inject
DataSourceand logdataSource.getClass(). If it'sHikariDataSourcefrom Spring Boot config instead of their custom one, the backoff failed. - Run with
--debugand search forDataSourceAutoConfigurationin the condition evaluation report. Look for:console
If this matches, Spring didn't see the custom bean — which means it wasn't registered yet when the auto-config ran.DataSourceAutoConfiguration.PooledDataSourceConfiguration.Hikari matched: - @ConditionalOnMissingBean (types: javax.sql.DataSource) did not find any beans - Check
/actuator/beans— look for the custom bean by name. Is it there at all? What's itsconfigurationClass?
Root causes (from most to least common)
Cause 1 — custom bean is inside an auto-config, not user config
If the colleague registered the bean in a @Configuration class that's listed in AutoConfiguration.imports, both configs run as deferred imports. The ordering between them becomes undefined unless explicit @AutoConfigureBefore is used.
Fix: Move the bean to a user-level @Configuration class (scanned via component scan) — user configs always beat auto-configs due to DeferredImportSelector ordering.
Cause 2 — wrong bean type
The colleague defined a HikariDataSource bean, but @ConditionalOnMissingBean on the auto-config uses DataSource. Normally this would match, but if the custom bean has an @Qualifier or is annotated with @Primary, Spring's bean-matching logic may not see it as "missing".
Fix: Declare the bean as DataSource type explicitly in the @Bean method signature. Don't use HikariDataSource as the return type.
Cause 3 — lazy initialization hiding the problem
If spring.main.lazy-initialization=true, the order of @ConditionalOnMissingBean evaluation vs bean registration can become non-deterministic for certain edge cases.
Fix: Disable lazy init temporarily to confirm.
Cause 4 — factory bean indirection
If the custom DataSource is returned from a FactoryBean, @ConditionalOnMissingBean needs to resolve the factory's getObjectType() return type. For parameterized factory beans, this can fail.
Fix: Use @ConditionalOnMissingBean(ignoredType = "...") or declare the factory with a concrete type.
Cause 5 — multiple data sources
The colleague may have defined one DataSource, but some other auto-config (e.g. XADataSourceAutoConfiguration) is adding another. Spring Boot sees two DataSources and doesn't know which is primary.
Fix: Mark the custom bean with @Primary, or exclude XADataSourceAutoConfiguration.
The nuclear option
Exclude the auto-config entirely:
java@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
This is correct if the colleague genuinely wants full control — but it also disables derived auto-configs like HibernateJpaAutoConfiguration's DataSource lookup, which can surprise downstream teams.
The fundamental shift
In the JVM world, Spring Boot discovers auto-configuration at runtime: it reads AutoConfiguration.imports, evaluates conditions dynamically, creates bean definitions via reflection, and uses CGLIB for proxies. Native image cannot do any of these at runtime — it's a closed-world, ahead-of-time compiled executable with no class loader, no reflection (unless registered), and no runtime bytecode generation.
Spring AOT's job
Spring 6 / Boot 3 introduced Spring AOT (spring-aot-processor), which runs during the Maven/Gradle build and generates equivalent ahead-of-time code. Specifically:
1. BeanFactoryInitializationAotContribution
AOT builds an in-memory ApplicationContext at build time, runs all the same condition evaluation logic, and captures the final set of bean definitions. This is then emitted as plain Java code:
generated at build time// target/spring-aot/main/sources/com/example/MyApp__BeanFactoryRegistrations.java
public class MyApp__BeanFactoryRegistrations implements BeanFactoryInitializer {
@Override
public void initialize(DefaultListableBeanFactory factory) {
factory.registerBeanDefinition("dataSource",
BeanDefinitionBuilder.rootBeanDefinition(HikariDataSource.class)
.addPropertyValue("jdbcUrl", ...)
.getBeanDefinition());
// ... hundreds more
}
}
2. Reflection hints
Every class accessed via reflection at runtime must be registered at build time. Spring AOT scans beans for:
- Constructor injection → register constructor for reflection
@Autowiredfields → register fields- JPA entities → register all getters/setters
- Jackson-serialized DTOs → register constructors and accessors
These are emitted as reflect-config.json files under META-INF/native-image/.
3. Proxy hints
CGLIB proxies cannot be generated at runtime in native image. Spring AOT pre-generates JDK dynamic proxies (interface-based) during AOT processing and registers them. This is why @Configuration(proxyBeanMethods = true) is penalized heavily in AOT — it forces CGLIB which doesn't work natively.
4. Resource hints
application.properties, banner.txt, Thymeleaf templates, etc., are all registered in resource-config.json so GraalVM includes them in the executable.
What you lose in native mode
- No dynamic class loading — e.g.
Class.forName("com.example.Plugin")at runtime fails unless pre-registered. - No dynamic condition evaluation — conditions that depend on runtime environment (not
@Profile— that's supported) don't work. - No hot reload of beans — the bean factory is frozen at build time.
- Reduced debugging — no
/actuator/conditionsendpoint with runtime condition evaluation (though build-time report is available).
The payoff
- Startup: ~50ms vs 3-10s on JVM
- Memory: ~60-100MB RSS vs 300-500MB
- No warmup — peak throughput from first request
- Ideal for serverless (Lambda, Cloud Functions) where cold starts kill economics
AutoConfigurationExcludeFilter and why does @ComponentScan need it?
▶
The problem it solves
Imagine an auto-configuration class lives in a JAR under the package com.example.mylib, and your application's @SpringBootApplication is in com.example.app. @ComponentScan (which defaults to the app's base package) would only find app classes — not the library's auto-config. So far so good.
But what if a developer moves your auto-config under com.example (shared root package)? Now @ComponentScan will find it — in addition to the import via AutoConfiguration.imports. Result: the auto-config class is registered twice, and every @Bean method runs twice, causing BeanDefinitionOverrideException or worse, silent duplicate beans.
What AutoConfigurationExcludeFilter does
java — simplifiedpublic class AutoConfigurationExcludeFilter implements TypeFilter {
@Override
public boolean match(MetadataReader reader, MetadataReaderFactory factory) {
return isConfiguration(reader) && isAutoConfiguration(reader);
}
private boolean isAutoConfiguration(MetadataReader reader) {
return getAutoConfigurations().contains(reader.getClassMetadata().getClassName());
}
private List<String> getAutoConfigurations() {
if (autoConfigurations == null) {
autoConfigurations = ImportCandidates.load(AutoConfiguration.class, ...).getCandidates();
}
return autoConfigurations;
}
}
It reads the full list of registered auto-configurations once and caches it. Then, during the @ComponentScan walk, any class that matches both "is a configuration" AND "is a registered auto-configuration" is excluded from scan results.
Why this only appears in @SpringBootApplication
If you write a plain Spring application with @ComponentScan manually, this filter isn't added. You wouldn't have auto-configurations either, so no conflict. The filter is a safety net specifically for the Spring Boot scenario where auto-configs exist and component scan runs on overlapping packages.
Practical takeaway
When writing a starter, name your auto-configure module's base package distinctly from your application's base package. Don't rely on AutoConfigurationExcludeFilter as an excuse to ignore package hygiene — it exists as defence in depth, not as the primary mechanism.
@ConfigurationProperties? What are the four binding sources and their formats?
▶
What relaxed binding is
A single Java field like apiUrl can be set from multiple sources using different naming conventions. Spring Boot's Binder API normalizes all of them before matching against the field.
The four sources
| Source | Canonical form | Example | Notes |
|---|---|---|---|
| Properties file | kebab-case | mylib.api-url=x | Recommended |
| YAML file | camelCase or kebab | mylib:\n apiUrl: x | Both work |
| Environment variable | UPPER_SNAKE | MYLIB_API_URL=x | Only viable form |
| System property | any | -Dmylib.apiUrl=x | Dot-notation |
How Binder normalizes names
When Spring Boot binds a property, it generates a ConfigurationPropertyName — a normalized representation. The algorithm:
- Split on
.→ elements - For each element, split on case boundaries and
-and_ - Lowercase everything
- Join with
-
So MYLIB_API_URL, mylib.apiUrl, mylib.api-url, and mylib.API_URL all normalize to the same mylib.api-url and match the apiUrl field.
Indexed properties (the tricky part)
Collections and maps also bind:
yamlmylib:
servers:
- name: primary
url: https://a.com
- name: backup
url: https://b.com
propertiesmylib.servers[0].name=primary
mylib.servers[0].url=https://a.com
mylib.servers[1].name=backup
mylib.servers[1].url=https://b.com
env varMYLIB_SERVERS_0_NAME=primary
MYLIB_SERVERS_0_URL=https://a.com
The env var form uses _INDEX_ instead of [N] because square brackets aren't allowed in shell env vars.
Validation with JSR-303
java@ConfigurationProperties(prefix = "mylib")
@Validated
public class MyLibProperties {
@NotBlank
private String apiUrl;
@Min(100) @Max(30000)
private int timeoutMs;
@Valid
private List<ServerConfig> servers;
}
Failures surface at startup with the full property path — e.g. mylib.servers[1].name: must not be blank. Compare this to @Value, which silently defaults or throws NPE at runtime. Always prefer @ConfigurationProperties for structured config.
Constructor binding (Spring Boot 2.2+)
java — immutable config@ConfigurationProperties(prefix = "mylib")
public record MyLibProperties(
String apiUrl,
@DefaultValue("5000") int timeoutMs,
@DefaultValue("true") boolean enabled
) {}
Records + @ConstructorBinding (implicit in Boot 3) → immutable, no setters, no Lombok needed. This is the modern recommended form.
Step 0 — measure before optimizing
Always baseline first. Enable Spring Boot's startup tracking:
application.propertiesspring.jmx.enabled=true
management.endpoints.web.exposure.include=startup
management.endpoint.startup.enabled=true
Then GET /actuator/startup returns a hierarchical trace showing which beans took the longest to instantiate. You'll often find one or two culprits (Hibernate session factory, a large Liquibase migration, Kafka admin client).
Tier 1 — zero-code changes (5 min effort, 10-30% reduction)
- Remove unused dependencies — every auto-config that fires is startup cost. Run
mvn dependency:analyze. - Disable unused auto-configurations — exclude
SecurityAutoConfiguration,WebSocketAutoConfiguration, etc. that you don't use. - Enable AppCDS — 20-40% reduction with zero code changes. Just generate the archive during image build.
Tier 2 — configuration tweaks (1 hour effort, 20-40% reduction)
- Lazy initialization —
spring.main.lazy-initialization=true. Warning: first request is slower, and startup-time errors become runtime errors. - Disable Hibernate validation on startup —
spring.jpa.hibernate.ddl-auto=none,spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true. - Tune HikariCP — reduce
minimum-idle. Initial pool warmup often adds 500ms-1s. - Defer Flyway/Liquibase — run migrations in a separate pipeline step instead of application startup.
- Disable JMX endpoints —
spring.jmx.enabled=false(if not using JMX).
Tier 3 — code changes (1-2 days effort, 30-50% reduction)
- Replace @Configuration with @AutoConfiguration in internal starters — removes CGLIB overhead.
- Reduce component scan surface —
@SpringBootApplication(scanBasePackages = "com.myapp.api")instead of scanning everything. - Use @Lazy on expensive beans selectively — e.g. rarely-used integrations.
- Replace reflection-heavy Jackson deserialization with
afterburnermodule orblackbird. - Profile-guided optimization — with
-XX:+UseParallelGCor-XX:TieredStopAtLevel=1for startup.
Tier 4 — architectural change (1 week+ effort, 90%+ reduction)
GraalVM Native Image — compile to native executable with Spring AOT. Startup drops to <100ms, memory drops 60-80%. Costs: longer build times (5-15 min), more limited runtime reflection, some libraries incompatible.
When to stop optimizing
If you're deploying to a long-running container (Kubernetes deployment, not Lambda), 12s→6s is often not worth the effort. Spend that engineering time on HTTP response latency or database query optimization instead — those have customer-visible impact. Aggressive startup optimization matters most for: FaaS, autoscaled services with frequent cold starts, CI pipelines running many integration tests.
What's in the report
The ConditionEvaluationReport is the authoritative record of every auto-configuration decision Spring Boot made during startup. It contains four sections:
- Positive matches — auto-configs that activated, with each condition that passed and why.
- Negative matches — auto-configs that were skipped, with the specific failing condition.
- Exclusions — auto-configs explicitly excluded via
@SpringBootApplication(exclude=...)orspring.autoconfigure.exclude. - Unconditional classes — configs with no conditions (always loaded).
Development-time access
Run with --debug or set debug=true in application.properties. The report prints to stdout during startup.
Production access via Actuator
Expose the conditions endpoint:
application.propertiesmanagement.endpoints.web.exposure.include=conditions,health,info,metrics
management.endpoint.conditions.enabled=true
Then GET /actuator/conditions returns JSON:
json{
"contexts": {
"application": {
"positiveMatches": {
"DataSourceAutoConfiguration": [
{ "condition": "OnClassCondition",
"message": "@ConditionalOnClass found 'javax.sql.DataSource'" }
],
"DataSourceAutoConfiguration#dataSource": [
{ "condition": "OnBeanCondition",
"message": "@ConditionalOnMissingBean (types: javax.sql.DataSource) did not find any beans" }
]
},
"negativeMatches": {
"MongoAutoConfiguration": {
"notMatched": [ { "condition": "OnClassCondition",
"message": "did not find required class 'com.mongodb.MongoClient'" } ],
"matched": []
}
}
}
}
}
Production use cases
- "Why isn't my bean being picked up?" — Search negative matches for the auto-config that should register it.
- Auditing which auto-configs are active — Compare between environments to spot drift.
- Understanding dependency bloat — Count positive matches. If you have 80+ auto-configs active and only use 20, there's cleanup to do.
- Diagnosing backoff failures — If a custom bean should have suppressed an auto-config but didn't, the positive-match entry will show
@ConditionalOnMissingBean did not find any beans— telling you the user bean wasn't visible.
Security consideration
The /actuator/conditions endpoint leaks significant information about your application's structure (class names, property values, bean hierarchy). Never expose it on a public interface. Use Actuator security:
application.propertiesmanagement.server.port=9090
management.server.address=127.0.0.1
management.endpoints.web.exposure.include=conditions
Bind Actuator to a separate internal port and protect with network policy / Spring Security.
@AutoConfigureBefore/@AutoConfigureAfter work under the hood? What happens if there's a cycle?
▶
The ordering contract
Auto-configurations form a directed dependency graph where edges represent "must be parsed before/after". AutoConfigurationSorter performs a topological sort of this graph during AutoConfigurationImportSelector.getAutoConfigurationEntry(), producing a linear ordering respected by Spring's configuration parser.
The algorithm
- For each auto-config, extract its
@AutoConfigureBefore,@AutoConfigureAfter, and@AutoConfigureOrdermetadata from annotations. - Also extract relationships from
@AutoConfiguration(before = ..., after = ...)— these are equivalent. - Build a directed graph:
@AutoConfigureBefore(B.class)on A → edgeA → B(A processed first)@AutoConfigureAfter(B.class)on A → edgeB → A(A processed after)
- Apply
@AutoConfigureOrderas a secondary sort key — lower values first. - Perform Kahn's algorithm topological sort.
- If
inDegree[node] > 0remains for any node after visiting all reachable nodes, a cycle exists.
What happens on a cycle
AutoConfigurationSorter throws IllegalStateException at startup with the cycle path:
stack tracejava.lang.IllegalStateException: AutoConfigure cycle detected between
ConfigA -> ConfigB -> ConfigC -> ConfigA
at AutoConfigurationSorter.sort(AutoConfigurationSorter.java:72)
The application refuses to start. This is a hard failure — you must break the cycle. In practice, cycles almost always happen when two starters from different vendors over-specify ordering. The fix is usually to remove one of the @AutoConfigureBefore/@AutoConfigureAfter declarations.
@AutoConfigureOrder — the weaker mechanism
Uses an integer priority (default Ordered.LOWEST_PRECEDENCE). It's a secondary tiebreaker:
java@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@AutoConfiguration
public class SecurityAutoConfiguration { ... }
Security auto-configs typically use HIGHEST_PRECEDENCE so they run first — useful when they don't have a natural after relationship to depend on.
Why ordering matters for conditions
Consider:
java@AutoConfiguration(after = RedisAutoConfiguration.class)
public class CacheAutoConfiguration {
@Bean @ConditionalOnBean(RedisTemplate.class)
public CacheManager redisCacheManager(RedisTemplate template) { ... }
}
If CacheAutoConfiguration ran before RedisAutoConfiguration, RedisTemplate wouldn't exist yet in the BeanFactory, and @ConditionalOnBean would fail. @AutoConfigureAfter enforces the correct ordering.
Internal representation of ordering
Spring Boot 3 reads ordering metadata from a compiled META-INF/spring/...AutoConfiguration.annotation.imports file when available, avoiding reflection on each class. This is generated by the spring-boot-autoconfigure-processor annotation processor during compilation — another startup-time optimization.
spring.factories and AutoConfiguration.imports. Why did Spring Boot 3 introduce the new format?
▶
spring.factories — the legacy format
META-INF/spring.factoriesorg.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.FooAutoConfiguration,\
com.example.BarAutoConfiguration
org.springframework.context.ApplicationContextInitializer=\
com.example.MyInitializer
org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.MyFailureAnalyzer
A single file that hosts many different extension points — all keyed by the fully-qualified interface name they extend. Parsed by SpringFactoriesLoader.loadFactoryNames() using Properties-style key-value parsing.
AutoConfiguration.imports — Boot 3's dedicated file
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.FooAutoConfiguration
com.example.BarAutoConfiguration
One file per extension point. The filename is the interface name. Format is dead simple: one FQN per line, # comments, no quoting.
Why the change — five reasons
- Parse performance —
ImportCandidatesreads one line at a time withBufferedReader. NoPropertiesparsing, no key splitting, no line-continuation handling. Measured ~10x faster on cold JVM start for projects with many dependencies. - Less error-prone —
spring.factories's backslash continuation is notoriously fragile. Forget a backslash? Half your auto-configs silently don't register. The new format has no escape mechanism to get wrong. - Better IDE / tooling support — each extension has its own file, so IDE plugins can validate "this line must be a class that implements X". Possible with
spring.factoriesbut nobody built it. - Enables indexed imports — Spring Boot can write a compiled index at build time (
spring-boot-autoconfigure-processor) listing classes in dependency order. The new format makes this indexing trivial. - Multi-module clarity — with one file per concern, it's obvious from a JAR's contents which extensions it contributes.
spring.factoriesrequired reading the file to know.
Backward compatibility
Spring Boot 3 still reads spring.factories, but only for non-auto-config extensions — ApplicationListener, FailureAnalyzer, etc. For auto-configs specifically, spring.factories entries are ignored in Boot 3. This caught many library authors off-guard during migration; Boot 3 even emits a deprecation warning when it finds auto-config entries in spring.factories.
Migration rule
If you ship a Boot 2 starter:
- Boot 2 support: keep
spring.factories - Boot 3 support: add
META-INF/spring/...AutoConfiguration.imports - Ship both in a dual-JAR or as separate version lines.
Other extension points that moved
Boot 3 introduced several per-interface imports files under META-INF/spring/:
org.springframework.boot.env.EnvironmentPostProcessor.importsorg.springframework.boot.BootstrapRegistryInitializer.importsorg.springframework.context.ApplicationContextInitializer.imports
The pattern is uniform — expect more extension points to migrate in future Boot versions.
Short answer
Yes, they can — and this happens frequently. The resolution mechanism depends on whether the beans are mutually exclusive (only one should win) or complementary (both should coexist with different names/roles).
Case 1 — mutually exclusive (only one wins)
Example: DataSourceAutoConfiguration imports nested configurations for Hikari, Tomcat JDBC, DBCP2, and Oracle UCP. Each registers a DataSource bean. Conflict is resolved by class-level @ConditionalOnClass:
java — DataSourceAutoConfiguration internal@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(HikariDataSource.class)
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.type",
havingValue = "com.zaxxer.hikari.HikariDataSource",
matchIfMissing = true)
static class Hikari { ... }
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(org.apache.tomcat.jdbc.pool.DataSource.class)
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.type",
havingValue = "org.apache.tomcat.jdbc.pool.DataSource")
static class Tomcat { ... }
Three conditions work together: "pool class on classpath" AND "no DataSource already defined" AND "property matches (or is unset for default)". Only the first one to match wins — subsequent ones see a DataSource already registered and back off via @ConditionalOnMissingBean.
Case 2 — two different auto-configs, both want to register the same type
Example: DataSourceTransactionManagerAutoConfiguration wants to register a PlatformTransactionManager, and so does JpaBaseConfiguration. Resolution:
- Ordering:
@AutoConfigureAfter(DataSourceTransactionManagerAutoConfiguration.class)on JPA config. - When JPA runs, it checks
@ConditionalOnMissingBean(PlatformTransactionManager.class)— finds the JDBC one registered already. - Wait — JPA actually replaces it. The JPA config uses a higher-priority registration trick: it registers a
JpaTransactionManagerwith@Primaryor uses@ConditionalOnSingleCandidateto coexist.
In practice, the Spring team ordered these carefully: DataSourceTransactionManagerAutoConfiguration uses @ConditionalOnMissingBean(PlatformTransactionManager.class), and JPA registers its bean first in the ordering. So the JDBC one is skipped entirely.
Case 3 — two auto-configs from different JARs
Say spring-boot-starter-cache registers a CacheManager, and spring-boot-starter-data-redis also registers one. The resolution depends on:
- Ordering (
@AutoConfigureBefore/@AutoConfigureAfter) — determines who goes first. - @ConditionalOnMissingBean — whoever goes second yields.
- @ConditionalOnProperty — often used to let users explicitly choose (e.g.
spring.cache.type=redis).
Case 4 — duplicate bean definition without conditions (pathological)
If two auto-configs register the same bean name with no conditions to guard against it, you get BeanDefinitionOverrideException at startup (Boot 2.1+). You can disable this check with spring.main.allow-bean-definition-overriding=true — but don't. It's there to catch genuine bugs. If you need one config to win, use @Primary or explicit exclusion.
The golden rules
- Always pair
@Beanin an auto-config with@ConditionalOnMissingBean. - Name your beans explicitly (don't rely on method name) to avoid accidental collision.
- Use
@ConditionalOnPropertyfor mutually-exclusive variants. - Declare ordering with
@AutoConfigureBefore/@AutoConfigureAfterwhen the resolution depends on timing.
OnClassCondition's bulk filtering optimization, and why does it matter for startup performance?
▶
The scale of the problem
A typical Spring Boot 3 application with common dependencies has 150-200 candidate auto-configurations. Most are irrelevant to your app (MongoDB, Cassandra, Elasticsearch, RabbitMQ, etc.). If Spring evaluated each one individually — parsing its annotations, resolving its conditions, inspecting its @Bean methods — startup would take minutes.
The optimization — two-step bulk filtering
OnClassCondition implements two interfaces:
Condition— normal condition evaluation, called per bean/config.AutoConfigurationImportFilter— a bulk filter called once with all candidates.
The AutoConfigurationImportFilter interface lets Spring Boot ask one condition class: "given this list of 200 candidate auto-configs, which ones pass your check?" — in a single pass, using batch-optimized logic.
How it works internally
java — simplifiedpublic class OnClassCondition extends FilteringSpringBootCondition {
@Override
protected ConditionOutcome[] getOutcomes(
String[] autoConfigurationClasses,
AutoConfigurationMetadata metadata) {
// Metadata is pre-compiled at build time:
// it contains the @ConditionalOnClass value for each autoconfig
// without needing to load the class itself.
ConditionOutcome[] outcomes = new ConditionOutcome[autoConfigurationClasses.length];
for (int i = 0; i < autoConfigurationClasses.length; i++) {
String className = autoConfigurationClasses[i];
String onClass = metadata.get(className, "ConditionalOnClass");
if (onClass != null) {
if (!classPresent(onClass)) {
outcomes[i] = ConditionOutcome.noMatch(...);
}
}
}
return outcomes;
}
}
Crucially: it reads the @ConditionalOnClass target from pre-compiled metadata — it never loads the auto-config class. Only candidates that pass are then handed to Spring for full annotation processing.
The metadata file
Spring Boot ships META-INF/spring-autoconfigure-metadata.properties, generated at build time by spring-boot-autoconfigure-processor:
spring-autoconfigure-metadata.propertiesorg.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration.ConditionalOnClass=\
com.mongodb.MongoClient
org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration.ConditionalOnClass=\
com.datastax.oss.driver.api.core.CqlSession
So checking "is MongoDB on the classpath?" becomes a single Class.forName() attempt per auto-config, evaluated before any class is loaded.
Parallel evaluation (Spring Boot 2.5+)
If there are more than a threshold (~64) candidates, OnClassCondition splits the work across two threads — each checks half the candidates in parallel. This halves the classpath-scan latency on cold start. You can see this in OnClassCondition.getOutcomes() where it chooses between serial and parallel execution paths.
Measured impact
Without this optimization, a 200-candidate startup would:
- Load each auto-config class (reflection + class init) → ~200 class loads
- Evaluate each
@Beanmethod's conditions individually - Fall through to
@ConditionalOnMissingBeanwhich requires a BeanFactory lookup
With the optimization, 80%+ of candidates are eliminated in a single ~50ms pass before any class loading. Net startup impact: 1-3 seconds saved on a typical app.
Why this matters for library authors
When writing a custom starter, if you annotate your auto-config with @ConditionalOnClass(MyLibraryClass.class), run the spring-boot-autoconfigure-processor annotation processor to generate the metadata file:
xml — pom.xml<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
Without it, Spring has to fall back to reading annotations at runtime — losing the bulk-filter optimization for your starter specifically. This is a common oversight in community starters.