1
0

Three new article modules: configuration binding, profiles and config data, Spring AOP

configuration-properties/  @ConfigurationProperties vs @Value on Spring Boot 4.1.1.
  The relaxed-binding matrix is generated by binding each spelling rather than
  transcribed, and re-checked against real processes -- the in-process probe was
  wrong twice before it was right. Records the three findings that came out of it:
  @Value does get relaxed resolution inside Spring Boot (Boot attaches
  ConfigurationPropertySources), the configuration processor silently stops
  generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is
  not what makes nested constraints run.

profiles-and-config/       Precedence, profiles, spring.config.import and config trees.
  /precedence reports every source holding a property in rank order with file and
  line, which turns "my profile file had no effect" into a two-line answer. Also
  pins the counterintuitive one: an imported file outranks the file that imported it.

spring-aop/                Designators, proxy types, and aspects that do not fire.
  One advice per supported designator so the reference table is generated from real
  matches; all fourteen unsupported designators fed to the parser. Two corrections to
  the reference documentation: unsupported designators throw
  UnsupportedPointcutPrimitiveException (extends RuntimeException, not
  IllegalArgumentException), and spring-boot-starter-aop was renamed to
  spring-boot-starter-aspectj in Boot 4.

19 contract tests across the three modules, 15 captured transcripts, all regenerated
by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9,
JDK 25.0.4.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
2026-09-08 16:36:17 +00:00
parent 958b401f0f
commit 86246dc860
107 changed files with 5075 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# Spring Boot profiles, config import and config trees
Companion project for [**Spring Boot Profiles Done Right**](https://ankurm.com/) on ankurm.com.
The question the project answers: you set a value in `application-prod.yaml`, deployed with
`prod` active, and the old value is still in effect. Why?
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/run-all.sh # regenerate every transcript in docs/output/
mvn test # 6 contract tests
```
## Profiles
| Profile | What it demonstrates |
|---|---|
| `prod` | a profile group expanding to `prod`, `prod-db`, `prod-metrics` |
| `import` | `spring.config.import`, and the imported file winning |
| `multidoc` | one file, three documents, activated by condition |
| `badactivation` | `spring.profiles.active` set from a profile-specific document — refused |
| `staging` | the conditional document inside `application-multidoc.yaml` |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /precedence?name=` | every source holding a property, ranked, with file and line |
| `GET /sources` | the live property-source stack in precedence order |
Diagnostics. Delete before shipping, or use Actuator's `/actuator/env`, which sanitises.
## Documentation
1. [The precedence list](docs/01-the-precedence-list.md)
2. [Profiles: files, documents and groups](docs/02-profiles.md)
3. [Seeing precedence instead of reasoning about it](docs/03-seeing-precedence.md)
4. [Why your profile-specific file lost](docs/04-why-your-profile-file-lost.md)
5. [`spring.config.import`](docs/05-config-import.md)
6. [Config trees and Kubernetes ConfigMaps](docs/06-config-trees-and-configmaps.md)
## Captured output
| File | Produced by |
|---|---|
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
| [`01-precedence.txt`](docs/output/01-precedence.txt) | `scripts/demo-precedence.sh` |
| [`02-profile-file-loses.txt`](docs/output/02-profile-file-loses.txt) | `scripts/demo-profile-file-loses.sh` |
| [`03-config-tree.txt`](docs/output/03-config-tree.txt) | `scripts/demo-config-tree.sh` |
| [`04-import-and-multidoc.txt`](docs/output/04-import-and-multidoc.txt) | `scripts/demo-import-and-multidoc.sh` |
## The short answer
Config data — every file you write, profile-specific ones included — is item 3 in Spring Boot's
documented precedence list. OS environment variables are item 5. Later items win. A
profile-specific file beats other files and loses to the weakest environment variable on the
host.
## The one that surprises people
`spring.config.import` does not behave like `#include`. The **imported** file is processed after
the file that declared the import, so it **wins**. Import a shared baseline expecting to
override it and every key it sets will quietly beat yours.

View File

@@ -0,0 +1,56 @@
[Index](../README.md) &middot; [Profiles &rarr;](02-profiles.md)
# 1. The precedence list
Spring Boot's documented order, lowest precedence first. Later entries win.
| # | Source |
|---|---|
| 1 | Default properties (`SpringApplication.setDefaultProperties`) |
| 2 | `@PropertySource` on `@Configuration` classes |
| 3 | **Config data**`application.properties`, `application.yaml`, profile-specific files, `spring.config.import` |
| 4 | `RandomValuePropertySource` (`random.*`) |
| 5 | **OS environment variables** |
| 6 | Java system properties (`-D`) |
| 7 | JNDI attributes from `java:comp/env` |
| 8 | `ServletContext` init parameters |
| 9 | `ServletConfig` init parameters |
| 10 | `SPRING_APPLICATION_JSON` |
| 11 | Command-line arguments |
| 12 | `properties` on `@SpringBootTest` |
| 13 | `@DynamicPropertySource` |
| 14 | `@TestPropertySource` |
| 15 | Devtools global settings |
## The two rows that matter
**Item 3 covers every file you write.** `application.yaml`, `application-prod.yaml`, an
imported config tree, a mounted ConfigMap — all of it is config data, all of it at rank 3.
**Item 5 is above it.** Every environment variable outranks every file.
Profile-specific files beat non-profile files, and later imports beat earlier ones, but those
are orderings *within* item 3. Nothing inside item 3 can reach item 5.
That single fact explains the bug this project exists for, and
[chapter 4](04-why-your-profile-file-lost.md) walks through it with a transcript.
## Seeing it for real
`/sources` prints the live stack, which is more useful than the table because it shows exactly
which files were loaded:
```
3. SimpleCommandLinePropertySource commandLineArgs
6. PropertiesPropertySource systemProperties
7. OriginAwareSystemEnvironmentPropertySource systemEnvironment
9. OriginTrackedMapPropertySource application-prod-metrics.yaml
10. OriginTrackedMapPropertySource application-prod-db.yaml
11. OriginTrackedMapPropertySource application-prod.yaml
12. OriginTrackedMapPropertySource application.yaml
```
Note rank 2 in the real stack, which the table does not mention:
`ConfigurationPropertySourcesPropertySource`, named `configurationProperties`. That is the
source Spring Boot attaches to give `${...}` placeholders the binder's relaxed name matching —
see the [binding project's chapter 2](../../configuration-properties/docs/02-relaxed-binding.md).

View File

@@ -0,0 +1,70 @@
[&larr; Precedence list](01-the-precedence-list.md) &middot; [Index](../README.md) &middot; [Seeing precedence &rarr;](03-seeing-precedence.md)
# 2. Profiles: files, documents and groups
## Profile-specific files
`application-<profile>.yaml`, loaded from the same locations as `application.yaml`, and always
overriding it. With several profiles active, last one wins:
`--spring.profiles.active=prod,live` means `application-live.yaml` beats
`application-prod.yaml`.
## Multi-document files
The same effect without multiplying files. Documents are separated by `---` and activated by
condition:
```yaml
demo:
greeting: from-multidoc-default-document
---
spring:
config:
activate:
on-profile: staging
demo:
greeting: from-multidoc-staging-document
```
Later documents win over earlier ones, so an unconditional first document acts as the default
and each conditional document overrides it. Measured in
[`04-import-and-multidoc.txt`](output/04-import-and-multidoc.txt).
`spring.config.activate.on-cloud-platform` and `spring.config.activate.on-profile` can be
combined; both must match.
## Profile groups
One profile that activates several:
```yaml
spring:
profiles:
group:
prod: prod-db,prod-metrics
```
`--spring.profiles.active=prod` reports all three as active, and all three
`application-<name>.yaml` files are loaded. Groups are resolved before config data is
processed, which is why declaring a group in `application.yaml` can still affect which files
get loaded.
## The activation Spring Boot refuses
`spring.profiles.active` cannot be set from a document that is itself profile-specific:
```
InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from location
'class path resource [application-badactivation.yaml]' is invalid in a profile specific
resource [origin: ... - 12:13]
```
A profile that activates itself would change which files are loaded after the set of files had
already been decided. Boot refuses rather than half-applying it. `spring.profiles.include` has
the same restriction; `spring.config.activate.on-profile` is how you express the condition.
## `@Profile` is a different mechanism
`@Profile("prod")` on a bean is evaluated when the context is built, long after config data is
resolved. It decides which *beans* exist, not which *properties* are set. The two use the same
profile names and nothing else.

View File

@@ -0,0 +1,59 @@
[&larr; Profiles](02-profiles.md) &middot; [Index](../README.md) &middot; [Why your profile file lost &rarr;](04-why-your-profile-file-lost.md)
# 3. Seeing precedence instead of reasoning about it
Endpoint: [`PrecedenceEndpoint`](../src/main/java/com/ankurm/profiles/web/PrecedenceEndpoint.java).
Transcript: [`01-precedence.txt`](output/01-precedence.txt).
Set `demo.greeting` from five places at once and ask which won:
```
$ DEMO_GREETING=from-environment-variable \
java -Ddemo.greeting=from-system-property \
-jar profiles-and-config-1.0.0.jar --spring.profiles.active=prod \
--demo.greeting=from-command-line-argument
```
```
"effectiveValue": "from-command-line-argument",
"activeProfiles": ["prod", "prod-db", "prod-metrics"],
"holders": [
{ "rank": 1, "value": "from-command-line-argument", "source": "commandLineArgs" },
{ "rank": 2, "value": "from-system-property", "source": "systemProperties" },
{ "rank": 3, "value": "from-environment-variable", "source": "systemEnvironment" },
{ "rank": 4, "value": "from-application-prod-yaml", "origin": "application-prod.yaml - 4:13" },
{ "rank": 5, "value": "from-application-yaml", "origin": "application.yaml - 21:13" }
],
"shadowedCount": 4
```
Five sources hold the property. Four of them lose. Each one that came from a file names its
line.
## The whole implementation
```java
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
ConfigurationProperty property =
source.getConfigurationProperty(ConfigurationPropertyName.of(name));
if (property != null) {
// rank = position, property.getValue(), property.getOrigin()
}
}
```
`ConfigurationPropertySources.get(...)` returns the sources in precedence order. Iterate,
collect every hit, and the first is the winner. That is the entire diagnostic.
## Why this beats reading the list
The documented order is correct but abstract. It does not tell you that a `DEMO_GREETING` left
over from a shell three weeks ago is sitting at rank 3, and that is the actual question.
## Alternatives if you would rather not add an endpoint
- Actuator's `/actuator/env` gives the same information with sanitisation, and
`/actuator/env/{name}` narrows to one property. Prefer it in anything real.
- `logging.level.org.springframework.boot.context.config=TRACE` logs which config data
resources were loaded and in what order.
- `--debug` does *not* show this. It prints the auto-configuration report.

View File

@@ -0,0 +1,73 @@
[&larr; Seeing precedence](03-seeing-precedence.md) &middot; [Index](../README.md) &middot; [Config import &rarr;](05-config-import.md)
# 4. Why your profile-specific file lost
Transcript: [`02-profile-file-loses.txt`](output/02-profile-file-loses.txt).
The bug: you set a value in `application-prod.yaml`, deploy with `prod` active, and the old
value is still in effect.
## Two runs, one difference
```
--- 1. prod profile active, no environment variable ---
effective value : jdbc:postgresql://prod-db:5432/orders
1. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml
2. jdbc:h2:mem:default <- application.yaml
```
Working as intended. Now with one leftover variable in the environment:
```
--- 2. identical, plus one leftover environment variable ---
effective value : jdbc:postgresql://leftover:5432/orders
1. jdbc:postgresql://leftover:5432/orders <- systemEnvironment
2. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml
3. jdbc:h2:mem:default <- application.yaml
```
The profile file was still loaded. It still holds the right value. It is at rank 2.
## Why it feels wrong
Profile-specific files *do* override — the mental model is not wrong, it is incomplete. They
override other config data. Config data as a whole sits at item 3 in the precedence list and
environment variables at item 5, so the strongest file loses to the weakest variable.
## Where the leftover variables come from
Every one of these is real:
- A Kubernetes `Deployment` with an `env:` block that predates the ConfigMap and was never
removed. `envFrom` a `ConfigMap` produces environment variables, not config data.
- A `docker-compose.yml` `environment:` entry copied from a colleague.
- Spring Cloud Kubernetes or a service mesh injecting `SPRING_DATASOURCE_URL`.
- A CI runner exporting variables for a different service.
- `SPRING_APPLICATION_JSON`, which is item 10 and beats almost everything.
## Diagnosing it in one step
If a property is not what the file says, look for a variable:
```bash
kubectl exec deploy/my-app -- env | grep -i datasource
```
or ask the running application, which reports every holder including the one you did not know
about.
## Living with it
**Prefer environment variables in containers, files for defaults.** The precedence order is
designed for exactly this: the image carries defaults, the deployment overrides them. Fighting
it means fighting the design.
**Do not set the same key in both places.** If a value is per-environment, keep it out of the
profile files entirely so there is only ever one source.
**Name environment variables specifically.** `DEMO_DATASOURCE_URL` collides with nothing;
`SPRING_DATASOURCE_URL` collides with every Spring application on the host.
**Mount configuration as a config tree instead.** Still config data, still below environment
variables, but at least it is one mechanism rather than two —
[chapter 6](06-config-trees-and-configmaps.md).

View File

@@ -0,0 +1,58 @@
[&larr; Why your profile file lost](04-why-your-profile-file-lost.md) &middot; [Index](../README.md) &middot; [Config trees &rarr;](06-config-trees-and-configmaps.md)
# 5. `spring.config.import`
Transcript: [`04-import-and-multidoc.txt`](output/04-import-and-multidoc.txt).
```yaml
spring:
config:
import: "optional:classpath:/imported.yaml"
```
## The imported file wins
This is the part that catches people, and it catches them in the direction opposite to the one
they brace for:
```
effective value : from-imported-yaml
1. from-imported-yaml <- imported.yaml
2. from-application-import-yaml <- application-import.yaml (declared the import)
3. from-application-yaml <- application.yaml
```
The importing file declared the import and then lost to it. An imported document is processed
*after* the document that declared it, and later documents win.
`#include` semantics would give the opposite. So would treating the import as a set of
defaults, which is what people usually intend when they import a shared baseline. If you import
a company-wide `common.yaml` expecting your own file to override it, every key `common.yaml`
sets will quietly beat yours.
To get defaults-style behaviour, put your overrides somewhere that outranks config data — an
environment variable or a command-line argument — or import from a *later* document in your own
file so the ordering is explicit.
## Prefixes
| Prefix | Meaning |
|---|---|
| `optional:` | do not fail if it is missing |
| `file:` | a filesystem path |
| `classpath:` | a classpath resource |
| `configtree:` | a directory of value-per-file entries |
They compose: `optional:configtree:/etc/config/`.
Without `optional:`, a missing location is `ConfigDataLocationNotFoundException` at startup.
That is usually what you want for a secret mount and never what you want for a developer
machine.
## Where imports are legal
`spring.config.import` is only honoured in config data — `application.yaml` and friends. Setting
it as an environment variable or a command-line argument works too, because those are processed
before config data is loaded. Setting it anywhere else does nothing.
Imports are processed depth-first, and a cycle is detected and reported rather than looping.

View File

@@ -0,0 +1,85 @@
[&larr; Config import](05-config-import.md) &middot; [Index](../README.md)
# 6. Config trees and Kubernetes ConfigMaps
Transcript: [`03-config-tree.txt`](output/03-config-tree.txt).
## What Kubernetes actually mounts
A ConfigMap mounted as a volume is not a properties file. Kubernetes writes **one file per
key**, named after the key, containing only the value with no trailing newline:
```
<configmap-mount>/demo.datasource-url
<configmap-mount>/demo.greeting
<configmap-mount>/demo.pool-size
<configmap-mount>/demo/nested/value
<secret-mount>/demo.api-key
```
```
$ cat <configmap-mount>/demo.greeting
from-configmap-volume
```
There is no syntax to parse. The filename is the key.
## Reading it
```
--spring.config.import=configtree:/etc/config/,configtree:/etc/secrets/
```
A trailing `/` is required — the location is a directory. Values arrive as properties:
```
demo.pool-size = 25
demo.nested.value = from-nested-directory
demo.api-key = sk_live_not_a_real_key
```
A directory under the mount becomes a nested property, so `demo/nested/value` is
`demo.nested.value`. That is how a ConfigMap whose keys contain slashes arrives.
Secrets mount identically. The only difference is file permissions, which is why the same
mechanism reads both and why nothing in your application needs to know which it got.
## Why this beats mounting a properties file
- **Per-key updates.** Changing one key rewrites one file. Kubernetes propagates it to the
volume without a restart, and `spring.config.import` supports `configtree` reloading through
Spring Cloud Kubernetes if you want to act on it.
- **No parse step**, so no chance of one malformed line taking out the whole file.
- **Secrets and config read the same way.**
- **Values can contain anything.** No escaping, no quoting, no YAML surprises — a value of
`yes` stays the string `yes`.
## Wildcards
```
--spring.config.import=optional:configtree:/etc/config/*/
```
Reads every immediate subdirectory, which is the shape you get when several ConfigMaps are
mounted under one parent. Useful for "one ConfigMap per component" layouts.
## It is still config data
An imported config tree outranks `application.yaml` — and still loses to an environment
variable:
```
effective value : from-environment-variable
1. from-environment-variable <- systemEnvironment
2. from-configmap-volume <- ConfigTreePropertySource
3. from-application-yaml <- application.yaml
```
If you mount a ConfigMap *and* set `envFrom` on the same Deployment — which is a common way to
migrate from one to the other — the environment variables win and the ConfigMap looks broken.
## There is no profile-specific config tree
No `<mount>-prod` convention exists. Per-environment configuration is a different ConfigMap
chosen by the Deployment, not by `spring.profiles.active`. This is a feature: the environment
is decided by what you deploy, not by a string inside the image.

View File

@@ -0,0 +1,83 @@
== every source sets demo.greeting at once ==
$ DEMO_GREETING=from-environment-variable \
java -Ddemo.greeting=from-system-property \
-jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod \
--demo.greeting=from-command-line-argument
{
"property": "demo.greeting",
"effectiveValue": "from-command-line-argument",
"activeProfiles": [
"prod",
"prod-db",
"prod-metrics"
],
"holders": [
{
"rank": 1,
"source": "SimpleCommandLinePropertySource {name='commandLineArgs'}",
"value": "from-command-line-argument",
"origin": "\"demo.greeting\" from property source \"commandLineArgs\""
},
{
"rank": 2,
"source": "PropertiesPropertySource {name='systemProperties'}",
"value": "from-system-property",
"origin": "\"demo.greeting\" from property source \"systemProperties\""
},
{
"rank": 3,
"source": "OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}",
"value": "from-environment-variable",
"origin": "System Environment Property \"DEMO_GREETING\""
},
{
"rank": 4,
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/''}",
"value": "from-application-prod-yaml",
"origin": "class path resource [application-prod.yaml] from profiles-and-config-1.0.0.jar - 4:13"
},
{
"rank": 5,
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}",
"value": "from-application-yaml",
"origin": "class path resource [application.yaml] from profiles-and-config-1.0.0.jar - 21:13"
}
],
"shadowedCount": 4
}
== and with the environment variable removed, nothing else changed ==
./scripts/demo-precedence.sh: line 41: 5735 Killed DEMO_GREETING=from-environment-variable setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" --spring.profiles.active=prod --demo.greeting=from-command-line-argument > /tmp/profiles-precedence.log 2>&1 < /dev/null
{
"property": "demo.greeting",
"effectiveValue": "from-system-property",
"activeProfiles": [
"prod",
"prod-db",
"prod-metrics"
],
"holders": [
{
"rank": 1,
"source": "PropertiesPropertySource {name='systemProperties'}",
"value": "from-system-property",
"origin": "\"demo.greeting\" from property source \"systemProperties\""
},
{
"rank": 2,
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/''}",
"value": "from-application-prod-yaml",
"origin": "class path resource [application-prod.yaml] from profiles-and-config-1.0.0.jar - 4:13"
},
{
"rank": 3,
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}",
"value": "from-application-yaml",
"origin": "class path resource [application.yaml] from profiles-and-config-1.0.0.jar - 21:13"
}
],
"shadowedCount": 2
}
./scripts/demo-precedence.sh: line 41: 5796 Killed setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" --spring.profiles.active=prod > /tmp/profiles-precedence2.log 2>&1 < /dev/null

