Spring · 11

Configuration & Profiles

Everything that legitimately differs between deployments of one build — and how Spring Boot gathers it. The Environment as a priority stack of property sources, application.yml and relaxed binding, @Value versus type-safe @ConfigurationProperties records with validation, profiles and groups, secrets for containers, and config-driven wiring — capped by a plain-Java mini Environment you can run.

12
Chapters
12
Property source tiers
6
Diagrams
1
Runnable demo
01
Chapter One · Why It Exists

Configuration Is What Varies Between Deploys

Every Spring Boot service runs in at least four places: a laptop, CI, a shared staging cluster and production. The code is identical in all four. The values are not: the database URL, the downstream payment host, the thread-pool size, the log level, the feature flags, and above all the credentials. Configuration is everything that legitimately differs between deployments of the same build. If you have to rebuild the jar to change it, it was code, not configuration.

Boot's answer is the Environment: an ordered stack of PropertySource objects, each a bag of name/value pairs (command line, OS environment variables, application.yml, ...). A lookup walks the stack from highest to lowest priority and returns the first hit. Everything on this page — profiles, @Value, @ConfigurationProperties, secrets, @ConditionalOnProperty — is a way of writing into or reading from that one stack.

Before — configuration baked into code
@Service public class PaymentClient { private final String url = "https://pay.example.com/v1"; // prod value hard-coded private final int maxRetries = 3; private final String apiKey = "sk_live_51H8..."; // secret in git history forever // to test against the sandbox you must edit, rebuild and redeploy a different artifact }
After — one artifact, values injected from outside
@ConfigurationProperties(prefix = "payment") public record PaymentProperties( String url, @DefaultValue("3") int maxRetries, @DefaultValue("2s") Duration timeout, String apiKey) { }
Same jar, three environments
$ java -jar orders.jar # laptop: application.yml defaults (sandbox URL) $ java -jar orders.jar --spring.profiles.active=staging # CI/staging: application-staging.yml on top $ SPRING_PROFILES_ACTIVE=prod PAYMENT_APIKEY=$KEY java -jar orders.jar # prod: env vars override everything below them
One immutable artifact, different configuration injected by each environment
orders-1.4.2.jarbuilt once, never editeddev (laptop)application-dev.yml · H2 · sandbox URL · log DEBUGstaging (Kubernetes)ConfigMap → env vars · shared Postgres · log INFOprod (Kubernetes)Secret + ConfigMap · RDS · live API key · log WARNThe Environment abstraction hides where values come from; the code only asks for payment.url.
Analogy: Think of the jar as a car and configuration as the driver's seat, mirrors and radio presets. Same car, but you do not rebuild the engine when a different driver gets in — you adjust the things designed to be adjusted, and you do not weld the owner's house key into the dashboard (that is the hard-coded apiKey).
The twelve-factor rule Factor III of the twelve-factor app: store config in the environment. Strict separation of config from code, and config that varies by deploy lives in environment variables (or files mounted at deploy time) — never in the repository, never in per-environment branches or per-environment builds.
02
Chapter Two · Mental Model

The Environment Is a Priority Stack

A PropertySource is a named collection of properties (commandLineArgs, systemEnvironment, Config resource 'class path resource [application.yml]', ...)
▶
Each source knows how to answer getProperty(name) → value or null. It has no idea the others exist
The Environment holds a MutablePropertySources list in priority order
▶
env.getProperty("server.port") iterates the list top to bottom and returns the first non-null value — first match wins, no merging
SpringApplication.run() builds the Environment before the ApplicationContext exists
▶
Config files are loaded by ConfigDataEnvironmentPostProcessor, so properties can influence which beans, profiles and auto-configurations are even considered
Names are normalised: SERVER_PORT, server.port and server-port are the same property
▶
ConfigurationPropertySources wraps every source with an adapter that applies relaxed-binding rules at lookup time (Chapter 04)
${other.key:default} placeholders inside values are resolved lazily
▶
A value can reference other properties; resolution happens at read time through PropertySourcesPropertyResolver, and fails with IllegalArgumentException if no default is given and the key is missing
Two consumers read the same stack: @Value and the Binder (used by @ConfigurationProperties)
▶
They differ in how they convert and validate, not in where data comes from — so precedence rules are identical for both
Lookup of server.port walks the stack top-down and stops at the first hit
HIGHEST PRIORITYcommandLineArgsno --server.port givensystemEnvironmentSERVER_PORT=9000 (HIT)application-prod.ymlserver.port: 8443 (shadowed)application.ymlserver.port: 8080 (shadowed)defaultProperties(nothing)LOWEST PRIORITY← asked first: miss← asked second: HITnever asked (shadowed)never asked (shadowed)never askedreturns "9000"Tomcat binds :9000env.getProperty("server.port")

Two properties of this design explain nearly every configuration surprise. First, lookup is per key, not per file: if application-prod.yml overrides only server.port, every other key still falls through to application.yml. Second, values are never merged across sources: a list or map defined in two places is replaced wholesale by the higher-priority source (the one exception being maps bound via Binder, which are merged key by key — lists are not).

Poking the Environment directly (rarely needed in app code, invaluable when debugging)
@Component class ConfigProbe implements ApplicationRunner { private final ConfigurableEnvironment env; ConfigProbe(ConfigurableEnvironment env) { this.env = env; } @Override public void run(ApplicationArguments args) { env.getPropertySources().forEach(ps -> System.out.println(ps.getName())); System.out.println("port = " + env.getProperty("server.port")); System.out.println("port as int = " + env.getProperty("server.port", Integer.class, 8080)); System.out.println("required = " + env.getRequiredProperty("payment.url")); // IllegalStateException if absent System.out.println("profiles = " + Arrays.toString(env.getActiveProfiles())); } }
03
Chapter Three · Property Sources

Where Values Come From, and Who Wins

Boot documents a fixed order for about seventeen kinds of source. You rarely need all of them; the useful mental shortcut is the closer a source is to the running process, the higher its priority. Command-line flags beat environment variables, which beat files in the working directory, which beat files packaged inside the jar, which beat defaults.

PrioritySourceTypical useNotes
1 (highest)@TestPropertySource, @SpringBootTest(properties=...)TestsOnly present when a test context is created
2Command-line arguments --key=valueOne-off override, java -jarDisable with setAddCommandLineProperties(false)
3SPRING_APPLICATION_JSON (env var or spring.application.json)Inject a JSON blob from a platform that only offers one variableFlattened into normal properties
4JVM system properties -Dkey=valueJVM-level flags, legacy launchersVisible to every library, not just Spring
5OS environment variablesContainers, Kubernetes, CIName mapping: SERVER_PORT → server.port
6${random.*} sourceRandom port/secret in tests${random.int(1000,2000)}, ${random.uuid}
7Profile-specific files outside the jarOps-managed overrides next to the jar./config/application-prod.yml
8Profile-specific files inside the jarEnvironment defaults shipped with the buildapplication-prod.yml on the classpath
9Base application.yml/.properties outside the jarOps-managed base values./application.yml
10Base application.yml/.properties inside the jarSane defaultsLowest of the "real" config files
11@PropertySource on @Configuration classesExtra .properties filesNo YAML support; loaded too late for logging.*, spring.main.*
12 (lowest)SpringApplication.setDefaultProperties(...)Programmatic defaults before startupOnly way to default something before any file is read

File locations. Boot searches these in order, and later locations override earlier ones: classpath:/, classpath:/config/, file:./, file:./config/, file:./config/*/. Within a single location, .properties beats .yml for the same key. Three knobs change the search:

PropertyEffectExample
spring.config.nameBase name instead of application--spring.config.name=orders → orders.yml
spring.config.locationReplaces the default locations--spring.config.location=file:/etc/orders/
spring.config.additional-locationAdds locations above the defaults--spring.config.additional-location=file:/etc/orders/
spring.config.importPull in more files, config trees, vault sources from inside a config filespring.config.import=optional:file:/etc/orders/local.yml
Importing external configuration — the Boot 2.4+ way
# application.yml spring: config: import: - optional:file:./local-overrides.yml # silently skipped if absent - optional:configtree:/etc/config/ # one file per key, K8s style - "aws-secretsmanager:prod/orders" # Spring Cloud AWS: fail at startup if missing
StepWhat Boot doesClass involved
1Create the Environment with servlet/system/env sourcesSpringApplication.prepareEnvironment
2Read command-line args into commandLineArgsDefaultApplicationArguments
3Locate and load application.* (no profile) and resolve spring.config.importConfigDataEnvironmentPostProcessor
4Read spring.profiles.active / groups → load application-{profile}.* filesConfigDataEnvironment
5Insert each file as a PropertySource at the right priorityMutablePropertySources
6Only now create the ApplicationContext; beans can read the finished stackSpringApplication.createApplicationContext
⚠ Production gotchas & how to diagnose them:
  • Setting spring.profiles.active in a profile-specific file → InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from location 'class path resource [application-prod.yml]' is invalid in a profile specific resource. Profiles must be decided before profile files load; set them in application.yml, an env var or on the command line.
  • Expecting two files to merge a list. payment.currencies: [USD, EUR] in application.yml and [GBP] in application-prod.yml gives [GBP], not three entries. Symptom: a currency silently disappears in prod.
  • Using @PropertySource for a .yml file — the YAML is parsed as a flat properties file and no key resolves. Use spring.config.import instead.
  • Forgetting optional: on an import that may legitimately be missing → ConfigDataLocationNotFoundException: Config data location 'file:./local-overrides.yml' cannot be found and the app refuses to start. Equally, omit optional: for a secrets import so a missing secret fails the deploy loudly.
  • Stale application.yml in the working directory overriding the jar's: a forgotten ./config/application.yml on one server explains "works on staging-1, not staging-2". Diagnose with /actuator/env/<key>, which lists every source that defines the key and which one wins.
04
Chapter Four · File Format & Naming

application.yml and Relaxed Binding

YAML is a nicer way to write the same flat key/value pairs. Boot flattens nested maps into dotted keys and lists into indexed keys (payment.currencies[0]), then everything is a property like any other. That flattening is also the source of the classic YAML mistakes.

application.yml
server: port: 8080 shutdown: graceful spring: application: name: orders datasource: url: jdbc:postgresql://localhost:5432/orders username: orders password: ${DB_PASSWORD} # resolved from env var; no default => fail fast payment: url: https://${payment.host:sandbox.pay.example.com}/v1 max-retries: 3 timeout: 2s currencies: - USD - EUR routes: # a map eu: https://eu.pay.example.com us: https://us.pay.example.com
...is exactly this flat set (what /actuator/env shows)
server.port=8080 server.shutdown=graceful spring.application.name=orders spring.datasource.url=jdbc:postgresql://localhost:5432/orders payment.url=https://sandbox.pay.example.com/v1 payment.max-retries=3 payment.timeout=2s payment.currencies[0]=USD payment.currencies[1]=EUR payment.routes.eu=https://eu.pay.example.com payment.routes.us=https://us.pay.example.com
Relaxed binding — one property, many spellings

When the Binder maps properties to a Java field it compares names in a canonical form (lower case, dashes and underscores removed). So the same value can be supplied in whatever style suits the source. In .properties/.yml use kebab-case; in env vars use UPPER_SNAKE.

Five spellings, one canonical key, one record component
payment.max-retries(kebab: recommended in files)payment.maxRetries(camelCase)payment.max_retries(underscore)PAYMENT_MAXRETRIES(env var: dots→_, dashes dropped)--payment.max-retries=5(command line)payment.maxretriescanonical formmaxRetriesrecord componentList from env var: PAYMENT_CURRENCIES=USD,EUR or PAYMENT_CURRENCIES_0=USD PAYMENT_CURRENCIES_1=EUR
Multi-document YAML and profile sections
One file, several profile-specific documents (Boot 2.4+ syntax)
server: port: 8080 logging: level: root: INFO --- spring: config: activate: on-profile: dev logging: level: com.example.orders: DEBUG --- spring: config: activate: on-profile: prod server: port: 8443

Documents are processed top to bottom, so a later document overrides an earlier one when its on-profile condition matches. The old spring.profiles: dev key (Boot ≤ 2.3) was replaced by spring.config.activate.on-profile; the old key now fails at startup in Boot 3.

⚠ Production gotchas & how to diagnose them:
  • YAML type coercion. enabled: yes / on are booleans in YAML 1.1; version: 1.10 becomes the float 1.1; zip: 01234 loses the zero (octal-looking). Quote anything that must stay a string: zip: "01234".
  • A colon or # inside an unquoted value. password: abc#123 silently stores abc — the rest is a YAML comment. Quote it, and better, do not keep passwords in files.
  • Tabs or wrong indentation → org.yaml.snakeyaml.scanner.ScannerException wrapped in ConfigDataResourceNotFoundException / Failed to load property source from location 'classpath:/application.yml'. Yaml needs spaces only.
  • Env var with a dash in the name (PAYMENT_MAX-RETRIES) — most shells cannot even export it. Use PAYMENT_MAXRETRIES; Boot strips dashes from the property side when matching.
  • Case sensitivity in @Value with camelCase keys. @Value("${payment.maxRetries}") works for a max-retries property only because Boot's source adapters normalise; prefer the canonical kebab-case form in @Value so env-var overrides keep matching.
05
Chapter Five · Binding

@Value vs @ConfigurationProperties

There are two ways to get a property into a bean. @Value injects one value through the BeanPostProcessor AutowiredAnnotationBeanPostProcessor, resolving the placeholder and optionally a SpEL expression. @ConfigurationProperties binds a whole prefix to a typed object through the Boot Binder (ConfigurationPropertiesBindingPostProcessor for JavaBean style, ConfigurationPropertiesBinder for constructor binding). For anything beyond a one-off value, use the second.

Capability@Value@ConfigurationProperties
GranularityOne property per annotationWhole prefix → one object
Relaxed binding (env var ↔ kebab)Only if you use the canonical kebab-case keyFull
Type conversionSpring ConversionService onlyBoot converters: Duration (30s, PT30S), DataSize (10MB), List, Map, nested objects
ValidationNone@Validated + Jakarta constraints, fails at startup
SpELYes: #{systemProperties['user.home']}No
ImmutabilityField/constructor paramRecords / constructor binding
IDE metadataNonespring-boot-configuration-processor generates autocompletion + docs
Fail on unknown/typo keysNoOptional (ignoreUnknownFields=false)
Best forA single toggle or labelAny group of related settings
A complete, validated, immutable configuration record (Boot 3, Java 17)
@ConfigurationProperties(prefix = "payment") @Validated public record PaymentProperties( @NotBlank String url, // required: no default @DefaultValue("3") @Min(0) @Max(10) int maxRetries, @DefaultValue("2s") Duration timeout, @DefaultValue("false") boolean sandbox, @DefaultValue("USD") List<@NotBlank String> currencies, @Valid @DefaultValue Pool pool) { // @Valid cascades into nested object public record Pool(@DefaultValue("10") @Positive int size, @DefaultValue("30s") Duration idleTimeout) { } }
Enabling it — pick one
@SpringBootApplication @ConfigurationPropertiesScan // finds every @ConfigurationProperties class under this package public class OrdersApplication { ... } // or, explicitly, on any @Configuration: @Configuration @EnableConfigurationProperties(PaymentProperties.class) class PaymentConfig { @Bean PaymentClient paymentClient(PaymentProperties props, RestClient.Builder builder) { return new PaymentClient(builder.baseUrl(props.url()).build(), props); } }
How the Binder turns properties into a validated record (all before the first request)
PropertySourcesstack from Ch. 02Binderprefix = "payment"ConvertString → Duration, int,List, nested recordConstructnew Record(..)ValidateJakarta constraintsRegister beaninjectable everywhereBindExceptionstartup failsA bad value, a missing required key or a violated constraint stops the JVM at startup — not at 3 a.m. on the first request.
What a bad value looks like on startup
*************************** APPLICATION FAILED TO START *************************** Description: Binding to target org.springframework.boot.context.properties.bind.BindException: Failed to bind properties under 'payment' to com.example.orders.PaymentProperties failed: Property: payment.max-retries Value: "three" Origin: class path resource [application.yml] - 18:16 Reason: failed to convert java.lang.String to int (caused by java.lang.NumberFormatException: For input string: "three") Action: Update your application's configuration
...and a violated constraint
Binding to target ... PaymentProperties failed: Property: payment.url Value: "" Origin: "PAYMENT_URL" from property source "systemEnvironment" Reason: must not be blank
Where @Value is still the right tool
One toggle, one SpEL, one default
@Component class BannerLogger { BannerLogger(@Value("${app.banner.enabled:true}") boolean enabled, @Value("#{'${spring.application.name}'.toUpperCase()}") String upper) { ... } }
⚠ Production gotchas & how to diagnose them:
  • Forgetting @Validated — constraint annotations on the record are inert; a blank URL boots fine and fails later. Also add spring-boot-starter-validation (Hibernate Validator is no longer pulled in by the web starter).
  • Nested object without @Valid — constraints on Pool.size are skipped. Validation does not cascade by itself, same as in controllers.
  • Mutable JavaBean binding by accident. A class with setters binds through setters; a class with a single parameterised constructor in Boot 3 binds through the constructor. With several constructors annotate the intended one with @ConstructorBinding — otherwise NoSuchMethodException / values left at defaults.
  • Missing key vs default. A primitive record component with no @DefaultValue binds to 0/false silently. If it must be present, make it a validated wrapper (@NotNull Integer). Silent zero timeouts are a classic outage.
  • @Value on a static field — ignored, field stays null. Also, @Value in a class created with new (not a bean) is never processed.
  • Placeholder without a default. @Value("${missing}") → IllegalArgumentException: Could not resolve placeholder 'missing' in value "${missing}" — the right behaviour for required config; add :default only for truly optional values.
06
Chapter Six · Profiles

Profiles, Groups and @Profile

A profile is a named switch that selects both extra configuration files and extra beans. Two independent mechanisms use it: application-{profile}.yml files layer extra properties on top of the base file (Chapter 03's priority list), and @Profile includes or excludes bean definitions from the context. Activating prod triggers both. If no profile is active, Boot activates the profile named default.

Activating profiles
# any one of these (highest priority first) $ java -jar orders.jar --spring.profiles.active=prod,eu $ java -Dspring.profiles.active=prod -jar orders.jar $ SPRING_PROFILES_ACTIVE=prod,eu java -jar orders.jar # the container way # application.yml (a sensible default for developers) # spring.profiles.active: dev
Active profiles prod,eu: files are layered; later-listed profile wins on conflicts
application.ymlbase: always loadedapplication-prod.ymloverrides baseapplication-eu.ymloverrides base + prodEnvironmenteffective values@Profile beansprod: SesMailerdev: LoggingMailerProfile groups: spring.profiles.group.prod = proddb, prodmqactivating "prod" also activates proddb and prodmq (and their files)Order of loading = order of listing; the last profile has the highest priority.
@Profile on beans
Profile-specific beans, including expressions
public interface Mailer { void send(String to, String body); } @Component @Profile("!prod") // every profile except prod class LoggingMailer implements Mailer { public void send(String to, String body) { log.info("[fake mail] to={} body={}", to, body); } } @Component @Profile("prod") class SesMailer implements Mailer { ... } @Configuration @Profile("prod & eu") // both active class EuComplianceConfig { ... } // mixing & and | needs parentheses: "(a | b) & c" @Bean @Profile("test | dev") DataSource embeddedDb() { return new EmbeddedDatabaseBuilder().setType(H2).build(); }
Profile groups — one switch, several concerns
application.yml
spring: profiles: group: prod: proddb,prodmq,metrics local: h2,debug active: local # developers get h2+debug with no flags
GoalMechanismWhere it may be set
Choose active profilesspring.profiles.activeenv var, command line, JVM prop, base application.yml — not in a profile-specific file
Always add a profilespring.profiles.includebase config only; invalid in profile-specific docs
Expand one profile into severalspring.profiles.group.<name>base config
Add profiles in codeSpringApplication.setAdditionalProfiles("x"), @ActiveProfiles in testsmain method / test class
Profile-conditional YAML blockspring.config.activate.on-profileinside a --- document
Profile-conditional bean@Profile / Environment.acceptsProfiles(Profiles.of("prod"))any bean/config class
Profiles are for environment kind, not for every flag Use profiles to mean dev, test, prod, cloud and capability bundles (metrics). For feature toggles prefer a normal property + @ConditionalOnProperty (Chapter 08): toggles can then be changed per deployment without inventing prod-without-recommendations profiles.
⚠ Production gotchas & how to diagnose them:
  • Profile explosion (prod-eu-blue-canary) — every combination is untested code. Keep profiles few; push the rest into plain properties.
  • @Profile("!prod") as the "default" bean and no prod bean defined → NoSuchBeanDefinitionException: No qualifying bean of type 'Mailer' in prod only. Make sure every profile combination yields exactly one candidate.
  • Tests silently inheriting the environment. A CI job that exports SPRING_PROFILES_ACTIVE=prod for a later deploy step also reaches every @SpringBootTest that does not pin its profiles. Declare @ActiveProfiles("test") explicitly.
  • A secret committed in application-prod.yml because "it's only the prod profile". The jar contains it, so anyone who can download the artifact has production credentials.
  • A typo in the profile name (--spring.profiles.active=prd) is not an error: Boot starts with only the base config and a single log line The following 1 profile is active: "prd". Check that line on every deploy, or add a startup check that rejects unknown profiles.
07
Chapter Seven · Secrets & Deployment

Secrets and Per-Environment Configuration

Secrets are configuration with a blast radius. The rules are short: never in git, never in the image, never in a log line, never in an actuator response. Boot has no secrets store of its own — it only needs the value to arrive as a property from somewhere that is access-controlled and auditable.

EnvironmentHow the secret arrivesHow Spring reads itRotation
LaptopGitignored application-local.yml or .envspring.config.import=optional:file:.env[.properties]Manual
CIMasked pipeline variablesEnv varsPer run
KubernetesSecret mounted as env var or as filesSPRING_DATASOURCE_PASSWORD or configtree:/etc/secrets/Restart pods (env) / file change (mount)
AWSSecrets Manager / Parameter StoreSpring Cloud AWS spring.config.import=aws-secretsmanager:... or injected by ECS/EKSNative rotation + restart
HashiCorpVault KV / dynamic DB credsSpring Cloud Vault spring.config.import=vault://Short-lived leases
Same image everywhere: the platform assembles the Environment from ConfigMap + Secret
ConfigMapnon-secret, versioned in gitSecret / Vault / ASMaccess-controlled, auditedPod specenv:SPRING_PROFILES_ACTIVEenvFrom: configMapRefvolumes: /etc/secretsJVM Environment stack1 commandLineArgs2 systemEnvironment3 configtree:/etc/secrets/4 application.ymlNever reaches: git history, the Docker image layers, logs, /actuator/env valuesBoot 3 masks all /env and /configprops values by default (management.endpoint.env.show-values=NEVER)
Kubernetes: secret as files, read with configtree
# deployment.yaml (excerpt) env: - name: SPRING_PROFILES_ACTIVE value: prod - name: SPRING_CONFIG_IMPORT value: "optional:configtree:/etc/secrets/" volumeMounts: - name: orders-secrets mountPath: /etc/secrets readOnly: true volumes: - name: orders-secrets secret: secretName: orders-secrets # keys: spring.datasource.password, payment.api-key

With a config tree every file's name is the property key and its content is the value, so a Secret key payment.api-key becomes the property payment.api-key. No newline handling is needed — Boot trims a trailing newline. Files are also easier to rotate than env vars because a mounted Secret is updated in place (the app must re-read it; Boot does not reload on its own).

Keep the actuator from leaking
management: endpoint: env: show-values: NEVER # default in Boot 3; WHEN_AUTHORIZED / ALWAYS are opt-in configprops: show-values: NEVER endpoints: web: exposure: include: health,info,prometheus # env and configprops are NOT exposed by default

Keys whose names contain password, secret, key, token, credentials or vcap_services were sanitised in Boot 2; Boot 3 flipped to masking everything unless you opt in, because name-based guessing missed things like spring.datasource.url=jdbc:postgresql://user:pw@host.

⚠ Production gotchas & how to diagnose them:
  • Secrets in the Docker image via COPY application-prod.yml. docker history or any registry reader extracts them. Mount at runtime instead.
  • Printing the config at startup (log.info("props={}", props) on a record with apiKey) → the key lands in the log aggregator. Override toString() or mark fields ***.
  • Secret rotation without restart assumption. Rotating the DB password in Vault does nothing for a HikariCP pool holding old connections until it is recreated; plan for rolling restarts or dynamic-lease support.
  • Defaults for secrets (${DB_PASSWORD:changeme}). The default is what runs in the environment where somebody forgot to set it — usually production. For secrets, no default: let the placeholder failure stop the boot.
  • Committing .env. Add it to .gitignore on day one, and rotate anything that was ever pushed — deleting the file does not remove it from history.
08
Chapter Eight · Config Drives Wiring

@ConditionalOnProperty and Feature Flags

Configuration can decide which beans exist. @ConditionalOnProperty evaluates while the context is being built (the same mechanism auto-configuration uses — see Boot Internals), so a disabled feature has no bean, no scheduler thread, no endpoint and no cost.

A feature flag as a property
@Configuration @ConditionalOnProperty(prefix = "features.recommendations", name = "enabled", havingValue = "true") class RecommendationConfig { @Bean RecommendationService recommendationService(RecommendationProperties p) { return new RemoteRecommendationService(p); } } @Configuration @ConditionalOnProperty(prefix = "features.recommendations", name = "enabled", havingValue = "false", matchIfMissing = true) class NoRecommendationConfig { @Bean RecommendationService recommendationService() { return userId -> List.of(); } // safe no-op default }
AttributeMeaningGotcha
name / valueProperty name(s) (relative to prefix); all must matchUse kebab-case canonical names
havingValueRequired value (case-insensitive)If omitted, matches any value that is not false — so enabled=off still counts as enabled
matchIfMissingWhether an absent property matchesDefault false: with no property the condition fails
prefixCommon prefix; a dot is added for youprefix = "a.b." is an error-prone double dot
MechanismDecided whenChangeable without restart?Use for
@ConditionalOnPropertyContext refresh (startup)NoTurning whole subsystems on/off per deployment
@ProfileContext refreshNoEnvironment kinds
Plain if (props.enabled())Per callOnly with refreshable configCheap branching inside a service
Spring Cloud @RefreshScopePer refresh (/actuator/refresh or bus event)YesRarely: rebuilds the bean; in-flight state is lost
External flag service (Unleash, LaunchDarkly, ...)Per requestYes, and per userGradual rollouts, kill switches
⚠ Production gotchas & how to diagnose them:
  • Typo in the property name — the condition silently fails and the feature is absent. Verify with --debug or /actuator/conditions, which lists "Did not match: @ConditionalOnProperty (features.recomendations.enabled=true) did not find property 'enabled'".
  • Two @ConditionalOnProperty configs that can both match (no havingValue on either) → NoUniqueBeanDefinitionException. Make the pair mutually exclusive as above.
  • Expecting runtime toggling. Changing the property in a ConfigMap does nothing until the pod restarts; the bean was decided at startup.
09
Chapter Nine · Build It Yourself

A Mini Environment and Binder in Plain Java

The best test of understanding the Environment is to build one. The program below is plain Java 17 — no Spring on the classpath — and implements the four ideas from this page: an ordered stack of property sources with first-match-wins, relaxed key matching (PAYMENT_MAXRETRIES ≡ payment.max-retries), placeholder resolution with defaults and cycle detection, and constructor binding of a record with type conversion and fail-fast error aggregation.

ConfigDemo.java — a miniature Environment + Binder
import java.lang.reflect.*; import java.time.Duration; import java.util.*; public class ConfigDemo { interface PropertySource { String name(); Optional<String> get(String canonicalKey); Set<String> keys(); } /** Canonical form used by relaxed binding: lower case, '_' -> '.', dashes removed. */ static String canonical(String key) { return key.toLowerCase().replace('_', '.').replace("-", ""); } static class MapSource implements PropertySource { private final String name; private final Map<String, String> byCanonical = new LinkedHashMap<>(); MapSource(String name, Map<String, String> raw) { this.name = name; raw.forEach((k, v) -> byCanonical.put(canonical(k), v)); } public String name() { return name; } public Optional<String> get(String key) { return Optional.ofNullable(byCanonical.get(key)); } public Set<String> keys() { return byCanonical.keySet(); } } static class Environment { private final List<PropertySource> sources = new ArrayList<>(); // index 0 = highest precedence void addLast(PropertySource s) { sources.add(s); } String resolveWithOrigin(String rawKey, String[] origin) { String key = canonical(rawKey); for (PropertySource s : sources) { Optional<String> v = s.get(key); if (v.isPresent()) { origin[0] = s.name(); return resolvePlaceholders(v.get(), new ArrayDeque<>()); } } return null; } String get(String rawKey) { return resolveWithOrigin(rawKey, new String[1]); } /** Resolves ${key} and ${key:default}, detecting cycles. */ String resolvePlaceholders(String text, Deque<String> path) { StringBuilder out = new StringBuilder(); int i = 0; while (i < text.length()) { int start = text.indexOf("${", i); if (start < 0) { out.append(text, i, text.length()); break; } out.append(text, i, start); int end = text.indexOf('}', start); String expr = text.substring(start + 2, end); int colon = expr.indexOf(':'); String key = colon < 0 ? expr : expr.substring(0, colon); String def = colon < 0 ? null : expr.substring(colon + 1); if (path.contains(key)) throw new IllegalStateException("Circular placeholder reference: " + path + " -> " + key); path.push(key); String raw = null; for (PropertySource s : sources) { Optional<String> v = s.get(canonical(key)); if (v.isPresent()) { raw = v.get(); break; } } String value = raw != null ? resolvePlaceholders(raw, path) : def; path.pop(); if (value == null) throw new IllegalArgumentException("Could not resolve placeholder '" + key + "' in value \"" + text + "\""); out.append(value); i = end + 1; } return out.toString(); } /** Constructor binding of a record, like @ConfigurationProperties on a record. */ <T extends Record> T bind(String prefix, Class<T> type) throws Exception { RecordComponent[] comps = type.getRecordComponents(); Object[] args = new Object[comps.length]; Class<?>[] types = new Class<?>[comps.length]; List<String> errors = new ArrayList<>(); for (int i = 0; i < comps.length; i++) { types[i] = comps[i].getType(); String key = prefix + "." + comps[i].getName(); String raw = get(key); if (raw == null) { if (types[i].isPrimitive() || types[i] == String.class) errors.add("Property '" + canonical(key) + "' is required"); continue; } try { args[i] = convert(raw, types[i]); } catch (RuntimeException e) { errors.add("Property '" + canonical(key) + "' value '" + raw + "' cannot be converted to " + types[i].getSimpleName()); } } if (!errors.isEmpty()) throw new IllegalStateException("Binding to target " + type.getSimpleName() + " failed:\n - " + String.join("\n - ", errors)); Constructor<T> ctor = type.getDeclaredConstructor(types); return ctor.newInstance(args); } static Object convert(String raw, Class<?> t) { if (t == String.class) return raw; if (t == int.class) return Integer.parseInt(raw.trim()); if (t == boolean.class) return Boolean.parseBoolean(raw.trim()); if (t == Duration.class) return parseDuration(raw.trim()); if (t == List.class) return Arrays.stream(raw.split(",")).map(String::trim).toList(); throw new IllegalArgumentException("no converter for " + t); } static Duration parseDuration(String s) { // 500ms, 30s, 5m (Spring's simple style) if (s.endsWith("ms")) return Duration.ofMillis(Long.parseLong(s.substring(0, s.length() - 2))); if (s.endsWith("s")) return Duration.ofSeconds(Long.parseLong(s.substring(0, s.length() - 1))); if (s.endsWith("m")) return Duration.ofMinutes(Long.parseLong(s.substring(0, s.length() - 1))); return Duration.ofMillis(Long.parseLong(s)); } } record PaymentProps(String url, int maxRetries, Duration timeout, boolean sandbox, List<String> currencies) {} public static void main(String[] args) throws Exception { Map<String, String> yaml = new LinkedHashMap<>(); yaml.put("spring.profiles.active", "dev"); yaml.put("server.port", "8080"); yaml.put("app.name", "orders"); yaml.put("payment.url", "https://${payment.host:localhost}/v1"); yaml.put("payment.max-retries", "3"); yaml.put("payment.timeout", "2s"); yaml.put("payment.sandbox", "true"); yaml.put("payment.currencies", "USD, EUR"); yaml.put("log.file", "${app.name}-${server.port}.log"); Map<String, String> prodYaml = Map.of("payment.host", "pay.example.com", "payment.sandbox", "false", "server.port", "9000"); Map<String, String> env = Map.of("PAYMENT_MAXRETRIES", "5", "SPRING_PROFILES_ACTIVE", "prod"); Map<String, String> cli = Map.of("server.port", "9090"); // 1) profile selection: command line > env > application.yml Environment bootstrap = new Environment(); bootstrap.addLast(new MapSource("commandLineArgs", cli)); bootstrap.addLast(new MapSource("systemEnvironment", env)); bootstrap.addLast(new MapSource("application.yml", yaml)); String profile = bootstrap.get("spring.profiles.active"); System.out.println("active profile : " + profile); // 2) final environment: profile file sits ABOVE the base file Environment envr = new Environment(); envr.addLast(new MapSource("commandLineArgs", cli)); envr.addLast(new MapSource("systemEnvironment", env)); envr.addLast(new MapSource("application-" + profile + ".yml", prodYaml)); envr.addLast(new MapSource("application.yml", yaml)); System.out.println("\n-- who wins? --"); for (String k : List.of("server.port", "payment.max-retries", "payment.sandbox", "payment.url", "app.name", "log.file")) { String[] origin = new String[1]; String v = envr.resolveWithOrigin(k, origin); System.out.printf("%-20s = %-34s <- %s%n", k, v, origin[0]); } System.out.println("\n-- typed binding --"); PaymentProps p = envr.bind("payment", PaymentProps.class); System.out.println(p); System.out.println("\n-- fail fast on bad config --"); Map<String, String> broken = new LinkedHashMap<>(yaml); broken.remove("payment.url"); broken.put("payment.max-retries", "three"); Environment bad = new Environment(); bad.addLast(new MapSource("application.yml", broken)); try { bad.bind("payment", PaymentProps.class); } catch (IllegalStateException e) { System.out.println(e.getMessage()); } System.out.println("\n-- placeholder cycle --"); Environment cyc = new Environment(); cyc.addLast(new MapSource("application.yml", Map.of("a", "${b}", "b", "${a}"))); try { cyc.get("a"); } catch (IllegalStateException e) { System.out.println(e.getMessage()); } } }
$ javac ConfigDemo.java && java ConfigDemo (real output)
active profile : prod -- who wins? -- server.port = 9090 <- commandLineArgs payment.max-retries = 5 <- systemEnvironment payment.sandbox = false <- application-prod.yml payment.url = https://pay.example.com/v1 <- application.yml app.name = orders <- application.yml log.file = orders-9090.log <- application.yml -- typed binding -- PaymentProps[url=https://pay.example.com/v1, maxRetries=5, timeout=PT2S, sandbox=false, currencies=[USD, EUR]] -- fail fast on bad config -- Binding to target PaymentProps failed: - Property 'payment.url' is required - Property 'payment.maxretries' value 'three' cannot be converted to int -- placeholder cycle -- Circular placeholder reference: [a, b] -> b
Output lineWhat it proves
server.port = 9090 <- commandLineArgsThe command line beat env, application-prod.yml (9000) and the base file (8080): first match down the stack wins.
payment.max-retries = 5 <- systemEnvironmentRelaxed matching: env var PAYMENT_MAXRETRIES overrode the file key payment.max-retries after both were canonicalised.
payment.sandbox = false <- application-prod.ymlThe profile file sits above the base file, so prod flips true to false.
payment.url = https://pay.example.com/v1The placeholder ${payment.host} is resolved against the whole stack at read time, so a value from the prod file fills a slot defined in the base file.
log.file = orders-9090.logA placeholder sees the final winner of server.port (9090), not the base-file value.
Binding failure lists both errors togetherAggregating errors (as Boot does) lets you fix all problems in one redeploy instead of one per restart.
What the real thing adds
  • Origins: each property remembers its file, line and column (the Origin: line in error messages).
  • A richer ConversionService (Duration, DataSize, enums, nested beans, Map/List indices).
  • Lazy, per-source ConfigurationPropertyName adapters instead of re-canonicalising at load time.
  • Config data lifecycle: imports, optional:, profile expansion, activation conditions.
10
Chapter Ten · Debugging

Diagnosing "Why Is This Value Wrong?"

When a value is "wrong", resist the urge to grep the repository. The question is always which source won, and the application can tell you.

SymptomFirst checkTypical root cause
Service uses the wrong DB / URLGET /actuator/env/spring.datasource.url → propertySources array lists every source defining the key, top = winnerAn env var or leftover ./config/application.yml shadows the file you edited
Feature is off in one environment--debug or /actuator/conditions → "Did not match" sectionTypo in a @ConditionalOnProperty key, or the profile that sets it is not active
Which profiles are active?Startup log: The following 2 profiles are active: "prod", "eu"; /actuator/env → activeProfilesEnv var not delivered into the container
A @ConfigurationProperties field is null/0/actuator/configprops shows the bound objectWrong prefix, or @EnableConfigurationProperties / scanning missing (a kebab/camel mismatch is not the usual cause: relaxed binding covers it)
Placeholder explodesException text names the key: Could not resolve placeholder 'x' in value "${x}"Key defined only in a profile that is not active; no :default
Config file seems ignoredRun with --debug and set logging.level.org.springframework.boot.context.config=TRACEFile in the wrong location, wrong name, or not in the built jar (check with unzip -l)
Targeted debugging
$ curl -s localhost:8080/actuator/env/payment.max-retries | jq { "property": { "source": "systemEnvironment", "value": "******" }, "activeProfiles": ["prod"], "propertySources": [ { "name": "systemEnvironment", "property": { "value": "******", "origin": "System Environment Property PAYMENT_MAXRETRIES" } }, { "name": "Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'", "property": { "value": "******", "origin": "class path resource [application.yml] - 12:16" } } ] } $ java -jar orders.jar --debug 2>&1 | grep -A3 'CONDITIONS EVALUATION'

Expose env and configprops only on the management port or behind authentication. Even with values masked as ****** you still get the source name and origin, which is usually all you need to find the culprit.

11
Chapter Eleven · Interview Angles

Questions You Will Be Asked

Short answers first; the trap is the follow-up an interviewer uses to test whether you have operated this, not just read about it.

Q1
What is the precedence between command line, env vars and application.yml?

Command-line args > JVM system props > OS env vars > profile-specific files > base files > @PropertySource > defaults. Per key, first match wins; nothing merges. Trap: lists are replaced entirely, maps merge by key.

Q2
@Value or @ConfigurationProperties?

@ConfigurationProperties for groups: relaxed binding, rich conversion, validation, immutability via records, IDE metadata. @Value for a single value or SpEL. Trap: prefer canonical kebab-case keys in @Value so env-var overrides still match.

Q3
How does PAYMENT_MAXRETRIES map to payment.max-retries?

Both are canonicalised: lower-case, underscores to dots (env only), dashes removed. The Binder compares canonical forms. Trap: an env var cannot contain a dash, so the dash is dropped, not converted.

Q4
How do you make a missing required property fail at startup?

Constraint annotations + @Validated on the properties class (and @Valid on nested types), or a placeholder with no default. Trap: without spring-boot-starter-validation on the classpath the annotations do nothing.

Q5
Difference between spring.profiles.active, include and group?

active chooses the set; include adds always-on profiles; group expands one name into several. Trap: active and include are invalid inside a profile-specific file.

Q6
Two documents in one YAML file: how are they chosen?

spring.config.activate.on-profile per --- document; later documents override earlier. Trap: the old spring.profiles key no longer works in Boot 3.

Q7
Where do you keep secrets on Kubernetes?

Kubernetes Secret (or an external manager) mounted as env var or files + configtree:; never in the image or repo. Trap: defaults like ${DB_PASSWORD:changeme} mask a missing secret.

Q8
Can you change configuration without restarting?

Not by default: Boot reads config once. Options: Spring Cloud @RefreshScope + /actuator/refresh, an external flag service, or re-reading files yourself. Trap: @RefreshScope rebuilds the bean and loses in-flight state.

Q9
How do you find out which source supplied a value?

/actuator/env/<key> lists sources in priority order with origins; /actuator/configprops shows bound objects. Trap: Boot 3 masks values, but source and origin remain.

Q10
What does matchIfMissing do?

Makes @ConditionalOnProperty match when the property is absent. Trap: without havingValue, anything except literal false matches.

12
Chapter Twelve · Review

Summary & Cheat Sheet

NeedUseRemember
Override one value for a runCommand-line --k=vHighest of the normal sources
Containers / KubernetesEnv vars UPPER_SNAKEDashes dropped; dots to underscores
Defaults shipped in the buildapplication.yml in the jarLowest real file; keep secrets out
Per-environment valuesapplication-<profile>.yml / multi-docProfile file overrides base per key
A group of related settings@ConfigurationProperties record + @ValidatedFail fast at startup
A single value / SpEL@ValueUse the canonical kebab key
SecretsK8s Secret / Vault / ASM + configtree:No defaults, no logs, masked actuator
Turn a subsystem on/off@ConditionalOnPropertyDecided at startup only
Why is the value wrong?/actuator/env/<key>, --debugFirst match wins; check origin
∑ Configuration & Profiles — what to remember
  • The Environment is a priority stack of PropertySources; lookup is per key, first match wins, lists are replaced not merged.
  • Closer to the process wins: command line > env vars > profile files > base files > defaults.
  • Relaxed binding: write kebab-case in files, UPPER_SNAKE in env vars; both map to the same canonical key.
  • @ConfigurationProperties + records + @Validated makes bad config a startup failure naming the exact property, value and origin.
  • Profiles select files and beans; keep them few; activate with an env var; groups bundle concerns.
  • Secrets arrive from the platform, never from the repo, image or logs; actuator masks values by default in Boot 3.
  • Config can wire beans (@ConditionalOnProperty), but only at startup.
Related pages
  • Boot Internals — the @Conditional machinery behind @ConditionalOnProperty, and how --debug prints it.
  • Actuator & Observability — /env, /configprops, /loggers and how to expose them safely.
  • Testing — @TestPropertySource, @ActiveProfiles, @DynamicPropertySource.
  • Security — protecting management endpoints and keeping secrets out of responses.
  • JWT, OAuth2 & OIDC — issuer URIs and client secrets as configuration.