Configuration and profiles
@Configuration, @Bean, component scanning, @Conditional, profiles and property sources — and the precedence order that decides which value wins.
Configuration is where a Spring application meets the world it is deployed into, and where the largest share of "works on my machine" comes from. Almost all of it reduces to two questions: which beans exist here, and which value wins for this key. This lesson answers both — the conditions that decide the first, and the precedence order that decides the second.
Two ways to declare a bean, and when each is right
@Configuration
class PricingConfig {
@Bean
PricingClient pricingClient(HttpClient http, @Value("${pricing.url}") String url) {
return new HttpPricingClient(http, url);
}
}@Service
class OrderService { … } // found by component scanningComponent scanning is for your classes: annotate and the container finds them under the scanned packages. @Bean is for everything else — a class from a library you cannot annotate, an object that needs construction logic, one whose type depends on configuration.
@SpringBootApplication includes @ComponentScan rooted at its own package, which is the whole reason the main class belongs at the top of the package tree. A class outside it is not scanned, not registered, and produces the most common "why is this bean missing" in Spring Boot.
The @Conditional family
A condition decides whether a definition is registered at all. Spring Boot's entire auto-configuration is built from them.
| Annotation | Registers the bean when |
|---|---|
@ConditionalOnClass | a class is on the classpath |
@ConditionalOnMissingBean | nobody else defined this type |
@ConditionalOnProperty | a property has a given value |
@ConditionalOnWebApplication | this is a servlet or reactive app |
@Profile | a named profile is active |
@ConditionalOnMissingBean is the one that makes Boot feel like it reads your mind: every auto-configured bean is declared with it, so defining your own DataSource silently replaces Boot's instead of colliding with it. The ordering that makes this work — your configuration is processed before auto-configuration — is not something to rely on in your own code, where two beans both claiming "only if missing" is a race you cannot see.
Conditions are evaluated once, at startup, against definitions. They cannot respond to anything that changes later.
Profiles
@Bean @Profile("!prod")
DataSource devDataSource() { return embedded(); }A profile is a label on a definition. spring.profiles.active=staging activates one; a bean with no @Profile is always registered.
Two rules worth internalising:
@Profileis not security. A bean excluded fromprodis excluded because a string matched, and that string comes from configuration that a deployment can get wrong. An endpoint that must not exist in production should not be in the artefact.- Profile-specific properties layer, not replace.
application.ymlis read andapplication-prod.ymlis read on top; the profile-specific file only needs the keys that differ.
spring.profiles.include, spring.profiles.group and the default profile exist and are worth knowing, but three profiles is usually one too many already: each one doubles the number of configurations nobody tests.
Which value wins
@Value("${pricing.url}") and @ConfigurationProperties both resolve against an ordered list of property sources. The list is long; the part that matters day to day is short, and it is ordered highest precedence first:
command line. --pricing.url=… on the java command. Nothing overrides it, which is why it is the right lever in an incident.
OS env. PRICING_URL. Relaxed binding maps the underscore form onto the dotted key — this is the layer a container platform injects.
app-prod.yml. Profile-specific file. It would have won over the plain file, but the environment already answered.
app.yml. The committed default. Read for every key the layers above did not set.
@Value default. ${pricing.url:http://localhost:9090} — the last resort, and the reason a missing key can fail silently instead of loudly.
The consequence people trip over: a value set in application.yml cannot be overridden by editing application.yml if something above it is also setting the key. An environment variable left over from a previous deployment beats every file in the artefact, and it does so silently.
/actuator/env answers this directly in a running application — it lists every source in order and shows which one supplied each key. It is the fastest way to end an argument about configuration, and the reason to expose it somewhere, even if not publicly.
@ConfigurationProperties over @Value
@ConfigurationProperties(prefix = "pricing")
@Validated
record PricingSettings(@NotBlank String url, @Positive int timeoutMs, boolean cacheEnabled) {}Against a scatter of @Value annotations this buys four things: the keys are one type you can navigate to, validation runs at startup rather than at first use, relaxed binding handles PRICING_TIMEOUT_MS and pricing.timeout-ms alike, and the object can be passed to a test constructor without a container.
@Value remains right for a single key in a single place. A group of related keys wants a type.
Under the hood: when placeholders are resolved
${…} is not resolved by the annotation. PropertySourcesPlaceholderConfigurer is a BeanFactoryPostProcessor, which — from the previous lesson — runs over definitions, before any bean is instantiated. That ordering is what makes @Value on a constructor parameter work at all: by the time the constructor is called, the placeholder is already a literal.
It also explains two behaviours that look inconsistent:
- A
@Valuein aBeanFactoryPostProcessormay not be resolved, because that bean has to exist before the configurer has run. This is the same early-instantiation trap as theBeanPostProcessorordering, one phase earlier. - An unresolvable placeholder fails at startup with
Could not resolve placeholder 'pricing.url'— unless a default was supplied, in which case the application starts with the default and nobody learns the key was missing. Defaults are a trade: they turn a loud failure into a quiet one, and they are right only where the fallback is genuinely correct.
Boot's Environment layers PropertySources in the documented order; @PropertySource adds one but lands below both files and environment variables, which surprises people using it to "override" something.
Walkthrough: the timeout that came back from the dead
A service had its pricing timeout raised from 500ms to 3000ms during an incident, by editing application-prod.yml. It worked. Six weeks later the timeouts returned, and the file still said 3000.
During the incident someone had also set PRICING_TIMEOUT_MS=3000 on the running container to make the change take effect immediately. The deployment that followed inherited that variable from the platform's saved configuration. The one six weeks later was created from a clean template and did not — and the file change, which had never actually been the thing taking effect, was revealed to have been dead all along. Neither change was wrong; nobody knew both existed.
/actuator/env would have shown systemEnvironment supplying the value and the YAML entry sitting underneath it, greyed out, from the first day.
Try it yourself
Which value?
application.yml has app.mode: safe. application-prod.yml has app.mode: fast. The container sets APP_MODE=debug. The application starts with --spring.profiles.active=prod.
Answer
debug. The OS environment beats both files, and relaxed binding maps APP_MODE onto app.mode. The profile-specific file beats the plain one, so without the variable it would be fast; safe only wins when prod is not active and nothing else sets the key. A command-line --app.mode=… would beat all three.
Why is the bean missing?
@SpringBootApplication // in com.example.api
class Application { … }
@Service // in com.example.shared
class AuditService { … }Answer
com.example.shared is not under com.example.api, so component scanning never sees it and no definition is registered. Injecting it fails at startup with "no qualifying bean". Move the main class up to com.example, add the package to @ComponentScan, or — for a genuinely shared module — declare it with @Bean in a configuration the application does import. Moving the main class is usually right and is why the convention exists.
Why did validation not run?
A @ConfigurationProperties record has @NotBlank on a field. A blank value starts the application anyway.
Answer
@Validated is missing from the class. @ConfigurationProperties binds; validation is opt-in and needs the annotation plus a Bean Validation implementation on the classpath. Without both, the constraint annotations are inert metadata. With both, a blank value stops startup with the field named — which is the entire reason to prefer this over @Value.
Misconceptions
- "
application-prod.ymlreplacesapplication.yml." It layers on top. Keys not mentioned still come from the plain file, which is the point. - "
@Profilekeeps the bean out of production." It keeps it unregistered while a string does not match. The class is still in the artefact and still reachable by anything that constructs it directly. - "
@Valueand@ConfigurationPropertiesresolve differently." Same sources, same order. What differs is validation, relaxed binding on nested keys, and whether the values have a type. - "A default in
${key:default}is harmless." It converts a missing-configuration failure at startup into a wrong-configuration success at run time. - "Conditions re-evaluate." Once, at startup, against definitions. Nothing about
@ConditionalOnPropertyresponds to a property changing later.
Going deeper
- Spring Boot reference, Externalized Configuration — the full precedence list, which has more entries than the five that matter most.
ConfigDataEnvironmentPostProcessorandPropertySourcesPlaceholderConfigurerin the source, for where the ordering is actually established./actuator/envand the conditions report at/actuator/conditions, which says not just which beans exist but which condition excluded each one that does not.