View File

@@ -0,0 +1,41 @@
== does application-prod.yaml win? ==
demo.datasource-url is set in application.yaml and again in application-prod.yaml.
--- 1. prod profile active, no environment variable ---
$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod
active profiles : prod, prod-db, prod-metrics
effective value : jdbc:postgresql://prod-db:5432/orders
1. jdbc:postgresql://prod-db:5432/orders <- 'file application-prod.yaml' via location 'optional:classpath:/''}
2. jdbc:h2:mem:default <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 1
--- 2. identical, plus one leftover environment variable ---
$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \
java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod
active profiles : prod, prod-db, prod-metrics
effective value : jdbc:postgresql://leftover:5432/orders
1. jdbc:postgresql://leftover:5432/orders <- OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}
2. jdbc:postgresql://prod-db:5432/orders <- 'file application-prod.yaml' via location 'optional:classpath:/''}
3. jdbc:h2:mem:default <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 2
The profile-specific file is still loaded and still holds its value -- it is listed,
and it lost. Config data is item 3 in the documented precedence list; OS environment
variables are item 5, and later items win.
== the full property-source stack, in order ==
$ curl -s localhost:8080/sources
1. MapPropertySource server.ports
2. ConfigurationPropertySourcesPropertySource configurationProperties
3. SimpleCommandLinePropertySource commandLineArgs
4. StubPropertySource servletConfigInitParams
5. ServletContextPropertySource servletContextInitParams
6. PropertiesPropertySource systemProperties
7. OriginAwareSystemEnvironmentPropertySource systemEnvironment
8. RandomValuePropertySource random
9. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod-metrics.yaml]' via location 'optional:classpath:/'
10. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod-db.yaml]' via location 'optional:classpath:/'
11. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/'
12. OriginTrackedMapPropertySource Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/'
13. ApplicationInfoPropertySource applicationInfo

