Configuration and profiles

@Configuration, @Bean, component scanning, @Conditional, profiles and property sources — and the precedence order that decides which value wins.

8 min read🌱 Spring Core

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

java
@Configuration
class PricingConfig {
    @Bean
    PricingClient pricingClient(HttpClient http, @Value("${pricing.url}") String url) {
        return new HttpPricingClient(http, url);
    }
}
java
@Service
class OrderService { … }        // found by component scanning

Component 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.

AnnotationRegisters the bean when
@ConditionalOnClassa class is on the classpath
@ConditionalOnMissingBeannobody else defined this type
@ConditionalOnPropertya property has a given value
@ConditionalOnWebApplicationthis is a servlet or reactive app
@Profilea 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

java
@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:

  • @Profile is not security. A bean excluded from prod is 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.yml is read and application-prod.yml is 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 lineOS envapp-prod.ymlapp.yml@Value default
consultedcommand linevalue so farnot setresolvedkeep looking

command line. --pricing.url=… on the java command. Nothing overrides it, which is why it is the right lever in an incident.

1 / 5

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

java
@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 @Value in a BeanFactoryPostProcessor may not be resolved, because that bean has to exist before the configurer has run. This is the same early-instantiation trap as the BeanPostProcessor ordering, 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?

java
@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.yml replaces application.yml." It layers on top. Keys not mentioned still come from the plain file, which is the point.
  • "@Profile keeps 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.
  • "@Value and @ConfigurationProperties resolve 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 @ConditionalOnProperty responds 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.
  • ConfigDataEnvironmentPostProcessor and PropertySourcesPlaceholderConfigurer in the source, for where the ordering is actually established.
  • /actuator/env and the conditions report at /actuator/conditions, which says not just which beans exist but which condition excluded each one that does not.
Progress is saved on this device and to your account when signed in.