Spring Boot Auto-Configuration: Conditional Annotations & Custom Starters Guide
Master Spring Boot conditional annotations (@ConditionalOnClass, @ConditionalOnProperty) and build custom starters for production.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Basic understanding of Spring Boot 3.x (IoC, @Configuration, @Bean)
- ✓Familiarity with Maven/Gradle dependency management
- ✓Java 17+ and Spring Boot 3.2+ installed
โข @ConditionalOnClass loads beans only if a class is on the classpath (e.g., H2 driver) โข @ConditionalOnProperty enables/disables features via application.properties (e.g., feature flag) โข Custom starters bundle auto-configuration into reusable JARs for microservices โข Use @AutoConfigureAfter/@AutoConfigureBefore to control ordering in auto-configuration โข Always test conditional logic with @SpringBootTest and ApplicationContextRunner to avoid silent failures
Think of Spring Boot auto-configuration like a smart kitchen appliance that detects what ingredients you have and automatically sets the right cooking program. Conditional annotations are the sensors that check what's in your pantry (classpath), what settings you've dialed (properties), and whether you've plugged in a specific attachment (bean presence). Custom starters are like pre-packaged meal kits โ you just add them and the appliance knows exactly how to cook that meal without you reading a manual.
Spring Boot's auto-configuration is the killer feature that made it the de facto standard for Java microservices. But here's the thing โ most developers treat it as magic and never look under the hood. I've seen production incidents where a seemingly innocent dependency upgrade caused an entire auto-configuration chain to silently fail, leaving the system in a degraded state for hours. The core mechanism is deceptively simple: Spring Boot scans META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (or the old spring.factories) and applies @Conditional annotations to decide which beans to create. You've probably used @ConditionalOnClass without thinking โ that's how Spring Boot knows to configure an in-memory H2 database when you add the dependency but switch to PostgreSQL when you include its driver. The real power comes when you start building custom starters. At 2.5M RPM payment processing system I worked on, we had a custom 'fraud-detection-starter' that loaded different rule engines based on @ConditionalOnProperty flags per tenant. This article will take you from understanding the built-in conditions to writing production-grade starters that don't break silently. We'll cover the gotchas that the official docs gloss over, like the classloader issues with @ConditionalOnMissingBean and why your custom starter might work locally but fail in Kubernetes. By the end, you'll have the confidence to build auto-configuration that other teams will actually want to use.
How Conditional Annotations Actually Work Under the Hood
Let's cut through the abstraction. When your Spring Boot 3.2 application starts, the AutoConfigurationImportSelector reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. This file contains a list of fully qualified class names of auto-configuration classes. Spring Boot then iterates through them and evaluates each @Conditional annotation using a ConditionEvaluator. The key interface is Condition โ every @Conditional annotation (like @ConditionalOnClass) resolves to a Condition implementation that returns true or false. For example, @ConditionalOnClass uses OnClassCondition, which loads the specified class via the thread context classloader. If the class is found, the condition passes and the @Configuration class is processed. Here's the critical detail most developers miss: the condition is evaluated BEFORE the bean definitions are registered. This means you cannot reference beans created by the same auto-configuration class in your condition โ they don't exist yet. Also, @ConditionalOnMissingBean checks the bean factory's current bean definitions, not the final application context. So if two auto-configuration classes both check @ConditionalOnMissingBean for the same type, the one processed first wins, and the second silently does nothing. This is why ordering matters. Let's look at a real example of a custom condition that checks for a specific environment variable.
What the Official Docs Won't Tell You
The Spring Boot reference documentation is excellent for explaining the happy path. But it conveniently omits the dark corners where production systems fail. Here's what I've learned from 7 years of debugging auto-configuration issues. First, @ConditionalOnClass uses the classloader from ConditionContext, NOT the application classloader. This matters when you have multiple classloaders โ like in a Java EE container or when using Spring Boot DevTools (which uses a restart classloader). If your condition checks for a class that's loaded by a different classloader, it will return false even if the class is technically available. Second, @ConditionalOnMissingBean has a subtle bug (still present in Spring Boot 3.2.x) when used with generic types. If you have @ConditionalOnMissingBean(type = "List<PaymentGateway>"), it checks for a bean named 'paymentGateway' with the exact generic signature. But Spring's bean definition merging may cause the condition to match incorrectly if another auto-configuration defines a raw List without generics. Third, the ordering of auto-configuration classes is NOT guaranteed by the order in the AutoConfiguration.imports file. Spring Boot uses a sorting algorithm based on @AutoConfigureOrder, @AutoConfigureBefore, and @AutoConfigureAfter. If you don't use these annotations explicitly, the order is undefined and can change between Spring Boot versions. I've seen a minor patch upgrade from 3.1.5 to 3.1.6 reorder two classes because of a change in the sorting algorithm.
Building a Production-Grade Custom Starter from Scratch
Now let's build a custom starter for a real-world scenario: a 'payment-rate-limiter-starter' that automatically configures rate limiting based on the client's IP and API key. A custom starter is essentially a JAR that contains an auto-configuration class and a spring.factories (or AutoConfiguration.imports) file. The key design principle is 'opinionated but overridable' โ provide sensible defaults but let the consuming application customize everything. Start by creating a Maven module with the starter code. The module should have two dependencies: spring-boot-autoconfigure (for the auto-configuration support) and spring-boot-starter (for the base starter). Do NOT include spring-boot-starter-web โ let the consuming application choose its web stack. Your auto-configuration class should use @ConditionalOnClass to check for required classes (like RedisTemplate if you're using Redis for rate limit storage), @ConditionalOnProperty to check for configuration properties, and @ConditionalOnMissingBean to allow the application to override your beans. The properties should be bound to a @ConfigurationProperties class with a prefix like 'rate-limiter'. Always provide a default implementation that works without any configuration โ for example, an in-memory rate limiter that kicks in if Redis is not available. This makes your starter easy to try out. Also, create a health indicator that reports the status of the rate limiter (e.g., Redis connection health). This integrates with Spring Boot Actuator automatically.
Testing Auto-Configuration: The ApplicationContextRunner Pattern
Testing auto-configuration is where most developers fail. You can't just write a unit test for the configuration class because it depends on the Spring context. The correct approach is to use ApplicationContextRunner from spring-boot-test-autoconfigure. This utility creates a minimal ApplicationContext with your auto-configuration and lets you assert which beans were created. The pattern is simple: create an ApplicationContextRunner, register your auto-configuration class, set properties, and then run assertions. The critical thing to test is the conditional behavior: does the bean get created when the condition is met? Does it NOT get created when the condition is absent? Also test ordering: does your auto-configuration correctly defer to another when both are present? And test the failure mode: what happens when a required dependency is missing? Use @ConditionalOnMissingBean tests to ensure your starter doesn't override a user-defined bean. Here's a complete test for the rate limiter starter. Notice how we test both the Redis and in-memory paths, and also test with a custom bean to verify @ConditionalOnMissingBean works.
Advanced Conditional Annotations: @ConditionalOnBean and @ConditionalOnResource
Beyond the common @ConditionalOnClass and @ConditionalOnProperty, Spring Boot provides several other conditional annotations that solve specific problems. @ConditionalOnBean is tricky โ it checks if a bean of the specified type already exists in the BeanFactory. But here's the catch: it only checks beans that have already been registered, not beans that will be created later. This makes it dangerous in auto-configuration because the order of processing matters. If you use @ConditionalOnBean for a bean that's defined in another auto-configuration class that hasn't been processed yet, your condition will fail even though the bean will eventually exist. The solution is to use @ConditionalOnMissingBean instead, which is more predictable. @ConditionalOnResource checks for the existence of a resource on the classpath. This is useful for feature toggles based on configuration files. For example, you can conditionally enable a feature if a 'features.json' file exists. Another advanced annotation is @ConditionalOnExpression, which evaluates a SpEL expression. This is powerful but dangerous โ SpEL expressions are evaluated at runtime and can fail silently if the expression is malformed. I've seen a production incident where a typo in a SpEL expression caused a bean to not be created, and the error was only visible in the debug logs. Finally, @ConditionalOnJndi is useful for legacy applications that still use JNDI lookups. In modern microservices, you rarely need it, but it's there for completeness.
Custom Condition Implementation: Beyond the Built-in Annotations
Sometimes the built-in conditions aren't enough. You need to check something like a database schema version, a secret from Vault, or a specific system property. In those cases, you implement the Condition interface directly. The key method is matches(ConditionContext context, AnnotatedTypeMetadata metadata). The ConditionContext gives you access to the BeanFactory, Environment, ResourceLoader, and ClassLoader. The AnnotatedTypeMetadata lets you read annotation attributes from the @Conditional annotation. A common pattern is to create a custom annotation that wraps @Conditional and your custom Condition class. For example, @ConditionalOnSchemaVersion that checks a database migration version before enabling certain beans. The implementation should be fast โ Condition evaluation happens during startup, and slow conditions can significantly increase application startup time. Cache the result if possible, but be careful: the condition might be evaluated multiple times for different beans. Also, the condition must be deterministic โ given the same inputs, it must always return the same result. Non-deterministic conditions (like checking the current time) can lead to race conditions where some beans are created and others aren't. Here's an example of a custom condition that checks if a specific environment variable is set to a specific value, with support for multiple values.
Auto-Configuration Ordering and Override Strategies
When multiple auto-configuration classes define beans for the same type, Spring Boot needs a deterministic way to decide which one wins. The official mechanism is @AutoConfigureOrder (with lower values having higher priority, defaulting to 0), @AutoConfigureBefore, and @AutoConfigureAfter. But in practice, the most robust strategy is to use @ConditionalOnMissingBean. This allows the consuming application to define its own bean and have it automatically override the auto-configured one. However, this only works if the user's bean is registered before the auto-configuration runs. If the user defines the bean in a @Configuration class that's imported after your auto-configuration, the @ConditionalOnMissingBean won't see it, and you'll end up with two beans. The solution is to document that users should define their beans in a @Configuration class that's imported early, or use @AutoConfigureBefore to ensure your auto-configuration runs after user configurations. Another strategy is to use @ConditionalOnProperty with a 'default' value. For example, your auto-configuration creates a bean only if the property 'my-starter.enabled' is true (which is the default). If the user sets it to false, your bean is not created, and they can define their own. This is the most explicit and least surprising approach. For complex scenarios, consider using a @Primary annotation on the default bean so that if multiple beans exist, the default is injected unless the user specifies @Qualifier.
Production Debugging: When Auto-Configuration Goes Wrong
When auto-configuration fails in production, the symptoms are often confusing: beans not found, wrong beans injected, or startup failures that only happen in certain environments. The first step is always to enable debug logging for auto-configuration. Set 'debug=true' in application.properties or add '--debug' to the JVM arguments. This prints a detailed auto-configuration report showing which conditions passed and which failed. The report is divided into 'Positive matches' (conditions that passed) and 'Negative matches' (conditions that failed). Look for unexpected negative matches โ a condition that should have passed but didn't. Common causes include: classloader issues (class is available but not visible to the condition), property name typos (e.g., 'rate-limiter.enabled' vs 'ratelimiter.enabled'), and ordering issues (condition evaluated before the required bean was registered). Another powerful tool is the /actuator/conditions endpoint (if Actuator is enabled). It exposes the same report via HTTP. For custom starters, add a health indicator that reports the status of the auto-configured bean. For example, if your starter creates a RedisTemplate, the health indicator should check if Redis is reachable. This gives you early warning if the auto-configuration succeeded but the underlying dependency is unhealthy. Finally, add a @EventListener(ApplicationReadyEvent.class) that logs the state of all beans created by your starter. This provides an audit trail in the logs.
The Case of the Missing Payment Gateway Bean
- Never rely on classpath scanning order for auto-configuration classes โ always use @AutoConfigureAfter/@AutoConfigureBefore explicitly
- Use @ConditionalOnMissingBean with specific type references, not just class literals, when dealing with interfaces
- Add startup health checks that verify critical beans exist before accepting traffic
curl -s http://localhost:8080/actuator/conditions | jq '.contexts."application".positiveMatches'curl -s http://localhost:8080/actuator/conditions | jq '.contexts."application".negativeMatches'| File | Command / Code | Purpose |
|---|---|---|
| EnvironmentCondition.java | public class EnvironmentCondition implements Condition { | How Conditional Annotations Actually Work Under the Hood |
| StarterAutoConfiguration.java | @AutoConfiguration | What the Official Docs Won't Tell You |
| RateLimiterAutoConfiguration.java | @AutoConfiguration | Building a Production-Grade Custom Starter from Scratch |
| RateLimiterAutoConfigurationTest.java | class RateLimiterAutoConfigurationTest { | Testing Auto-Configuration |
| FeatureToggleAutoConfiguration.java | @AutoConfiguration | Advanced Conditional Annotations |
| ConditionalOnEnvVariable.java | @Target({ElementType.TYPE, ElementType.METHOD}) | Custom Condition Implementation |
| OrderedAutoConfiguration.java | @AutoConfiguration | Auto-Configuration Ordering and Override Strategies |
| AutoConfigurationHealthIndicator.java | @Component | Production Debugging |
Key takeaways
Interview Questions on This Topic
Explain how Spring Boot decides which auto-configuration classes to apply. What is the role of the AutoConfiguration.imports file?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
7 min read · try the examples if you haven't