View File

@@ -0,0 +1,45 @@
== what Kubernetes actually mounts ==
$ find /tmp/demo-configmap /tmp/demo-secret -type f | sort
<configmap-mount>/demo.datasource-url
<configmap-mount>/demo.greeting
<configmap-mount>/demo.pool-size
<configmap-mount>/demo/nested/value
<secret-mount>/demo.api-key
$ cat <configmap-mount>/demo.greeting; echo
from-configmap-volume
Each file holds a bare value with no trailing newline and no key. There is no
properties syntax to parse -- the filename is the key.
== importing it ==
$ java -jar target/profiles-and-config-1.0.0.jar \
--spring.config.import=configtree:/tmp/demo-configmap/,configtree:/tmp/demo-secret/
active profiles : (none)
effective value : from-configmap-volume
1. from-configmap-volume <- ConfigTreePropertySource {name='Config tree '/tmp/demo-configmap''}
2. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 1
demo.pool-size = 25
demo.nested.value = from-nested-directory
demo.api-key = sk_live_not_a_real_key
A directory under the mount becomes a nested property: demo/nested/value is
demo.nested.value. That is how a ConfigMap with slashes in its keys arrives.
== the part that surprises people ==
An imported config tree outranks application.yaml, but it is still config data,
so it still loses to an environment variable:
active profiles : (none)
effective value : from-environment-variable
1. from-environment-variable <- OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}
2. from-configmap-volume <- ConfigTreePropertySource {name='Config tree '/tmp/demo-configmap''}
3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 2
There is also no such thing as a profile-specific config tree. There is no
<mount>-prod directory convention; a per-environment ConfigMap is a different mount
chosen by the deployment, not by spring.profiles.active.

