Condition Evaluation: How @ConditionalOn* Annotations Are Processed
The Condition Infrastructure
Every @ConditionalOn* annotation in Spring Boot delegates to a Condition implementation. The base class for all Spring Boot conditions is SpringBootCondition, which extends Spring Framework’s Condition interface:
// Spring Framework
public interface Condition {
boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}
// Spring Boot
public abstract class SpringBootCondition implements Condition {
@Override
public final boolean matches(ConditionContext context,
AnnotatedTypeMetadata metadata) {
// 1. Determine the outcome
ConditionOutcome outcome = getMatchOutcome(context, metadata);
// 2. Record the result in the ConditionEvaluationReport
recordEvaluation(context, outcome);
// 3. Return match/no-match
return outcome.isMatch();
}
// Subclasses implement this
public abstract ConditionOutcome getMatchOutcome(
ConditionContext context, AnnotatedTypeMetadata metadata);
}
Every evaluation is recorded. Every single one. SpringBootCondition.recordEvaluation() writes to the ConditionEvaluationReport stored in the BeanFactory. This is how the --debug flag and the /actuator/conditions endpoint know what happened.
The three primary condition implementations are:
OnClassConditionfor@ConditionalOnClassand@ConditionalOnMissingClassOnBeanConditionfor@ConditionalOnBeanand@ConditionalOnMissingBeanOnPropertyConditionfor@ConditionalOnProperty
Two-Phase Evaluation
Spring Boot evaluates conditions in two distinct phases, controlled by the ConfigurationPhase enum:
public enum ConfigurationPhase {
PARSE_CONFIGURATION, // Phase 1: before bean definitions are loaded
REGISTER_BEAN // Phase 2: when individual @Bean methods are registered
}
Phase 1: PARSE_CONFIGURATION
Phase 1 conditions are evaluated when the ConfigurationClassParser first encounters an auto-configuration class. At this point, no @Bean methods from this class have been processed. The purpose is to decide: should we parse this configuration class at all?
@ConditionalOnClass operates in Phase 1. When placed at the class level:
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
// This entire class is skipped if javax.sql.DataSource is not on the classpath
}
The evaluation happens before Spring reads any @Bean method inside the class. If the condition fails, the class is discarded immediately. No method-level annotations are inspected. No bean definitions are created.
Phase 2: REGISTER_BEAN
Phase 2 conditions are evaluated when individual @Bean methods are being registered as bean definitions. At this point, the configuration class has been parsed, and bean definitions from previously processed configuration classes exist in the BeanDefinitionRegistry.
@ConditionalOnBean and @ConditionalOnMissingBean operate in Phase 2. They need to inspect the bean registry, which only has content after configuration classes have been processed:
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(DataSource.class)
public class JdbcTemplateAutoConfiguration {
@Bean
@ConditionalOnMissingBean(JdbcTemplate.class) // Phase 2: checks bean registry
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
The @ConditionalOnClass at the class level runs in Phase 1. The @ConditionalOnMissingBean at the method level runs in Phase 2. By Phase 2, DataSourceAutoConfiguration has already registered its DataSource bean definition (because of @AutoConfigureAfter), so the dataSource parameter can be resolved.
How @ConditionalOnClass Avoids Class Loading
OnClassCondition needs to check whether a class exists on the classpath without actually loading it. Loading a class that references missing dependencies would trigger NoClassDefFoundError. The solution is ASM bytecode reading.
When AutoConfigurationImportFilter processes candidates before they are imported, OnClassCondition reads the annotation metadata directly from the class file bytes using ASM’s ClassReader:
// Simplified flow inside OnClassCondition
// 1. Read the @ConditionalOnClass annotation value from bytecode
String[] classNames = metadata.getAnnotationAttributes(
ConditionalOnClass.class.getName()
).getStringArray("value");
// 2. Check each class name using ClassLoader.getResource(), NOT Class.forName()
for (String className : classNames) {
String resourcePath = className.replace('.', '/') + ".class";
if (classLoader.getResource(resourcePath) == null) {
return ConditionOutcome.noMatch("Class not found: " + className);
}
}
The check converts the class name to a resource path and uses ClassLoader.getResource(). This is a file existence check on the classpath. It never triggers class initialization, static blocks, or dependency resolution. If com.mongodb.client.MongoClient is not on the classpath, getResource("com/mongodb/client/MongoClient.class") returns null, and MongoAutoConfiguration is discarded without any attempt to load MongoDB classes.
This ASM-based filtering happens early in the AutoConfigurationImportSelector pipeline, before the configuration classes are passed to ConfigurationClassParser. It is a performance optimization: filtering out 100+ configuration classes by checking for the presence of a .class file is faster than loading and parsing each configuration class only to discard it later.
How @ConditionalOnBean Searches the Registry
OnBeanCondition searches the BeanDefinitionRegistry and the ConfigurableListableBeanFactory for matching beans. The search is more complex than a simple name lookup:
// Simplified OnBeanCondition logic
ConditionOutcome getMatchOutcome(ConditionContext context,
AnnotatedTypeMetadata metadata) {
// Extract search parameters from @ConditionalOnBean
BeanSearchSpec spec = new BeanSearchSpec(metadata);
// Search by type
Set<String> matching = new LinkedHashSet<>();
for (String type : spec.getTypes()) {
matching.addAll(
BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
context.getBeanFactory(), resolve(type)
)
);
}
// Search by name
for (String name : spec.getNames()) {
if (context.getBeanFactory().containsBeanDefinition(name)) {
matching.add(name);
}
}
if (matching.isEmpty()) {
return ConditionOutcome.noMatch("No bean of type " + spec.getTypes());
}
return ConditionOutcome.match("Found beans: " + matching);
}
The SearchStrategy parameter controls the scope. SearchStrategy.ALL (the default) searches the current context and all ancestor contexts. SearchStrategy.CURRENT searches only the current context. In a multi-tenant SaaS backend with a parent-child context setup (shared beans in parent, tenant-specific beans in child), the search strategy determines whether a shared DataSource in the parent context satisfies @ConditionalOnBean(DataSource.class) in the child.
The ConditionEvaluationReport in Practice
Via —debug
Start the application with --debug or set debug=true in application.properties:
java -jar saas-backend.jar --debug
The ConditionEvaluationReportLogger prints the full report at INFO level. The report has four sections:
Positive matches: Auto-configuration classes (and individual beans) whose conditions all passed.
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required classes 'javax.sql.DataSource',
'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType'
(OnClassCondition)
DataSourceAutoConfiguration.PooledDataSourceConfiguration matched:
- @ConditionalOnMissingBean did not find any beans of type
'javax.sql.DataSource' or 'javax.sql.XADataSource' (OnBeanCondition)
Negative matches: Auto-configuration classes whose conditions failed.
Negative matches:
-----------------
MongoAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'com.mongodb.client.MongoClient' (OnClassCondition)
Exclusions: Classes removed by exclude or excludeName before condition evaluation.
Unconditional classes: Configuration classes with no @Conditional annotations.
Via /actuator/conditions
Add spring-boot-starter-actuator and expose the endpoint:
management:
endpoints:
web:
exposure:
include: conditions
curl http://localhost:8080/actuator/conditions | jq .
The JSON output mirrors the report structure, making it suitable for programmatic inspection. In a CI pipeline, you can assert that specific auto-configurations matched or did not match.
The Failure Mode: @ConditionalOnBean at Class Level
In a multi-tenant SaaS backend, you create an auto-configuration that provides a TenantResolver only if a DataSource exists:
// BROKEN: @ConditionalOnBean at class level evaluates in Phase 1
@AutoConfiguration
@ConditionalOnBean(DataSource.class)
public class TenantResolverAutoConfiguration {
@Bean
public TenantResolver tenantResolver(DataSource dataSource) {
return new JdbcTenantResolver(dataSource);
}
}
@ConditionalOnBean at the class level is evaluated in Phase 1 (PARSE_CONFIGURATION). At this point, DataSourceAutoConfiguration may not have been processed yet. The DataSource bean definition does not exist in the registry. The condition fails. TenantResolverAutoConfiguration is discarded.
The behavior is non-deterministic. It depends on the processing order of configuration classes. If DataSourceAutoConfiguration happens to be processed first (due to alphabetical ordering or @AutoConfigureAfter), the DataSource bean definition exists and the condition passes. If it is processed second, the condition fails. Adding or removing an unrelated dependency can change the processing order and flip the result.
The --debug output reveals the problem:
Negative matches:
-----------------
TenantResolverAutoConfiguration:
Did not match:
- @ConditionalOnBean (types: javax.sql.DataSource; SearchStrategy: all)
did not find any beans (OnBeanCondition)
The message says “did not find any beans.” This is accurate at the time of evaluation, but misleading: the DataSource bean will exist later, after DataSourceAutoConfiguration runs.
The Correct Pattern
Move @ConditionalOnBean to the @Bean method level. Use @ConditionalOnClass at the class level for the Phase 1 gate:
// CORRECT: @ConditionalOnClass at class level (Phase 1),
// @ConditionalOnBean at method level (Phase 2)
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(DataSource.class)
public class TenantResolverAutoConfiguration {
@Bean
@ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean(TenantResolver.class)
public TenantResolver tenantResolver(DataSource dataSource) {
return new JdbcTenantResolver(dataSource);
}
}
Three changes:
-
@ConditionalOnClass(DataSource.class)at class level. This is Phase 1. It checks whether theDataSourceclass exists on the classpath (using ASM, no class loading). If the JDBC module is not present, the entire configuration is skipped cheaply. -
@AutoConfiguration(after = DataSourceAutoConfiguration.class)ensures this class is processed afterDataSourceAutoConfiguration. By the time Phase 2 runs for this class, theDataSourcebean definition exists. -
@ConditionalOnBean(DataSource.class)at method level. This is Phase 2. It checks the bean registry, which now contains theDataSourcebean definition fromDataSourceAutoConfiguration. -
@ConditionalOnMissingBean(TenantResolver.class)allows users to define their ownTenantResolver. If they do, the auto-configured one backs off.
The --debug output now shows:
Positive matches:
-----------------
TenantResolverAutoConfiguration matched:
- @ConditionalOnClass found required class 'javax.sql.DataSource'
(OnClassCondition)
TenantResolverAutoConfiguration#tenantResolver matched:
- @ConditionalOnBean (types: javax.sql.DataSource; SearchStrategy: all)
found bean 'dataSource' (OnBeanCondition)
- @ConditionalOnMissingBean (types: com.example.saas.TenantResolver;
SearchStrategy: all) did not find any beans (OnBeanCondition)
The rule is straightforward: class-level conditions must not depend on bean definitions. Method-level conditions can. @ConditionalOnClass, @ConditionalOnWebApplication, and @ConditionalOnProperty are safe at class level because they check the classpath, the application type, or the environment, none of which depend on bean registration order. @ConditionalOnBean and @ConditionalOnMissingBean depend on what beans have been registered, which is order-sensitive, so they belong on @Bean methods where evaluation is deferred to Phase 2.