View File

@@ -0,0 +1,60 @@
== spring.config.import: which document wins? ==
application-import.yaml imports imported.yaml. Both set demo.greeting.
$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=import
active profiles : import
effective value : from-imported-yaml
1. from-imported-yaml <- 'file imported.yaml' via location 'optional:classpath:/imported.yaml''}
2. from-application-import-yaml <- 'file application-import.yaml' via location 'optional:classpath:/''}
3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 2
demo.imported-only = yes-this-file-was-read
The imported file WON. spring.config.import does not behave like #include, and it
does not behave like a default either: the imported document is processed AFTER the
document that declared the import, so it outranks the file that pulled it in.
If you import a shared baseline expecting your own file to override it, every key
the baseline sets will quietly beat yours.
== one file, several documents, activated by condition ==
--- spring.profiles.active=<none> (with the multidoc profile) ---
active profiles : multidoc
effective value : from-multidoc-default-document
1. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ...
2. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 1
--- spring.profiles.active=staging (with the multidoc profile) ---
active profiles : multidoc, staging
effective value : from-multidoc-staging-document
1. from-multidoc-staging-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ...
2. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ...
3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 2
--- spring.profiles.active=prod (with the multidoc profile) ---
active profiles : multidoc, prod, prod-db, prod-metrics
effective value : from-application-prod-yaml
1. from-application-prod-yaml <- 'file application-prod.yaml' via location 'optional:classpath:/''}
2. from-multidoc-prod-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ...
3. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ...
4. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''}
holders that lost: 3
Later documents in the same file win over earlier ones, so the unconditional first
document acts as the default and each conditional document overrides it.
== the activation Spring Boot refuses ==
application-badactivation.yaml tries to set spring.profiles.active from a document
that is itself conditional on a profile.
$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=badactivation,staging
org.springframework.boot.context.config.InvalidConfigDataPropertyException: Property
'spring.profiles.active' imported from location 'class path resource
[application-badactivation.yaml]' is invalid in a profile specific resource [origin: class path
resource [application-badactivation.yaml] from profiles-and-config-1.0.0.jar - 12:13]
at
org.springframework.boot.context.config.InvalidConfigDataPropertyException.lambda$throwIfPropert
yFound$1(InvalidConfigDataPropertyException.java:123)

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>profiles-and-config</artifactId>
<version>1.0.0</version>
<name>profiles-and-config</name>
<description>Spring Boot profiles, config import, config trees and ConfigMaps</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Config trees: what a Kubernetes ConfigMap or Secret actually looks like to Spring Boot.
#
# A ConfigMap mounted as a volume is not a properties file. Kubernetes writes one file per
# key, named after the key, containing only the value. `configtree:` is the loader that reads
# that shape. This script builds the same directory layout on disk, so the demonstration is
# the real mechanism rather than a description of it.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
TREE="${TMPDIR:-/tmp}/demo-configmap"
SECRET="${TMPDIR:-/tmp}/demo-secret"
rm -rf "$TREE" "$SECRET"; mkdir -p "$TREE" "$SECRET"
# Exactly what `kubectl create configmap demo --from-literal=demo.greeting=...` produces
# once mounted: one file per key, the filename IS the property name.
printf 'from-configmap-volume' > "$TREE/demo.greeting"
printf 'jdbc:postgresql://configmap-db:5432/o' > "$TREE/demo.datasource-url"
printf '25' > "$TREE/demo.pool-size"
# Nested keys use a directory per level, or a dotted filename. Both work.
mkdir -p "$TREE/demo/nested"
printf 'from-nested-directory' > "$TREE/demo/nested/value"
# A Secret mount looks identical; only the permissions differ.
printf 'sk_live_not_a_real_key' > "$SECRET/demo.api-key"
{
echo "== what Kubernetes actually mounts =="
echo "\$ find $TREE $SECRET -type f | sort"
find "$TREE" "$SECRET" -type f | sort | sed "s|$TREE|<configmap-mount>|;s|$SECRET|<secret-mount>|"
echo
echo "\$ cat <configmap-mount>/demo.greeting; echo"
cat "$TREE/demo.greeting"; echo
echo
echo "Each file holds a bare value with no trailing newline and no key. There is no"
echo "properties syntax to parse -- the filename is the key."
echo
echo "== importing it =="
echo "\$ java -jar $JAR \\"
echo " --spring.config.import=configtree:$TREE/,configtree:$SECRET/"
echo
start_app "--spring.config.import=configtree:$TREE/,configtree:$SECRET/" > /dev/null
report demo.greeting
echo
echo " demo.pool-size = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.pool-size" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo " demo.nested.value = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.nested.value" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo " demo.api-key = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.api-key" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo
echo "A directory under the mount becomes a nested property: demo/nested/value is"
echo "demo.nested.value. That is how a ConfigMap with slashes in its keys arrives."
echo
echo "== the part that surprises people =="
echo "An imported config tree outranks application.yaml, but it is still config data,"
echo "so it still loses to an environment variable:"
echo
APP_ENV="DEMO_GREETING=from-environment-variable" \
start_app "--spring.config.import=configtree:$TREE/" > /dev/null
report demo.greeting
echo
echo "There is also no such thing as a profile-specific config tree. There is no"
echo "<mount>-prod directory convention; a per-environment ConfigMap is a different mount"
echo "chosen by the deployment, not by spring.profiles.active."
stop_app
} > docs/output/03-config-tree.txt 2>&1
cat docs/output/03-config-tree.txt

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# spring.config.import ordering, multi-document activation, and the activation Boot refuses.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== spring.config.import: which document wins? =="
echo
echo "application-import.yaml imports imported.yaml. Both set demo.greeting."
echo "\$ java -jar $JAR --spring.profiles.active=import"
echo
start_app --spring.profiles.active=import > /dev/null
report demo.greeting
echo
echo " demo.imported-only = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.imported-only" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo
echo "The imported file WON. spring.config.import does not behave like #include, and it"
echo "does not behave like a default either: the imported document is processed AFTER the"
echo "document that declared the import, so it outranks the file that pulled it in."
echo "If you import a shared baseline expecting your own file to override it, every key"
echo "the baseline sets will quietly beat yours."
echo
echo "== one file, several documents, activated by condition =="
for profile in "" staging prod; do
label="${profile:-<none>}"
echo "--- spring.profiles.active=$label (with the multidoc profile) ---"
if [ -z "$profile" ]; then
start_app --spring.profiles.active=multidoc > /dev/null
else
start_app --spring.profiles.active="multidoc,$profile" > /dev/null
fi
report demo.greeting
echo
done
echo "Later documents in the same file win over earlier ones, so the unconditional first"
echo "document acts as the default and each conditional document overrides it."
echo
echo "== the activation Spring Boot refuses =="
echo "application-badactivation.yaml tries to set spring.profiles.active from a document"
echo "that is itself conditional on a profile."
echo "\$ java -jar $JAR --spring.profiles.active=badactivation,staging"
echo
stop_app
java -jar "$JAR" --spring.profiles.active=badactivation,staging 2>&1 | clean \
| grep -E 'InvalidConfigDataPropertyException' | head -2 | fold -s -w 96
echo
echo
echo "InvalidConfigDataPropertyException, naming the file and the line. Boot refuses"
echo "rather than half-applying it: a profile that activates itself would change which"
echo "files are loaded after those files had already been chosen."
} > docs/output/04-import-and-multidoc.txt 2>&1
cat docs/output/04-import-and-multidoc.txt

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# The whole precedence question, asked once with every source set at the same time.
#
# demo.greeting is set by application.yaml, by application-prod.yaml, by an environment
# variable, by a system property and by a command-line argument -- simultaneously. The
# endpoint reports all of them in order, so the winner is not a matter of opinion.
set -euo pipefail
set +m # no job-control notices ("Killed") in the captured transcript
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== every source sets demo.greeting at once =="
echo
echo "\$ DEMO_GREETING=from-environment-variable \\"
echo " java -Ddemo.greeting=from-system-property \\"
echo " -jar $JAR --spring.profiles.active=prod \\"
echo " --demo.greeting=from-command-line-argument"
echo
scripts/stop.sh
DEMO_GREETING=from-environment-variable setsid nohup java \
-Ddemo.greeting=from-system-property -jar "$JAR" \
--spring.profiles.active=prod --demo.greeting=from-command-line-argument \
> /tmp/profiles-precedence.log 2>&1 < /dev/null &
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool
echo
echo "== and with the environment variable removed, nothing else changed =="
scripts/stop.sh
setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" \
--spring.profiles.active=prod > /tmp/profiles-precedence2.log 2>&1 < /dev/null &
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool
scripts/stop.sh
} > docs/output/01-precedence.txt 2>&1
cat docs/output/01-precedence.txt

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# The article's title question: why did application-prod.yaml have no effect?
#
# Because an environment variable was set. Profile-specific files beat non-profile files,
# but the whole config-data group sits BELOW environment variables in the documented
# precedence list, so a profile file never outranks one.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== does application-prod.yaml win? =="
echo
echo "demo.datasource-url is set in application.yaml and again in application-prod.yaml."
echo
echo "--- 1. prod profile active, no environment variable ---"
echo "\$ java -jar $JAR --spring.profiles.active=prod"
start_app --spring.profiles.active=prod > /dev/null
report demo.datasource-url
echo
echo "--- 2. identical, plus one leftover environment variable ---"
echo "\$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \\"
echo " java -jar $JAR --spring.profiles.active=prod"
APP_ENV="DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders" \
start_app --spring.profiles.active=prod > /dev/null
report demo.datasource-url
echo
echo "The profile-specific file is still loaded and still holds its value -- it is listed,"
echo "and it lost. Config data is item 3 in the documented precedence list; OS environment"
echo "variables are item 5, and later items win."
echo
echo "== the full property-source stack, in order =="
echo "\$ curl -s localhost:8080/sources"
curl -s "http://127.0.0.1:${APP_PORT}/sources" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %2d. %-34s %s" % (r["rank"], r["type"], r["name"][:110]))'
stop_app
} > docs/output/02-profile-file-loses.txt 2>&1
cat docs/output/02-profile-file-loses.txt

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
set +m # no job-control notices ("Killed") in the captured transcript
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== versions =="
java -version 2>&1 | clean
echo
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
} > docs/output/00-versions.txt 2>&1
cat docs/output/00-versions.txt

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
export PATH="$JAVA_HOME/bin:$PATH"
MVN="${MVN:-mvn}"
JAR="target/profiles-and-config-1.0.0.jar"
APP_MAIN="com.ankurm.profiles.ProfilesApplication"
APP_PORT="${APP_PORT:-8080}"
# Strip environment noise that is an artefact of the machine, not of Spring:
# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy
# truststore is configured, and it would otherwise end up in every committed transcript.
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
# Start the demo jar detached, record its PID, and block until it answers.
# Extra arguments are passed to the application. Environment variables for the run are
# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x`
start_app() {
stop_app
mkdir -p target
# Deliberately NOT setsid: setsid forks when it is not already a process-group leader,
# so $! would be the PID of a process that exits immediately and the JVM would survive
# every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind,
# and curl answers from the previous scenario -- which reads exactly like the
# configuration change under test having had no effect. Three wrong findings in this
# repository came from that before it was tracked down.
if [ -n "${APP_ENV:-}" ]; then
# shellcheck disable=SC2086
env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null &
else
nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null &
fi
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" 2>/dev/null && return 0
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
tail -20 /tmp/profiles-demo.log; return 1; }
sleep 1
done
echo "application did not answer"; tail -20 /tmp/profiles-demo.log; return 1
}
# Stop it by recorded PID. Never by pattern: `ps | grep <jar name>` also matches the shell
# running the script, because the jar name is on that shell's own command line.
stop_app() {
if [ -f target/app.pid ]; then
pid=$(cat target/app.pid)
if [ -n "$pid" ] && grep -qa "profiles-and-config" "/proc/$pid/cmdline" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice
fi
rm -f target/app.pid
fi
for _ in $(seq 1 40); do
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
sleep 0.25
done
exec 3<&- 2>/dev/null || true
}
# Print the precedence report for one property, compactly.
report() {
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=$1" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" active profiles :", ", ".join(d["activeProfiles"]) or "(none)")
print(" effective value :", d["effectiveValue"])
for h in d["holders"]:
src=h["source"]
for noisy,short in (("Config resource \x27class path resource [","file "),
("\x27 via location \x27optional:classpath:/\x27}","")):
src=src.replace(noisy,short)
src=src.replace("OriginTrackedMapPropertySource {name=","").replace("]","")
print(" %d. %-34s <- %s" % (h["rank"], h["value"], src.strip()))
print(" holders that lost:", d["shadowedCount"])'
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
"$MVN" -B -q package -DskipTests
for demo in versions precedence profile-file-loses config-tree import-and-multidoc; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
stop_app
echo
echo "regenerated:"
ls -1 docs/output/

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Start the application and block until it answers. Extra arguments are passed to the app,
# so a scenario can add --demo.mail.recipients=a,b,c without a new profile.
# ./scripts/run.sh # defaults
# ./scripts/run.sh csvlist # a profile
# ./scripts/run.sh "" --demo.x=y # no profile, one override
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
PROFILES="${1:-}"; shift || true
LOG="${LOG:-/tmp/configprops-demo.log}"
PIDFILE="${PIDFILE:-target/app.pid}"
scripts/stop.sh
ARGS=(-jar "$JAR")
[ -n "$PROFILES" ] && ARGS+=("--spring.profiles.active=$PROFILES")
ARGS+=("$@")
setsid nohup java "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
echo $! > "$PIDFILE"
for _ in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}/precedence" || true)
[ "$code" = "200" ] && exit 0
# If the JVM died -- most often because the port was still held -- fail fast and loudly
# instead of letting curl answer from a process started by an earlier scenario.
kill -0 "$(cat "$PIDFILE")" 2>/dev/null || { echo "JVM exited during startup:" >&2
tail -25 "$LOG" >&2; exit 1; }
sleep 1
done
echo "application did not answer; tail of $LOG:" >&2
tail -40 "$LOG" >&2
exit 1

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# This uses a PID file rather than a pattern match, deliberately. `pkill -f spring-boot`
# matches the shell that is running the script and takes the terminal with it. Even a
# careful-looking `ps | grep '[c]onfiguration-properties'` matches the shell's own command
# line whenever that string appears in the command you just typed -- which it does, because
# you typed the jar name. Killing a recorded PID cannot misfire.
set -u
cd "$(dirname "$0")/.."
PIDFILE="${PIDFILE:-target/app.pid}"
if [ -f "$PIDFILE" ]; then
pid=$(cat "$PIDFILE")
# Confirm the PID is still ours before signalling it: PIDs are reused.
if [ -n "$pid" ] && grep -qa "profiles-and-config" "/proc/$pid/cmdline" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PIDFILE"
fi
# Killing the process is not the same as the socket closing, and a stale listener looks
# exactly like your configuration change having had no effect.
for _ in $(seq 1 40); do
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
sleep 0.25
done
exec 3<&- 2>/dev/null || true

View File

@@ -0,0 +1,19 @@
package com.ankurm.profiles;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for the ankurm.com article
* "Spring Boot Profiles Done Right: Config Import, Config Trees and Kubernetes ConfigMaps".
*
* <p>Every scenario in {@code scripts/} starts this same application with a different
* combination of profiles, imported locations and environment variables, and asks it one
* question: which source won, and which sources were present and lost.
*/
@SpringBootApplication
public class ProfilesApplication {
public static void main(String[] args) {
SpringApplication.run(ProfilesApplication.class, args);
}
}

View File

@@ -0,0 +1,90 @@
package com.ankurm.profiles.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The endpoint the whole article is built on: for one property, every source that holds a
* value for it, in precedence order, with the winner first.
*
* <p>"I set it in {@code application-prod.yaml} and it had no effect" is not a mystery once
* you can see that four sources hold the property and yours is third. Spring Boot knows the
* answer; it just never volunteers it.
*
* <p>Documented in {@code docs/03-seeing-precedence.md}. Delete it before shipping: it will
* print whatever a mounted secret contains.
*/
@RestController
public class PrecedenceEndpoint {
private final ConfigurableEnvironment environment;
public PrecedenceEndpoint(ConfigurableEnvironment environment) {
this.environment = environment;
}
/** Every source holding {@code name}, highest precedence first. */
@GetMapping("/precedence")
public Map<String, Object> precedence(
@RequestParam(defaultValue = "demo.greeting") String name) {
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
List<Map<String, Object>> holders = new ArrayList<>();
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
var property = source.getConfigurationProperty(propertyName);
if (property == null) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("rank", holders.size() + 1);
row.put("source", shortName(source.getUnderlyingSource()));
row.put("value", String.valueOf(property.getValue()));
row.put("origin", String.valueOf(property.getOrigin()));
holders.add(row);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("property", name);
result.put("effectiveValue", environment.getProperty(name));
result.put("activeProfiles", List.of(environment.getActiveProfiles()));
result.put("holders", holders);
result.put("shadowedCount", Math.max(0, holders.size() - 1));
return result;
}
/** The environment's property sources in order, so the article can show the real stack. */
@GetMapping("/sources")
public List<Map<String, Object>> sources() {
List<Map<String, Object>> rows = new ArrayList<>();
int rank = 1;
for (PropertySource<?> source : environment.getPropertySources()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("rank", rank++);
row.put("name", source.getName());
row.put("type", source.getClass().getSimpleName());
if (source instanceof EnumerablePropertySource<?> enumerable) {
row.put("propertyCount", enumerable.getPropertyNames().length);
}
rows.add(row);
}
return rows;
}
private String shortName(Object underlying) {
String text = String.valueOf(underlying);
return text.length() > 150 ? text.substring(0, 150) + "..." : text;
}
}

View File

@@ -0,0 +1,12 @@
# spring.profiles.active cannot be set from a document that is itself profile-specific.
# Spring Boot refuses this rather than silently half-applying it. The exact exception is
# captured in docs/output/05-invalid-activation.txt.
demo:
greeting: from-badactivation
---
spring:
config:
activate:
on-profile: staging
profiles:
active: sneaky

View File

@@ -0,0 +1,11 @@
# Demonstrates spring.config.import ordering.
#
# The imported document is processed as though it appeared immediately AFTER this one, which
# means the importing file wins on any key both of them set. That is the opposite of the
# intuition most people bring from #include, and it is the subject of docs/05-config-import.md.
spring:
config:
import: "optional:classpath:/imported.yaml"
demo:
greeting: from-application-import-yaml

View File

@@ -0,0 +1,20 @@
# One file, three documents, activated by condition rather than by filename.
#
# spring.config.activate.on-profile is the mechanism behind profile-specific behaviour when
# you would rather keep everything in one file. Later documents win over earlier ones.
demo:
greeting: from-multidoc-default-document
---
spring:
config:
activate:
on-profile: staging
demo:
greeting: from-multidoc-staging-document
---
spring:
config:
activate:
on-profile: prod
demo:
greeting: from-multidoc-prod-document

View File

@@ -0,0 +1,3 @@
# Activated as part of the "prod" profile group declared in application.yaml.
demo:
pool-size: 40

View File

@@ -0,0 +1,3 @@
# The second member of the "prod" group.
demo:
metrics-enabled: true

View File

@@ -0,0 +1,5 @@
# Profile-specific configuration. This file always beats application.yaml -- and still loses
# to an environment variable, which is the point of docs/04-why-your-profile-file-lost.md.
demo:
greeting: from-application-prod-yaml
datasource-url: jdbc:postgresql://prod-db:5432/orders

View File

@@ -0,0 +1,22 @@
spring:
application:
name: profiles-and-config
profiles:
# A profile group: activating "prod" activates all three. Groups are resolved before
# config data is processed, which is why a group can be declared here and still affect
# which application-<profile>.yaml files are loaded.
group:
prod: prod-db,prod-metrics
server:
port: 8080
logging:
level:
root: WARN
demo:
# The property every scenario asks about. Each source below sets it to a string naming
# itself, so the winner is self-identifying in the transcript.
greeting: from-application-yaml
datasource-url: jdbc:h2:mem:default

View File

@@ -0,0 +1,4 @@
# Imported by application-import.yaml. Sets the same key, and loses.
demo:
greeting: from-imported-yaml
imported-only: yes-this-file-was-read

View File

@@ -0,0 +1,102 @@
package com.ankurm.profiles;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pins the precedence claims the article makes, so a future Spring Boot upgrade that changes
* any of them fails the build rather than quietly making the article wrong.
*/
class PrecedenceContractTests {
private ConfigurableApplicationContext run(String... args) {
SpringApplication application = new SpringApplication(ProfilesApplication.class);
application.setWebApplicationType(WebApplicationType.NONE);
return application.run(args);
}
@Test
@DisplayName("a profile-specific file beats application.yaml")
void profileFileBeatsBaseFile() {
try (var context = run("--spring.profiles.active=prod")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-application-prod-yaml");
}
}
@Test
@DisplayName("a profile group activates every profile it names")
void profileGroupExpands() {
try (var context = run("--spring.profiles.active=prod")) {
assertThat(context.getEnvironment().getActiveProfiles())
.containsExactlyInAnyOrder("prod", "prod-db", "prod-metrics");
assertThat(context.getEnvironment().getProperty("demo.pool-size")).isEqualTo("40");
}
}
@Test
@DisplayName("a command-line argument beats every config file")
void commandLineBeatsConfigData() {
try (var context = run("--spring.profiles.active=prod",
"--demo.greeting=from-command-line")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-command-line");
}
}
/**
* The counterintuitive one, and the reason the article has a callout about it: an
* imported document outranks the document that imported it.
*/
@Test
@DisplayName("spring.config.import: the IMPORTED file wins over the importing file")
void importedFileWins() {
try (var context = run("--spring.profiles.active=import")) {
ConfigurableEnvironment environment = context.getEnvironment();
assertThat(environment.getProperty("demo.imported-only"))
.as("the import was processed at all")
.isEqualTo("yes-this-file-was-read");
assertThat(environment.getProperty("demo.greeting"))
.as("and it beat application-import.yaml, which declared the import")
.isEqualTo("from-imported-yaml");
}
}
@Test
@DisplayName("later documents in a multi-document file win over earlier ones")
void multiDocumentOrdering() {
try (var context = run("--spring.profiles.active=multidoc")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-multidoc-default-document");
}
try (var context = run("--spring.profiles.active=multidoc,staging")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-multidoc-staging-document");
}
}
@Test
@DisplayName("a config tree beats application.yaml but still loses to nothing above it")
void configTreeIsConfigData(@org.junit.jupiter.api.io.TempDir java.nio.file.Path mount)
throws Exception {
java.nio.file.Files.writeString(mount.resolve("demo.greeting"), "from-config-tree");
try (var context = run("--spring.config.import=configtree:" + mount + "/")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-config-tree");
}
// A command-line argument still outranks it: a config tree is config data.
try (var context = run("--spring.config.import=configtree:" + mount + "/",
"--demo.greeting=from-command-line")) {
assertThat(context.getEnvironment().getProperty("demo.greeting"))
.isEqualTo("from-command-line");
}
}
}