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

@@ -11,6 +11,9 @@ files.
|---|---|---|
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
| [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache |
| [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation |
| [`profiles-and-config/`](profiles-and-config) | [Spring Boot Profiles Done Right](https://ankurm.com/) | the precedence stack made visible, config trees and ConfigMaps, why a profile file loses to an environment variable |
| [`spring-aop/`](spring-aop) | [Spring AOP Explained](https://ankurm.com/) | every pointcut designator with real matches, JDK vs CGLIB proxies, six aspects that do not fire |
Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/``post.md` for the body and `meta.md` for the title, excerpt and

View File

@@ -0,0 +1,76 @@
# @ConfigurationProperties vs @Value
Companion project for [**@ConfigurationProperties vs @Value in Spring Boot 4**](https://ankurm.com/)
on ankurm.com.
Every table and transcript quoted in that article was produced by running something here.
Three of them were produced twice, because the first answer was wrong — see
[chapter 2](docs/02-relaxed-binding.md).
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
| Validation | Hibernate Validator via `spring-boot-starter-validation` |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/run-all.sh # regenerate every transcript in docs/output/
mvn test # 7 contract tests
```
## Profiles
| Profile | What it does |
|---|---|
| *(none)* | binds `demo.*` from `application.yaml` |
| `probe` | runs the relaxed-binding matrix and exits |
| `envprobe` | reports one property as seen by the binder and by `@Value` |
| `badvalidation` | values that violate every constraint, so startup fails |
| `csvlist` | the recipients list as a comma-separated string |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /diag/bound` | the bound objects, and the same values via `@Value` |
| `GET /diag/origin?name=` | every source holding a property, in precedence order, with file and line |
Both are diagnostics. Delete them before shipping.
## Documentation
1. [Two mechanisms, not two styles](docs/01-two-mechanisms.md)
2. [Relaxed binding, measured](docs/02-relaxed-binding.md)
3. [Getting the bean registered](docs/03-registration.md)
4. [Records, constructor binding and defaults](docs/04-records-and-defaults.md)
5. [Validation](docs/05-validation.md)
6. [When `@Value` is still the right answer](docs/06-when-value-still-wins.md)
7. [IDE metadata, and the JDK 23 change that silently breaks it](docs/07-ide-metadata.md)
8. [Diagnosing a value](docs/08-diagnosing-a-value.md)
## Captured output
| File | Produced by |
|---|---|
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
| [`01-relaxed-matrix.txt`](docs/output/01-relaxed-matrix.txt) | `scripts/demo-relaxed-matrix.sh` |
| [`02-env-var-binding.txt`](docs/output/02-env-var-binding.txt) | `scripts/demo-env-binding.sh` |
| [`03-value-vs-binding.txt`](docs/output/03-value-vs-binding.txt) | `scripts/demo-value-vs-binding.sh` |
| [`04-validation-failure.txt`](docs/output/04-validation-failure.txt) | `scripts/demo-validation.sh` |
| [`05-metadata-generation.txt`](docs/output/05-metadata-generation.txt) | `scripts/demo-metadata-generation.sh` |
## Three findings worth the trip
- **`@Value` gets relaxed binding inside Spring Boot.** The universal claim that it does not is
a statement about the Spring Framework; Boot attaches a property source that changes it.
- **The configuration processor silently stops on JDK 23+** when declared as a plain
dependency, so IDE auto-completion quietly dies while the build stays green.
- **`@Valid` is not what makes nested constraints run.** Boot's `ValidationBindHandler`
validates every object it binds.

View File

@@ -0,0 +1,49 @@
[Index](../README.md) &middot; [Relaxed binding &rarr;](02-relaxed-binding.md)
# 1. Two mechanisms, not two styles
`@Value` and `@ConfigurationProperties` are often presented as a matter of taste. They are not.
They are two different mechanisms that happen to read from the same `Environment`, and almost
every difference in behaviour follows from that.
## `@Value` is placeholder resolution
`@Value("${demo.mail.host}")` is a string. Spring's `PropertySourcesPlaceholderConfigurer`
resolves it against the environment during bean post-processing, converts the result to the
field's type, and assigns it. There is no model of what a property *is* — only a name, a
lookup and a conversion.
Consequences:
- The property name lives in a string literal. Renaming a property is a text search.
- One field, one lookup. There is no way to express "these six properties belong together".
- Defaulting is `:` inside the placeholder, and nothing else.
- There is no validation step, because there is no object to validate.
- SpEL is available: `#{...}` can compute rather than look up.
## `@ConfigurationProperties` is binding
`Binder` walks a target type, works out which properties it needs, asks the
`ConfigurationPropertySource`s for each one, converts, and constructs the object. The target
type is the model.
Consequences:
- The property names are derived from the type. Renaming a component renames the property.
- Nested types, lists and maps bind, because the binder knows the shape it is filling.
- Defaults are `@DefaultValue`, applied per component during construction.
- The bound object can be validated as an object, once, at startup.
- No SpEL. The binder maps values; it does not compute them.
## The one that surprises people
Because both read from the same environment, the *relaxed* rules ought to differ — and in the
Spring Framework alone they do. Inside Spring Boot they largely do not, because Boot attaches
an extra property source that gives placeholder resolution the binder's name matching.
[Chapter 2](02-relaxed-binding.md) has the measured matrix.
## Which to use
Use `@ConfigurationProperties` for anything that is a group of settings — which is most
things. Reach for `@Value` when you need a single value in a place where a type would be
ceremony, or when you need SpEL. [Chapter 6](06-when-value-still-wins.md) is the honest list.

View File

@@ -0,0 +1,62 @@
[&larr; Two mechanisms](01-two-mechanisms.md) &middot; [Index](../README.md) &middot; [Registration &rarr;](03-registration.md)
# 2. Relaxed binding, measured
Everything here is generated by [`RelaxedBindingProbe`](../src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java)
and re-checked against real processes by [`demo-env-binding.sh`](../scripts/demo-env-binding.sh).
Transcripts: [`01-relaxed-matrix.txt`](output/01-relaxed-matrix.txt),
[`02-env-var-binding.txt`](output/02-env-var-binding.txt).
## The canonical form
Every property has one canonical name: lower case, words separated by `-`, levels separated by
`.`. For `RelaxedProperties.apiKey` under prefix `demo.relaxed`, that is
`demo.relaxed.api-key`.
## What matches it
| Spelling | Source | Binder | `${}` plain Spring | `${}` in Spring Boot |
|---|---|---|---|---|
| `demo.relaxed.api-key` | file / `-D` | bound | bound | bound |
| `demo.relaxed.apiKey` | file / `-D` | bound | MISS | bound |
| `demo.relaxed.api_key` | file / `-D` | bound | MISS | bound |
| `demo.relaxed.APIKEY` | file / `-D` | bound | MISS | bound |
| `DEMO.RELAXED.API-KEY` | file / `-D` | bound | MISS | bound |
| `demo.relaxed.apikey` | file / `-D` | bound | MISS | bound |
| `demo.relaxed.api.key` | file / `-D` | **MISS** | MISS | **MISS** |
| `DEMO_RELAXED_API_KEY` | env var | bound | bound | bound |
| `DEMO_RELAXED_APIKEY` | env var | bound | MISS | bound |
| `demo_relaxed_api_key` | env var | bound | bound | bound |
## Reading it
**Dashes, underscores and case are noise.** `ConfigurationPropertyName` compares elements in a
"uniform" form with separators removed and case folded, so `api-key`, `apiKey`, `api_key` and
`apikey` are the same name.
**A dot is not noise.** `demo.relaxed.api.key` is four name elements; `demo.relaxed.api-key` is
three. Nothing relaxed will ever join them. This is the one spelling that genuinely fails, and
it is the one people write when they are guessing.
**`@Value` gets relaxed resolution too — but only inside Spring Boot.** The widely repeated
claim that `@Value` does not support relaxed binding is a statement about the Spring Framework.
Spring Boot calls `ConfigurationPropertySources.attach(environment)` while preparing every
environment, which inserts a property source that resolves placeholders through the binder's
name matching. Remove that call and the middle column is what you get.
That is worth knowing in both directions: you can rely on it in a Boot application, and you
cannot rely on it in a plain `ApplicationContext`, a standalone `Environment` or a test that
builds one by hand.
## Environment variables
Both `DEMO_RELAXED_API_KEY` and `DEMO_RELAXED_APIKEY` work. Prefer the first: `_` for every
separator, upper case throughout. It is the form the binder generates when it goes looking, so
it is the one that cannot depend on the reverse mapping.
A harness note that cost a wrong finding here: Spring Boot decides to apply environment-variable
name mapping by comparing the property source's *name* against
`StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME`, not by checking its type. A
`SystemEnvironmentPropertySource` registered under any other name is mapped with the ordinary
rules. A test that builds one by hand and names it something descriptive will report that
underscore spellings do not bind, and will be wrong.

View File

@@ -0,0 +1,45 @@
[&larr; Relaxed binding](02-relaxed-binding.md) &middot; [Index](../README.md) &middot; [Records and defaults &rarr;](04-records-and-defaults.md)
# 3. Getting the bean registered
`@ConfigurationProperties` on a class does nothing on its own. The class has to become a bean,
and there are three ways to arrange that:
| How | Where it goes | When to use it |
|---|---|---|
| `@ConfigurationPropertiesScan` | on the application class | most applications; scans a package |
| `@EnableConfigurationProperties(Foo.class)` | on any `@Configuration` class | libraries, or when you want the list explicit |
| `@Component` | on the properties class itself | works, but mixes a stereotype into a value type |
This project uses `@ConfigurationPropertiesScan`, on
[`ConfigBindingApplication`](../src/main/java/com/ankurm/configprops/ConfigBindingApplication.java).
## The failure
If none of the three is present, the class compiles, the application starts, and the bean does
not exist. Injecting it fails with `NoSuchBeanDefinitionException` — which is at least loud.
The quiet version is worse: with constructor binding, a properties *record* that is never
registered simply never appears, and if the only thing that used it was optional, nothing
complains at all.
## Constructor binding and `@Autowired` do not mix
A type bound through its constructor is built by the binder, not by the container. It cannot
have collaborators injected into that constructor, because every parameter is treated as a
property to bind. If a properties type needs a collaborator, it is not a properties type.
## `@ConfigurationProperties` on an `@Bean` method
Legal, and useful when the type comes from a library you cannot annotate:
```java
@Bean
@ConfigurationProperties(prefix = "demo.thirdparty")
ThirdPartyConfig thirdPartyConfig() {
return new ThirdPartyConfig();
}
```
This uses setter binding, not constructor binding — the object already exists by the time the
binder sees it.

View File

@@ -0,0 +1,62 @@
[&larr; Registration](03-registration.md) &middot; [Index](../README.md) &middot; [Validation &rarr;](05-validation.md)
# 4. Records, constructor binding and defaults
See [`MailProperties`](../src/main/java/com/ankurm/configprops/props/MailProperties.java) and
the bound output in [`03-value-vs-binding.txt`](output/03-value-vs-binding.txt).
## Records need no annotation
A record has exactly one canonical constructor, so the binder uses it. `@ConstructorBinding`
is only needed to pick between candidates when a type has more than one constructor — and
since Spring Boot 3 it goes on the *constructor*, not the type.
Constructor binding gives you immutability, which matters more than it sounds: a mutable
`@ConfigurationProperties` bean is a singleton that anything can write to.
## Defaults
A record cannot have field initialisers, so the default has to be attached to the component:
```java
public record MailProperties(
String host, // no default: null if absent
@DefaultValue("587") int port,
@DefaultValue("30s") Duration timeout,
@DefaultValue RetryProperties retries, // nested, with its own defaults
@DefaultValue List<String> recipients, // empty list, not null
@DefaultValue Map<String, String> headers) {
}
```
`@DefaultValue` with no argument on a nested type means "construct it with its own defaults"
rather than "bind it to null". Same for collections: you get an empty one instead of `null`,
which removes a class of `NullPointerException` from startup code.
A component with neither a value nor a `@DefaultValue` binds to `null` for a reference type. A
`record` component of primitive type with no value and no default fails the bind.
## The whole-object rule
If *nothing* under the prefix is present, the binder returns no result at all — not an object
full of defaults. `BindResult.isBound()` is `false` and `get()` throws. Defaults apply to
components of an object that is being constructed; they do not cause one to be constructed.
For a bean registered through `@ConfigurationPropertiesScan` this is invisible, because Spring
Boot binds with a target that always constructs. It shows up as soon as you call `Binder`
yourself, which is why it is here.
## Lists
| Written as | `@ConfigurationProperties` | `@Value` |
|---|---|---|
| YAML block list | binds | does not see it |
| `a,b,c` string | binds | binds |
| `foo[0]`, `foo[1]` | binds | does not see it |
The middle row is the only shape both understand, and it is why comma-separated lists persist
in configuration long after they stopped being pleasant to read.
One thing to know about list overriding: a list is bound from the highest-precedence source
that contains the property, *entirely*. Lists do not merge across sources. A source that sets
three elements replaces a lower source's two; it does not append to them.

View File

@@ -0,0 +1,55 @@
[&larr; Records and defaults](04-records-and-defaults.md) &middot; [Index](../README.md) &middot; [When @Value wins &rarr;](06-when-value-still-wins.md)
# 5. Validation
Transcript: [`04-validation-failure.txt`](output/04-validation-failure.txt).
Type: [`ValidatedProperties`](../src/main/java/com/ankurm/configprops/props/ValidatedProperties.java).
This is the capability `@Value` does not have at all, and the reason to prefer binding for
anything that can be misconfigured.
## What it takes
1. `spring-boot-starter-validation` on the classpath. Without a validator implementation,
`@Validated` is a no-op — nothing is checked and nothing says so.
2. `@Validated` on the properties type.
3. JSR-380 constraints on the components.
## What you get
A bad value becomes a startup failure that names the property, the offending value, the file
and line it came from, and the constraint it broke — all of them at once, not one per restart:
```
Property: demo.validated.port
Value: "99999"
Origin: class path resource [application-badvalidation.yaml] - 6:11
Reason: must be less than or equal to 65535
```
The alternative is a `NumberFormatException` in a request handler at 3am.
## `@Valid` on nested types is not what makes them validate
The rule "annotate nested properties with `@Valid` or their constraints are ignored" is widely
repeated and does not apply here. It is true of ordinary bean validation, where cascading is
opt-in. Spring Boot's `ValidationBindHandler` validates *every object the binder finishes
constructing*, nested ones included.
`BindingContractTests.nestedConstraintsFireWithoutValid` pins this: a nested record carrying
`@Max(100)` and no `@Valid` anywhere in the type still fails the bind at 4000. That test was
originally written to assert the opposite and failed, which is how it ended up documented here.
Keep writing `@Valid` if you like — it is harmless and it is what a reader expects. Just do not
believe it is load-bearing.
## Ordering
The violation order in the failure report is not stable between runs. Do not write a test that
asserts on it.
## Custom validation
For rules a constraint annotation cannot express — "if `mode` is `remote` then `endpoint` is
required" — implement `Validator` and expose it as a bean named `configurationPropertiesValidator`.
It runs at bind time with the same reporting.

View File

@@ -0,0 +1,42 @@
[&larr; Validation](05-validation.md) &middot; [Index](../README.md) &middot; [IDE metadata &rarr;](07-ide-metadata.md)
# 6. When `@Value` is still the right answer
Binding wins most of the time, and this project is largely an argument for it. The exceptions
are real though, and pretending otherwise makes the advice easy to dismiss.
## SpEL
`@Value` evaluates SpEL; the binder does not. If the value has to be *computed*, this is the
only one of the two that can do it:
```java
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
private int computedThreads;
```
There is no `@ConfigurationProperties` equivalent. The binder maps a value; it does not derive
one. (You can always compute in a compact constructor instead, which is usually clearer.)
## A single value in a class that is not about configuration
A `@Component` that needs one feature flag does not benefit from a properties type. The type
would exist to hold one field and would be injected in one place.
The line is roughly: if you would name the type after the group of settings and the name would
be meaningful, use binding. If you would have to invent a name, use `@Value`.
## Reading someone else's property
`@Value("${server.port}")` reads a property owned by Spring Boot. Declaring your own type for
it would imply you own it.
## What is *not* a good reason
- **"It's less code."** For one property, marginally. For four, the properties type is
shorter and it is also checked.
- **"I need a default."** Both support defaults.
- **"@Value is faster."** Neither is measurable next to a database call, and both happen once
at startup.
- **"Relaxed binding doesn't work with @Value."** Inside Spring Boot, it does —
[chapter 2](02-relaxed-binding.md).

View File

@@ -0,0 +1,88 @@
[&larr; When @Value wins](06-when-value-still-wins.md) &middot; [Index](../README.md) &middot; [Diagnosing a value &rarr;](08-diagnosing-a-value.md)
# 7. IDE metadata, and the JDK 23 change that silently breaks it
Transcript: [`05-metadata-generation.txt`](output/05-metadata-generation.txt).
## What the metadata is
`spring-boot-configuration-processor` is an annotation processor. At compile time it reads your
`@ConfigurationProperties` types and writes `META-INF/spring-configuration-metadata.json`:
```json
{
"groups": [
{ "name": "demo.mail", "type": "com.ankurm.configprops.props.MailProperties" }
],
"properties": [
{ "name": "demo.mail.port", "type": "java.lang.Integer", "defaultValue": 587 }
]
}
```
That file is what makes property names auto-complete in an IDE, and what shows the Javadoc on
a record component as hover documentation. Nothing at runtime reads it.
## The failure
Declaring the processor as a dependency — the way every tutorial written before 2024 shows —
stops working on JDK 23 and later:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
```
Absent any processor-related command-line option, `-proc:none` is now javac's default. JDK 21
began printing an informative message when implicit annotation processing was detected, and
JDK 23 turned the policy off, with the stated goal of making builds robust against processors
landing on the classpath unintentionally. A processor that is only on the classpath is now
simply not run.
The build still succeeds. The jar is still valid. The application behaves identically. The only
symptom is that property auto-completion quietly stops working, which is the kind of thing
people blame on the IDE.
Two compilations of identical sources with the identical processor jar:
```
A) javac -cp <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
spring-configuration-metadata.json files produced: 0
B) javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b $SOURCES
spring-configuration-metadata.json files produced: 1
```
## The fix
Declare it as an annotation processor path. That makes Maven pass `--processor-path`, and an
explicit processor option re-enables processing -- the new default only applies when javac is
given nothing to go on:
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${project.parent.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
```
`-proc:full` also works and is a smaller change, but it restores the old policy for every
processor on the classpath, which is the behaviour that was turned off for a reason.
## Checking
`ls target/classes/META-INF/spring-configuration-metadata.json`. If it is not there, the
processor did not run. Add `@ConfigurationProperties` metadata to your build's definition of
done, because nothing else will tell you.

View File

@@ -0,0 +1,57 @@
[&larr; IDE metadata](07-ide-metadata.md) &middot; [Index](../README.md)
# 8. Diagnosing a value
Endpoint: [`BindingDiagnosticsEndpoint`](../src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java).
"The property is set but the application does not see it" is the most common configuration bug,
and it is hard for one reason: a value carries no visible provenance. Spring Boot tracks it
anyway — every property loaded from a file or a config tree carries an `Origin` naming the
resource and the line.
```
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'
{
"property": "demo.mail.host",
"effectiveValue": "smtp.example.com",
"candidatesInPrecedenceOrder": [
{
"source": "OriginTrackedMapPropertySource {name='Config resource ... [application.yaml]'}",
"value": "smtp.example.com",
"origin": "class path resource [application.yaml] - 15:11"
}
]
}
```
Line 15, column 11. Not "somewhere in your configuration".
## How it works
```java
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
ConfigurationProperty property =
source.getConfigurationProperty(ConfigurationPropertyName.of(name));
if (property != null) {
// property.getValue(), property.getOrigin(), source.getUnderlyingSource()
}
}
```
Iterating `ConfigurationPropertySources.get(...)` gives you the sources in precedence order.
The first hit is the winner; everything after it exists and lost. A property with several
holders is exactly what "my change had no effect" looks like from the inside — and the
[profiles project](../../profiles-and-config) is entirely about that case.
## Delete it before shipping
This endpoint prints whatever a mounted secret contains. If you want it permanently, put it
behind the management port and authentication, or use Actuator's `/actuator/env`, which does
the same job with sanitisation built in.
## Without an endpoint
- `--debug` prints the auto-configuration report, not property origins.
- Setting `logging.level.org.springframework.boot.context.config=TRACE` logs which config data
resources were loaded, in order — useful when the question is "was my file read at all".
- In a test, inject `Environment` and inspect `getPropertySources()` directly.

View File

@@ -0,0 +1,7 @@
== versions ==
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
spring-boot-starter-parent: 4.1.1

View File

@@ -0,0 +1,22 @@
== relaxed binding matrix ==
canonical property: demo.relaxed.api-key
SPELLING SOURCE BINDER ${} plain ${} boot
--------------------------------------------------------------
demo.relaxed.api-key map bound bound bound
demo.relaxed.apiKey map bound MISS bound
demo.relaxed.api_key map bound MISS bound
demo.relaxed.APIKEY map bound MISS bound
DEMO.RELAXED.API-KEY map bound MISS bound
demo.relaxed.apikey map bound MISS bound
demo.relaxed.api.key map MISS MISS MISS
DEMO_RELAXED_API_KEY env bound bound bound
DEMO_RELAXED_APIKEY env bound MISS bound
demo_relaxed_api_key env bound bound bound
DEMO.RELAXED.API-KEY env bound bound bound
BINDER = what @ConfigurationProperties sees (Binder)
${} plain = placeholder resolution against a bare StandardEnvironment
${} boot = the same, after ConfigurationPropertySources.attach(env),
which Spring Boot performs on every environment it prepares
bound = the value arrived; MISS = the property was not found

View File

@@ -0,0 +1,43 @@
== real process, real environment: binding demo.relaxed.api-key ==
canonical property : demo.relaxed.api-key
(MISS) means the property was not found and the declared default was used
--- as an operating-system environment variable ---
$ DEMO_RELAXED_API_KEY=secret-value java -jar target/configuration-properties-1.0.0.jar
env-var-set : DEMO_RELAXED_API_KEY=secret-value
@ConfigurationProps: secret-value
@Value : secret-value
Environment.get : secret-value
$ DEMO_RELAXED_APIKEY=secret-value java -jar target/configuration-properties-1.0.0.jar
env-var-set : DEMO_RELAXED_APIKEY=secret-value
@ConfigurationProps: secret-value
@Value : secret-value
Environment.get : secret-value
--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---
$ java -Ddemo.relaxed.api-key=secret-value -jar target/configuration-properties-1.0.0.jar
@ConfigurationProps: secret-value
@Value : secret-value
Environment.get : secret-value
$ java -Ddemo.relaxed.apiKey=secret-value -jar target/configuration-properties-1.0.0.jar
@ConfigurationProps: secret-value
@Value : secret-value
Environment.get : secret-value
$ java -Ddemo.relaxed.apikey=secret-value -jar target/configuration-properties-1.0.0.jar
@ConfigurationProps: secret-value
@Value : secret-value
Environment.get : secret-value
$ java -Ddemo.relaxed.api.key=secret-value -jar target/configuration-properties-1.0.0.jar
@ConfigurationProps: (unset)
@Value : (MISS)
Environment.get : (MISS)
The last one is not a spelling of the property. api.key is two name elements;
api-key is one. Nothing relaxed will ever join them.

View File

@@ -0,0 +1,79 @@
== @ConfigurationProperties vs @Value, same application, same application.yaml ==
$ curl -s localhost:8080/diag/bound
{
"mail": {
"host": "smtp.example.com",
"port": 587,
"timeout": "PT45S",
"retries": {
"maxAttempts": 5,
"backoff": "PT1.5S"
},
"recipients": [
"ops@example.com",
"oncall@example.com"
],
"headers": {
"X-Env": "demo",
"X-Team": "platform"
}
},
"validated": {
"name": "demo-service",
"port": 8443,
"endpoint": "https://api.example.com",
"pool": {
"size": 25
}
},
"fromValueAnnotation": {
"host": "smtp.example.com",
"port": 587,
"timeout": "PT45S",
"recipients": [],
"poolSize": 25,
"computedThreads": 4
}
}
demo.mail.recipients is a YAML block list. The binder produced both elements.
@Value produced an empty list -- placeholder resolution has no concept of a YAML
sequence, so ${demo.mail.recipients:} fell through to its own empty default.
== the same list written as a comma-separated string, at higher precedence ==
$ ./scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com
binder : ['x@e.com', 'y@e.com', 'z@e.com']
@Value : ['x@e.com', 'y@e.com', 'z@e.com']
Both see it. A comma-separated string is the one list shape @Value understands, and
the command-line source outranks the YAML file for the binder as well.
== where did that value come from? ==
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'
{
"property": "demo.mail.recipients",
"effectiveValue": "x@e.com,y@e.com,z@e.com",
"candidatesInPrecedenceOrder": [
{
"source": "SimpleCommandLinePropertySource {name='commandLineArgs'}",
"value": "x@e.com,y@e.com,z@e.com",
"origin": "\"demo.mail.recipients\" from property source \"commandLineArgs\""
}
],
"shadowedBy": null
}
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'
{
"property": "demo.mail.host",
"effectiveValue": "smtp.example.com",
"candidatesInPrecedenceOrder": [
{
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}",
"value": "smtp.example.com",
"origin": "class path resource [application.yaml] from configuration-properties-1.0.0.jar - 15:11"
}
],
"shadowedBy": null
}

View File

@@ -0,0 +1,33 @@
== startup with demo.validated.* deliberately out of range ==
$ java -jar target/configuration-properties-1.0.0.jar --spring.profiles.active=badvalidation
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.ankurm.configprops.props.ValidatedProperties failed:
Property: demo.validated.pool.size
Value: "4000"
Reason: must be less than or equal to 100
Property: demo.validated.endpoint
Value: "ftp://files.example.com"
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 8:15
Reason: must match "https?://.*"
Property: demo.validated.port
Value: "99999"
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 6:11
Reason: must be less than or equal to 65535
Property: demo.validated.name
Value: " "
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 4:11
Reason: must not be blank
Action:
Update your application's configuration

View File

@@ -0,0 +1,68 @@
== is the annotation processor discovered? ==
openjdk version "25.0.4.1" 2026-08-18 LTS
processor jar: spring-boot-configuration-processor-4.1.1.jar
A) processor on the classpath, javac defaults -- what an <optional> dependency gives you
$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
spring-configuration-metadata.json files produced: 0
B) identical, plus -proc:full
$ javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b $SOURCES
spring-configuration-metadata.json files produced: 1
Both compilations succeed. Only one of them has metadata.
== what this project's pom does instead ==
The processor is declared as an <annotationProcessorPath> on maven-compiler-plugin,
which puts it on javac's --processor-path where discovery is not disabled:
$ mvn clean package && ls target/classes/META-INF/
spring-configuration-metadata.json
== the generated metadata, first entries ==
{
"groups": [
{
"name": "demo.mail",
"type": "com.ankurm.configprops.props.MailProperties",
"sourceType": "com.ankurm.configprops.props.MailProperties"
},
{
"name": "demo.mail.retries",
"type": "com.ankurm.configprops.props.MailProperties$RetryProperties",
"sourceType": "com.ankurm.configprops.props.MailProperties",
"sourceMethod": "retries()"
},
{
"name": "demo.relaxed",
"type": "com.ankurm.configprops.props.RelaxedProperties",
"sourceType": "com.ankurm.configprops.props.RelaxedProperties"
},
{
"name": "demo.validated",
"type": "com.ankurm.configprops.props.ValidatedProperties",
"sourceType": "com.ankurm.configprops.props.ValidatedProperties"
},
{
"name": "demo.validated.pool",
"type": "com.ankurm.configprops.props.ValidatedProperties$Pool",
"sourceType": "com.ankurm.configprops.props.ValidatedProperties",
"sourceMethod": "pool()"
}
],
"properties": [
{
"name": "demo.mail.headers",
"type": "java.util.Map<java.lang.String,java.lang.String>",
"description": "Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.",
"sourceType": "com.ankurm.configprops.props.MailProperties"
},
{
"name": "demo.mail.host",
"type": "java.lang.String",
"description": "SMTP host. No default: absent means the application must not start.",
"sourceType": "com.ankurm.configprops.props.MailProperties"
},
{
"name": "demo.mail.port",

View File

@@ -0,0 +1,75 @@
<?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>configuration-properties</artifactId>
<version>1.0.0</version>
<name>configuration-properties</name>
<description>@ConfigurationProperties vs @Value: binding, validation and relaxed rules</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Supplies the jakarta.validation API plus Hibernate Validator. Without it,
@Validated on a @ConfigurationProperties class is silently a no-op:
no validator is present, so nothing is checked. See docs/05-validation.md. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- The configuration processor is declared HERE, as an annotationProcessorPath,
and deliberately NOT as an <optional> dependency.
On JDK 23 and later javac no longer discovers annotation processors on the
classpath (that discovery was deprecated in JDK 21 and switched off in 23).
A processor declared only as a dependency is therefore never run: the build
still succeeds, the jar is still valid, and META-INF/spring-configuration-
metadata.json is silently absent. The only visible symptom is that property
auto-completion stops working in the IDE.
docs/output/05-metadata-generation.txt is the A/B that proves it. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${project.parent.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# The matrix re-asked the expensive way: one real JVM per spelling, the value supplied by the
# operating system or by -D, and read back through both @ConfigurationProperties and @Value.
#
# This script exists because the in-process matrix was wrong twice before it was right. A
# synthesised property source is not a running Spring Boot application, and when the two
# disagree the running application wins.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
RUN=(java -jar "$JAR" --spring.profiles.active=envprobe
--spring.main.web-application-type=none --spring.main.banner-mode=off
--logging.level.root=OFF)
run_env() {
echo "\$ $1=secret-value java -jar $JAR"
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY "$1=secret-value" "${RUN[@]}" 2>&1 \
| clean | grep -E "^(env-var-set|@Config|@Value|Environment)"
echo
}
run_sysprop() {
echo "\$ java -D$1=secret-value -jar $JAR"
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY \
java "-D$1=secret-value" -jar "$JAR" --spring.profiles.active=envprobe \
--spring.main.web-application-type=none --spring.main.banner-mode=off \
--logging.level.root=OFF 2>&1 \
| clean | grep -E "^(@Config|@Value|Environment)"
echo
}
{
echo "== real process, real environment: binding demo.relaxed.api-key =="
echo
echo "canonical property : demo.relaxed.api-key"
echo "(MISS) means the property was not found and the declared default was used"
echo
echo "--- as an operating-system environment variable ---"
echo
run_env DEMO_RELAXED_API_KEY
run_env DEMO_RELAXED_APIKEY
echo "--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---"
echo
run_sysprop demo.relaxed.api-key
run_sysprop demo.relaxed.apiKey
run_sysprop demo.relaxed.apikey
run_sysprop demo.relaxed.api.key
echo "The last one is not a spelling of the property. api.key is two name elements;"
echo "api-key is one. Nothing relaxed will ever join them."
} > docs/output/02-env-var-binding.txt 2>&1
cat docs/output/02-env-var-binding.txt

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Does spring-boot-configuration-processor actually run?
#
# Two builds of the SAME sources with the SAME processor jar, differing only in how the
# processor is declared to the compiler. On JDK 23+ that difference decides whether
# META-INF/spring-configuration-metadata.json exists at all -- and neither build fails.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
WORK="${TMPDIR:-/tmp}/configprops-metadata-ab"
rm -rf "$WORK"; mkdir -p "$WORK/a" "$WORK/b"
PROC_JAR=$(find ~/.m2/repository/org/springframework/boot/spring-boot-configuration-processor \
-name 'spring-boot-configuration-processor-*.jar' | sort | tail -1)
CP=$("$MVN" -B -o -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout 2>/dev/null | tail -1)
SOURCES=$(find src/main/java -name '*.java')
{
echo "== is the annotation processor discovered? =="
echo
java -version 2>&1 | clean | head -1
echo "processor jar: $(basename "$PROC_JAR")"
echo
echo "A) processor on the classpath, javac defaults -- what an <optional> dependency gives you"
echo "\$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a \$SOURCES"
javac -nowarn -cp "$PROC_JAR:$CP" -d "$WORK/a" $SOURCES 2>&1 | clean | head -3 || true
echo " spring-configuration-metadata.json files produced: \
$(find "$WORK/a" -name 'spring-configuration-metadata.json' | wc -l)"
echo
echo "B) identical, plus -proc:full"
echo "\$ javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b \$SOURCES"
javac -nowarn -proc:full -cp "$PROC_JAR:$CP" -d "$WORK/b" $SOURCES 2>&1 | clean | head -3 || true
echo " spring-configuration-metadata.json files produced: \
$(find "$WORK/b" -name 'spring-configuration-metadata.json' | wc -l)"
echo
echo "Both compilations succeed. Only one of them has metadata."
echo
echo "== what this project's pom does instead =="
echo "The processor is declared as an <annotationProcessorPath> on maven-compiler-plugin,"
echo "which puts it on javac's --processor-path where discovery is not disabled:"
echo
echo "\$ mvn clean package && ls target/classes/META-INF/"
ls target/classes/META-INF/ 2>/dev/null || echo "(run mvn package first)"
echo
echo "== the generated metadata, first entries =="
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json 2>/dev/null \
| head -45
} > docs/output/05-metadata-generation.txt 2>&1
cat docs/output/05-metadata-generation.txt

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The relaxed-binding matrix, generated by binding each spelling rather than by hand.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
java -jar "$JAR" --spring.profiles.active=probe --spring.main.web-application-type=none \
2>&1 | clean | sed -n '/== relaxed binding matrix ==/,/MISS = the property/p' \
> docs/output/01-relaxed-matrix.txt
cat docs/output/01-relaxed-matrix.txt

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# What a failing @Validated @ConfigurationProperties actually prints at startup.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== startup with demo.validated.* deliberately out of range =="
echo "\$ java -jar $JAR --spring.profiles.active=badvalidation"
echo
java -jar "$JAR" --spring.profiles.active=badvalidation \
--spring.main.web-application-type=none 2>&1 | clean \
| sed -n '/APPLICATION FAILED TO START/,/^Update your application/p'
echo
echo
echo "All four violations are reported at once, each with the file and line that supplied"
echo "the value. The process refused to start rather than serving traffic with a pool size"
echo "of 4000."
echo
echo "Note demo.validated.pool.size has no Origin line. It is a nested record reached"
echo "through @Valid, and the binder tracks origins per bound property, not per constraint."
} > docs/output/04-validation-failure.txt 2>&1
cat docs/output/04-validation-failure.txt

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Side by side: what the binder produced and what @Value produced, from one running process.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== @ConfigurationProperties vs @Value, same application, same application.yaml =="
echo
scripts/run.sh > /dev/null
echo "\$ curl -s localhost:8080/diag/bound"
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" | python3 -m json.tool
echo
echo "demo.mail.recipients is a YAML block list. The binder produced both elements."
echo "@Value produced an empty list -- placeholder resolution has no concept of a YAML"
echo "sequence, so \${demo.mail.recipients:} fell through to its own empty default."
echo
echo "== the same list written as a comma-separated string, at higher precedence =="
scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com > /dev/null
echo "\$ ./scripts/run.sh \"\" --demo.mail.recipients=x@e.com,y@e.com,z@e.com"
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" binder :", d["mail"]["recipients"]); print(" @Value :", d["fromValueAnnotation"]["recipients"])'
echo
echo "Both see it. A comma-separated string is the one list shape @Value understands, and"
echo "the command-line source outranks the YAML file for the binder as well."
echo
echo "== where did that value come from? =="
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'"
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.recipients" | python3 -m json.tool
echo
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'"
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.host" | python3 -m json.tool
scripts/stop.sh
} > docs/output/03-value-vs-binding.txt 2>&1
cat docs/output/03-value-vs-binding.txt

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Exact versions every other transcript in this directory was produced against.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== versions =="
java -version 2>&1 | clean
echo
"$MVN" -B -o -q dependency:tree 2>/dev/null \
| grep -E "spring-boot:jar|spring-core:jar|spring-context:jar|hibernate-validator:jar|jakarta.validation-api:jar" \
| sed 's/^\[INFO\] //' || true
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,13 @@
#!/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/configuration-properties-1.0.0.jar"
APP_MAIN="com.ankurm.configprops.ConfigBindingApplication"
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"; }

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 relaxed-matrix env-binding value-vs-binding validation metadata-generation; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
scripts/stop.sh
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}/diag/bound" || 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 "configuration-properties" "/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,23 @@
package com.ankurm.configprops;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* Companion application for the ankurm.com article
* "@ConfigurationProperties vs @Value in Spring Boot 4".
*
* <p>{@code @ConfigurationPropertiesScan} is what registers the {@code @ConfigurationProperties}
* types in {@code com.ankurm.configprops.props} as beans. Without it &mdash; and without
* {@code @EnableConfigurationProperties} or a stereotype annotation on each type &mdash; the
* classes compile, the application starts, and the beans simply do not exist. That is the first
* entry in the failure gallery: see {@code docs/03-registration.md}.
*/
@SpringBootApplication
@ConfigurationPropertiesScan
public class ConfigBindingApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigBindingApplication.class, args);
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.configprops.props;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Constructor binding with a record &mdash; the shape this article recommends.
*
* <p>A record has exactly one canonical constructor, so Spring Boot uses constructor binding
* without any annotation. {@code @ConstructorBinding} is only needed to disambiguate when a
* type has more than one candidate constructor.
*
* <p>A record cannot declare field initialisers, so "default value" has to be expressed with
* {@link DefaultValue}. Leaving it off does not give you {@code null} for a primitive-like
* type &mdash; it gives you a binding failure. See {@code docs/04-records-and-defaults.md}.
*
* @param host SMTP host. No default: absent means the application must not start.
* @param port SMTP port, defaulted rather than left null.
* @param timeout Bound from an ISO-8601 duration or a suffixed form such as {@code 30s}.
* @param retries Nested record, bound from {@code demo.mail.retries.*}.
* @param recipients Bound from a YAML list, an indexed property list, or a comma-separated string.
* @param headers Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.
*/
@ConfigurationProperties(prefix = "demo.mail")
public record MailProperties(
String host,
@DefaultValue("587") int port,
@DefaultValue("30s") Duration timeout,
@DefaultValue RetryProperties retries,
@DefaultValue List<String> recipients,
@DefaultValue Map<String, String> headers) {
/**
* Nested record. {@code @DefaultValue} on the enclosing component means this is
* instantiated with its own defaults when {@code demo.mail.retries.*} is absent entirely,
* rather than binding to {@code null}.
*/
public record RetryProperties(
@DefaultValue("3") int maxAttempts,
@DefaultValue("2s") Duration backoff) {
}
}

View File

@@ -0,0 +1,16 @@
package com.ankurm.configprops.props;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* One property, bound by the relaxed binder, used to prove which spellings actually work.
*
* <p>The canonical form of this property is {@code demo.relaxed.api-key}. The relaxed binder
* accepts several spellings of that name; {@code @Value} accepts exactly one. The matrix in
* {@code docs/02-relaxed-binding.md} is generated by
* {@code com.ankurm.configprops.web.RelaxedBindingProbe}, not written by hand.
*/
@ConfigurationProperties(prefix = "demo.relaxed")
public record RelaxedProperties(@DefaultValue("(unset)") String apiKey) {
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.configprops.props;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
/**
* Validation is the capability {@code @Value} does not have at all.
*
* <p>{@code @Validated} plus JSR-380 constraints turns a bad value into a startup failure with
* a readable report, instead of a {@code NumberFormatException} somewhere in a request three
* hours later. The failure text this produces is captured in
* {@code docs/output/04-validation-failure.txt}.
*
* <p>{@code @Valid} on the nested component is written here because it is conventional, but
* on this path it is not what makes the nested constraints run. Spring Boot's
* {@code ValidationBindHandler} validates every object the binder finishes constructing, so
* {@code demo.validated.pool.size} is checked with or without it. The rule that nested types
* need {@code @Valid} comes from ordinary bean validation, where cascading is opt-in, and it
* gets repeated about {@code @ConfigurationProperties} where it does not apply.
* {@code BindingContractTests.nestedConstraintsFireWithoutValid} pins this. See
* {@code docs/05-validation.md}.
*/
@Validated
@ConfigurationProperties(prefix = "demo.validated")
public record ValidatedProperties(
@NotBlank String name,
@Min(1) @Max(65535) int port,
@Pattern(regexp = "https?://.*") @DefaultValue("http://localhost") String endpoint,
@Valid @DefaultValue Pool pool) {
public record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
}
}

View File

@@ -0,0 +1,98 @@
package com.ankurm.configprops.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.configprops.props.MailProperties;
import com.ankurm.configprops.props.ValidatedProperties;
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.boot.origin.Origin;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints where a value actually came from, instead of only what it is.
*
* <p>"The property is set but the application does not see it" is the single most common
* configuration bug, and the reason it is hard is that a value carries no visible provenance.
* Spring Boot tracks it anyway: every property loaded from a file or a config tree carries an
* {@link Origin} naming the file and the line. This endpoint surfaces it.
*
* <p>Documented in {@code docs/08-diagnosing-a-value.md}. Delete it before shipping &mdash; it
* will happily print a password that came from a mounted secret.
*/
@RestController
public class BindingDiagnosticsEndpoint {
private final ConfigurableEnvironment environment;
private final MailProperties mail;
private final ValidatedProperties validated;
private final ValueHolder valueHolder;
public BindingDiagnosticsEndpoint(ConfigurableEnvironment environment, MailProperties mail,
ValidatedProperties validated, ValueHolder valueHolder) {
this.environment = environment;
this.mail = mail;
this.validated = validated;
this.valueHolder = valueHolder;
}
/**
* Every property source that can supply the given name, in precedence order, with the
* value each one holds and where that value was written.
*
* <p>The first row is the winner. Rows below it are values that exist and lose &mdash;
* which is what a "my change had no effect" bug looks like from the inside.
*/
@GetMapping("/diag/origin")
public Map<String, Object> origin(
@RequestParam(defaultValue = "demo.mail.host") String name) {
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
List<Map<String, Object>> candidates = 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("source", String.valueOf(source.getUnderlyingSource()));
row.put("value", String.valueOf(property.getValue()));
row.put("origin", String.valueOf(property.getOrigin()));
candidates.add(row);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("property", name);
result.put("effectiveValue", environment.getProperty(name));
result.put("candidatesInPrecedenceOrder", candidates);
result.put("shadowedBy", candidates.size() > 1 ? candidates.getFirst().get("source") : null);
return result;
}
/** The bound objects, so the article can show a record's contents rather than describe them. */
@GetMapping("/diag/bound")
public Map<String, Object> bound() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("mail", mail);
result.put("validated", validated);
Map<String, Object> fromValue = new LinkedHashMap<>();
fromValue.put("host", valueHolder.host());
fromValue.put("port", valueHolder.port());
fromValue.put("timeout", valueHolder.timeout().toString());
fromValue.put("recipients", valueHolder.recipients());
fromValue.put("poolSize", valueHolder.poolSize());
fromValue.put("computedThreads", valueHolder.computedThreads());
result.put("fromValueAnnotation", fromValue);
return result;
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.configprops.web;
import com.ankurm.configprops.props.RelaxedProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* The same question as {@link RelaxedBindingProbe}, but asked of a real process with a real
* operating-system environment variable rather than a synthesised property source.
*
* <p>{@code scripts/demo-env-binding.sh} runs this once per spelling. It exists because the
* synthesised answer was surprising enough to be worth re-checking against reality: the
* spelling most articles recommend does not bind.
*
* <p>{@code @Value} here carries a default so that a miss is reported rather than crashing
* the process &mdash; the difference between the two columns is the point.
*/
@Component
@Profile("envprobe")
public class RealEnvironmentProbe implements CommandLineRunner {
private final RelaxedProperties bound;
private final Environment environment;
@Value("${demo.relaxed.api-key:(MISS)}")
private String viaValue;
public RealEnvironmentProbe(RelaxedProperties bound, Environment environment) {
this.bound = bound;
this.environment = environment;
}
@Override
public void run(String... args) {
System.out.println("env-var-set : " + describeEnv());
System.out.println("@ConfigurationProps: " + bound.apiKey());
System.out.println("@Value : " + viaValue);
System.out.println("Environment.get : "
+ environment.getProperty("demo.relaxed.api-key", "(MISS)"));
}
private String describeEnv() {
StringBuilder sb = new StringBuilder();
System.getenv().forEach((k, v) -> {
if (k.toUpperCase().startsWith("DEMO_RELAXED") || k.toUpperCase().startsWith("DEMO.")) {
sb.append(k).append('=').append(v).append(' ');
}
});
return sb.isEmpty() ? "(none)" : sb.toString().trim();
}
}

View File

@@ -0,0 +1,150 @@
package com.ankurm.configprops.web;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.configprops.props.RelaxedProperties;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import org.springframework.stereotype.Component;
/**
* Generates the relaxed-binding matrix in {@code docs/02-relaxed-binding.md} by actually
* binding each spelling, rather than by quoting the reference documentation.
*
* <p>For every spelling of the canonical property {@code demo.relaxed.api-key} the probe asks
* three questions, and the three answers are not the same:
*
* <ol>
* <li><strong>BINDER</strong> &mdash; does {@link Binder}, the engine behind
* {@code @ConfigurationProperties}, resolve it?</li>
* <li><strong>${} plain</strong> &mdash; does {@code ${demo.relaxed.api-key}} resolve against
* a bare {@link StandardEnvironment}? This is placeholder resolution as the Spring
* Framework alone defines it.</li>
* <li><strong>${} boot</strong> &mdash; does the same placeholder resolve once
* {@link ConfigurationPropertySources#attach} has been called? Spring Boot calls it on
* every environment it prepares, which quietly gives {@code @Value} relaxed resolution
* that plain Spring does not have.</li>
* </ol>
*
* <p>That third column is the one worth knowing about. "{@code @Value} does not support relaxed
* binding" is repeated everywhere and is true of the Spring Framework; inside a Spring Boot
* application it is not, and the difference is one method call made on your behalf during
* environment preparation.
*
* <p>Two harness details were paid for in wrong answers and are worth stating:
* the system-environment source must be <em>named</em>
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME} or Boot maps it with the
* ordinary rules, and a bare {@code StandardEnvironment} is not what a Boot application runs
* with. Both produced plausible, publishable, incorrect results before being checked against a
* real process by {@code scripts/demo-env-binding.sh}.
*
* <p>Activated by the {@code probe} profile so it does not run during the other demos.
*/
@Component
@Profile("probe")
public class RelaxedBindingProbe implements CommandLineRunner {
/** The one canonical name. Everything else in the matrix is a spelling of this. */
private static final String CANONICAL = "demo.relaxed.api-key";
private static final List<String> MAP_SPELLINGS = List.of(
"demo.relaxed.api-key", // kebab-case, the canonical form
"demo.relaxed.apiKey", // camelCase
"demo.relaxed.api_key", // underscore notation
"demo.relaxed.APIKEY", // upper case, no separator
"DEMO.RELAXED.API-KEY", // upper case with separators
"demo.relaxed.apikey", // no separator at all
"demo.relaxed.api.key"); // dot as separator -- a DIFFERENT property
private static final List<String> ENV_SPELLINGS = List.of(
"DEMO_RELAXED_API_KEY", // underscore for every separator -- the safe form
"DEMO_RELAXED_APIKEY", // word joined up
"demo_relaxed_api_key", // lower case underscores
"DEMO.RELAXED.API-KEY"); // dots and dashes, which most shells reject anyway
@Override
public void run(String... args) {
System.out.println("== relaxed binding matrix ==");
System.out.println("canonical property: " + CANONICAL);
System.out.println();
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n",
"SPELLING", "SOURCE", "BINDER", "${} plain", "${} boot");
System.out.println("-".repeat(62));
for (String spelling : MAP_SPELLINGS) {
report(spelling, "map", () -> mapEnvironment(spelling));
}
for (String spelling : ENV_SPELLINGS) {
report(spelling, "env", () -> envEnvironment(spelling));
}
System.out.println();
System.out.println("BINDER = what @ConfigurationProperties sees (Binder)");
System.out.println("${} plain = placeholder resolution against a bare StandardEnvironment");
System.out.println("${} boot = the same, after ConfigurationPropertySources.attach(env),");
System.out.println(" which Spring Boot performs on every environment it prepares");
System.out.println("bound = the value arrived; MISS = the property was not found");
}
private void report(String spelling, String kind,
java.util.function.Supplier<StandardEnvironment> factory) {
StandardEnvironment forBinder = factory.get();
BindResult<RelaxedProperties> result =
Binder.get(forBinder).bind("demo.relaxed", RelaxedProperties.class);
String binder = result.isBound() && !"(unset)".equals(result.get().apiKey())
? "bound" : "MISS";
String plain = placeholder(factory.get());
StandardEnvironment attached = factory.get();
ConfigurationPropertySources.attach(attached);
String boot = placeholder(attached);
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n", spelling, kind, binder, plain, boot);
}
private String placeholder(StandardEnvironment environment) {
try {
environment.resolveRequiredPlaceholders("${" + CANONICAL + "}");
return "bound";
} catch (IllegalArgumentException ex) {
return "MISS";
}
}
/** An environment whose only property source behaves like a properties or YAML file. */
private StandardEnvironment mapEnvironment(String spelling) {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(
new MapPropertySource("probe", Map.of(spelling, "from-" + spelling)));
return environment;
}
/**
* An environment whose only property source is a {@link SystemEnvironmentPropertySource}.
*
* <p>The source <strong>must</strong> carry the name
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME}: Spring Boot decides
* which name mapper applies by comparing the source's name against that constant, not by
* checking its type. A {@code SystemEnvironmentPropertySource} called anything else is
* mapped with the ordinary rules and the underscore spellings stop resolving.
*/
private StandardEnvironment envEnvironment(String spelling) {
StandardEnvironment environment = new StandardEnvironment();
Map<String, Object> entries = new LinkedHashMap<>();
entries.put(spelling, "from-" + spelling);
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource(
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, entries));
return environment;
}
}

View File

@@ -0,0 +1,66 @@
package com.ankurm.configprops.web;
import java.time.Duration;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* The {@code @Value} side of the comparison, written the way people actually write it.
*
* <p>Everything here works. The point of the class is what it costs to make it work, and what
* it still cannot do:
*
* <ul>
* <li>Every field repeats the property name as a string literal. Rename the property and the
* compiler says nothing.</li>
* <li>The name has to be the exact canonical form. {@code ${demo.mail.apiKey}} does not find
* {@code demo.mail.api-key} &mdash; proved by
* {@link RelaxedBindingProbe}.</li>
* <li>There is no validation. {@code :-1} below is accepted silently.</li>
* <li>A missing property without a {@code :default} is a startup failure whose message names
* the field, not the property's purpose.</li>
* </ul>
*
* <p>What {@code @Value} does have that binding does not: SpEL. {@code #{...}} can compute a
* value; {@code @ConfigurationProperties} only maps one. That is the honest reason to keep it.
* See {@code docs/06-when-value-still-wins.md}.
*/
@Component
public class ValueHolder {
/** Exact canonical name required -- no relaxed spelling is accepted here. */
@Value("${demo.mail.host}")
private String host;
/** The ":" default is the only defaulting mechanism @Value has. */
@Value("${demo.mail.port:587}")
private int port;
/** Conversion works: the ConversionService is shared with the binder. */
@Value("${demo.mail.timeout:30s}")
private Duration timeout;
/**
* A comma-separated string splits into a List. A YAML block list does NOT --
* that is one of the concrete gaps captured in docs/output/03-value-vs-binding.txt.
*/
@Value("${demo.mail.recipients:}")
private List<String> recipients;
/** No constraint is applied. A negative pool size is simply a negative pool size. */
@Value("${demo.validated.pool.size:-1}")
private int poolSize;
/** SpEL -- the capability @ConfigurationProperties genuinely does not have. */
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
private int computedThreads;
public String host() { return host; }
public int port() { return port; }
public Duration timeout() { return timeout; }
public List<String> recipients() { return recipients; }
public int poolSize() { return poolSize; }
public int computedThreads() { return computedThreads; }
}

View File

@@ -0,0 +1,12 @@
demo:
validated:
# @NotBlank -- blank is exactly what it rejects.
name: " "
# @Min(1) @Max(65535)
port: 99999
# @Pattern(regexp = "https?://.*")
endpoint: "ftp://files.example.com"
pool:
# @Min(1) @Max(100) on a nested record. Only checked because the component
# carries @Valid; without it this passes.
size: 4000

View File

@@ -0,0 +1,7 @@
# Overrides the YAML block list in application.yaml with a comma-separated string.
# demo.csvlist.loaded is a marker so the transcript can prove this file was read.
demo:
csvlist:
loaded: "yes"
mail:
recipients: "ops@example.com,oncall@example.com,sre@example.com"

View File

@@ -0,0 +1,37 @@
spring:
application:
name: configuration-properties
server:
port: 8080
logging:
level:
root: WARN
com.ankurm: INFO
demo:
mail:
host: smtp.example.com
# port omitted on purpose: @DefaultValue("587") supplies it.
timeout: 45s
retries:
max-attempts: 5
backoff: 1500ms
# A YAML block list. @ConfigurationProperties binds this; @Value("${demo.mail.recipients}")
# does not -- see docs/output/03-value-vs-binding.txt.
recipients:
- ops@example.com
- oncall@example.com
headers:
X-Env: demo
X-Team: platform
# demo.relaxed.api-key is deliberately NOT set here. scripts/demo-env-binding.sh supplies
# it as an operating-system environment variable, one spelling at a time, and a value in
# this file would mask the env var and make every row of the matrix say "bound".
validated:
name: demo-service
port: 8443
endpoint: https://api.example.com
pool:
size: 25

View File

@@ -0,0 +1,160 @@
package com.ankurm.configprops;
import java.time.Duration;
import java.util.Map;
import com.ankurm.configprops.props.MailProperties;
import com.ankurm.configprops.props.RelaxedProperties;
import com.ankurm.configprops.props.ValidatedProperties;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Contract tests for the claims the article makes. Each one pins a behaviour that would
* otherwise be an assertion in prose.
*/
class BindingContractTests {
private static Binder binderFor(Map<String, Object> properties) {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource("test", properties));
return Binder.get(environment);
}
@Test
@DisplayName("kebab-case, camelCase, underscores and no separator are all the same property")
void relaxedSpellingsAreEquivalent() {
for (String spelling : new String[] {
"demo.relaxed.api-key", "demo.relaxed.apiKey",
"demo.relaxed.api_key", "demo.relaxed.apikey", "demo.relaxed.APIKEY" }) {
RelaxedProperties bound = binderFor(Map.of(spelling, "v"))
.bind("demo.relaxed", RelaxedProperties.class).get();
assertThat(bound.apiKey()).as(spelling).isEqualTo("v");
}
}
@Test
@DisplayName("a dot is a name separator, so api.key is a different property from api-key")
void dotIsNotAWordSeparator() {
// Nothing under the prefix matches, so the bind produces no result at all. Note this
// is *not* an all-defaults object: @DefaultValue applies to a component of an object
// that is being constructed, and here no object is constructed.
assertThat(binderFor(Map.of("demo.relaxed.api.key", "v"))
.bind("demo.relaxed", RelaxedProperties.class).isBound()).isFalse();
}
@Test
@DisplayName("a record binds through its canonical constructor with no annotation")
void recordConstructorBinding() {
MailProperties bound = binderFor(Map.of(
"demo.mail.host", "smtp.test",
"demo.mail.retries.max-attempts", "9"))
.bind("demo.mail", MailProperties.class).get();
assertThat(bound.host()).isEqualTo("smtp.test");
assertThat(bound.port()).as("@DefaultValue supplied it").isEqualTo(587);
assertThat(bound.timeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(bound.retries().maxAttempts()).isEqualTo(9);
assertThat(bound.retries().backoff()).as("nested default").isEqualTo(Duration.ofSeconds(2));
assertThat(bound.recipients()).isEmpty();
}
@Test
@DisplayName("a component with no value and no @DefaultValue binds to null, not to a failure")
void absentPropertyWithoutDefault() {
MailProperties bound = binderFor(Map.of("demo.mail.port", "25"))
.bind("demo.mail", MailProperties.class).get();
assertThat(bound.host()).isNull();
}
@Test
@DisplayName("@Validated turns a bad value into a BindValidationException")
void validationRejectsBadValues() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> binderFor(Map.of(
"demo.validated.name", "ok",
"demo.validated.port", "99999"))
.bind("demo.validated", org.springframework.boot.context.properties.bind
.Bindable.of(ValidatedProperties.class),
new org.springframework.boot.context.properties.bind.validation
.ValidationBindHandler(validator()))
.get())
.withCauseInstanceOf(BindValidationException.class);
}
/**
* Contradicts a widely repeated rule.
*
* <p>The advice "put {@code @Valid} on nested {@code @ConfigurationProperties} types or
* their constraints are ignored" is true of ordinary bean validation, where cascading is
* opt-in. It is not true of the binder. {@code ValidationBindHandler} validates every
* object it finishes binding, nested ones included, so the constraint below fires with no
* {@code @Valid} anywhere in the type.
*
* <p>This test was originally written to assert the opposite and failed, which is how the
* article came to say the opposite of what most of its neighbours say.
*/
@Test
@DisplayName("nested constraints are evaluated even without @Valid")
void nestedConstraintsFireWithoutValid() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> binderFor(Map.of("demo.unchecked.pool.size", "4000"))
.bind("demo.unchecked", org.springframework.boot.context.properties.bind
.Bindable.of(Unchecked.class),
new org.springframework.boot.context.properties.bind.validation
.ValidationBindHandler(validator()))
.get())
.withCauseInstanceOf(BindValidationException.class)
.withMessageContaining("demo.unchecked");
}
@Test
@DisplayName("Boot's attached property source gives ${} the same relaxed rules as the binder")
void attachGivesPlaceholdersRelaxedResolution() {
StandardEnvironment plain = new StandardEnvironment();
plain.getPropertySources()
.addFirst(new MapPropertySource("test", Map.of("demo.relaxed.apiKey", "v")));
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
.as("plain Spring: no relaxed placeholder resolution")
.isEqualTo("MISS");
ConfigurationPropertySources.attach(plain);
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
.as("Spring Boot attaches this source on every environment it prepares")
.isEqualTo("v");
}
private static LocalValidatorFactoryBean validator() {
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
factory.afterPropertiesSet();
return factory;
}
/** Deliberately missing {@code @Valid} on the nested component. */
@Validated
@ConfigurationProperties(prefix = "demo.unchecked")
record Unchecked(@DefaultValue Pool pool) {
record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
}
}
}

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");
}
}
}

67
spring-aop/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Spring AOP: designators, proxies, and aspects that do not fire
Companion project for [**Spring AOP Explained**](https://ankurm.com/) on ankurm.com.
Two halves. One set of aspects that work — one advice per pointcut designator, so the reference
table is generated from real matches. One set that does not fire, each for a different reason,
each paired with its fix.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| AspectJ weaver | 1.9.25.1 |
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
## Read this before copying a dependency block
In Spring Boot 3 the starter was `spring-boot-starter-aop`. In Spring Boot 4 it is
**`spring-boot-starter-aspectj`**. The old artifact's last publication is `4.0.0-M2` and its
last GA is `3.5.16`, so a pre-4 dependency block fails resolution with a missing-version error.
## 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
```
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /aop/proxies` | per bean: proxied or not, which kind, and the advisor list |
| `GET /aop/designators` | exercises every advised method, reports what each designator matched |
| `GET /aop/parser` | feeds supported and unsupported expressions to the pointcut parser |
| `GET /aop/broken` | runs the failure gallery and reports that nothing fired |
## Documentation
1. [What Spring AOP actually is](docs/01-what-spring-aop-is.md)
2. [The designator reference](docs/02-designators.md)
3. [JDK dynamic proxies and CGLIB](docs/03-proxy-types.md)
4. [Advice types and ordering](docs/04-advice-types.md)
5. [Six aspects that do not fire](docs/05-broken-aspect-gallery.md)
6. [Diagnosing a silent aspect](docs/06-diagnosing-a-silent-aspect.md)
## Captured output
| File | Produced by |
|---|---|
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
| [`01-designators.txt`](docs/output/01-designators.txt) | `scripts/demo-designators.sh` |
| [`02-proxy-types.txt`](docs/output/02-proxy-types.txt) | `scripts/demo-proxy-types.sh` |
| [`03-broken-gallery.txt`](docs/output/03-broken-gallery.txt) | `scripts/demo-broken-gallery.sh` |
## Two corrections to the reference documentation
- Unsupported designators throw **`UnsupportedPointcutPrimitiveException`**, which extends
`RuntimeException` directly — not `IllegalArgumentException` as the docs state. Catching the
documented type will not catch it.
- The framework default is interface-based proxying, but **Spring Boot sets
`proxy-target-class=true`**, so you get CGLIB even for beans that implement interfaces. That
changes which designators match; `scripts/demo-proxy-types.sh` shows both.

View File

@@ -0,0 +1,51 @@
[Index](../README.md) &middot; [Designators &rarr;](02-designators.md)
# 1. What Spring AOP actually is
Spring AOP is a **proxy** mechanism that borrows AspectJ's **pointcut language**. Both halves
of that sentence explain a failure mode.
## It is proxies
Spring does not modify your bytecode. When a bean matches a pointcut, the container puts a
proxy in the bean registry in its place. Callers get the proxy; the proxy runs advice and then
forwards to the real object.
Everything that follows from this:
- Only **beans** can be advised. An object created with `new` has no proxy.
- Only calls **through the proxy** are intercepted. A call the object makes to itself is not.
- Only **overridable** methods can be advised: not private, not final, not static.
- Join points are **method executions**. Field access, constructor calls and exception handlers
are not available, no matter what the pointcut language allows you to write.
## It borrows the pointcut language
`aspectjweaver` on the classpath supplies the pointcut *parser*. Spring uses it to decide which
methods match and then does its own weaving with proxies. AspectJ's own weaver is not involved.
This is why the designator list is a subset: the language can express `call()` and `cflow()`,
and a proxy cannot implement them. See [chapter 2](02-designators.md).
## The Spring Boot 4 starter rename
In Spring Boot 3 the dependency was `spring-boot-starter-aop`. In Spring Boot 4 it is
**`spring-boot-starter-aspectj`**.
```
spring-boot-starter-aop last published 4.0.0-M2 (last GA: 3.5.16)
spring-boot-starter-aspectj first published 4.0.0-M3
```
The contents are unchanged: `spring-boot-starter`, `spring-aop`, `aspectjweaver`. But the old
artifact is no longer in the Boot BOM, so copying a dependency block out of any pre-4 tutorial
fails resolution with a missing-version error rather than a helpful message.
Without the starter, `@Aspect` classes are ordinary beans, no pointcut is ever parsed, and
every aspect in the application silently matches nothing.
## When to use something else
If you need to advise field access, constructors, or calls between objects you do not own, you
need real AspectJ weaving (compile-time or load-time), not Spring AOP. If you need to advise
one internal call, you need to refactor — see [chapter 5](05-broken-aspect-gallery.md).

View File

@@ -0,0 +1,86 @@
[&larr; What Spring AOP is](01-what-spring-aop-is.md) &middot; [Index](../README.md) &middot; [Proxy types &rarr;](03-proxy-types.md)
# 2. The designator reference
Generated by [`DesignatorAspect`](../src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java)
and [`PointcutParserEndpoint`](../src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java).
Transcript: [`01-designators.txt`](output/01-designators.txt).
## Supported
| Designator | Matches on | Evaluated | Cost |
|---|---|---|---|
| `execution(...)` | method signature | statically | cheap |
| `within(Type)` | the declaring type | statically | cheap |
| `this(Type)` | the **proxy**'s type | at runtime | per call |
| `target(Type)` | the **target object**'s type | at runtime | per call |
| `args(Types)` | argument runtime types; can bind | at runtime | per call |
| `@target(Ann)` | annotation on the executing object's class | at runtime | per call |
| `@args(Ann)` | annotation on argument runtime types | at runtime | per call |
| `@within(Ann)` | annotation on the declaring type | statically | cheap |
| `@annotation(Ann)` | annotation on the method | statically | cheap |
| `bean(name)` | Spring bean name, wildcards allowed | — | cheap |
`bean(...)` is Spring's own; it does not exist in AspectJ.
The runtime group cannot be decided from the signature alone, so Spring must check on every
candidate invocation. Prefer the static equivalent where one exists: `@within` instead of
`@target`, `within` instead of `this`, when the distinction does not matter.
## What each one actually matched here
```
execution (full signature) -> DefaultOrderService.place(..)
execution (wildcards) -> DefaultOrderService.cancel(..)
within -> InventoryService.reserve(..)
this(OrderService) -> place, cancel, interfaceless
target(DefaultOrderService) -> place, cancel, interfaceless
args (bound: SKU-1/2) -> DefaultOrderService.place(..)
@target(Audited) -> DefaultOrderService.cancel(..)
@args(Trackable) -> DefaultOrderService.interfaceless(..)
@within(Audited) -> DefaultOrderService.place(..)
@annotation(Marker) -> DefaultOrderService.place(..)
bean(inventoryService) -> InventoryService.reserve(..)
bean(*OrderService) -> place, cancel, interfaceless
```
`InventoryService.finalCheck` was called and appears nowhere: it is `final`, so no proxy could
override it. That is [failure 4](05-broken-aspect-gallery.md).
## Not supported
`call`, `get`, `set`, `preinitialization`, `staticinitialization`, `initialization`, `handler`,
`adviceexecution`, `withincode`, `cflow`, `cflowbelow`, `if`, `@this`, `@withincode`.
All fourteen were fed to the parser. All fourteen were rejected, with:
```
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
Pointcut expression 'call(* ...place(..))' contains unsupported pointcut primitive 'call'
```
**The reference documentation says these throw `IllegalArgumentException`. They do not.**
`UnsupportedPointcutPrimitiveException extends RuntimeException` directly — checked with
`javap`, pinned by `AopContractTests.unsupportedDesignatorExceptionType`. A
`catch (IllegalArgumentException)` will not catch it.
## When the failure happens
`setExpression(...)` only stores the string. The expression is not parsed or validated until
something asks it to match. An unsupported designator therefore fails at the first candidate
invocation, not at startup — so a rarely-exercised aspect can ship broken.
## Combining and naming
`&&`, `||` and `!` compose designators. Name the result rather than repeating it:
```java
@Pointcut("within(com.ankurm.aop.service..*)")
public void inServiceLayer() {}
@Before("this(OrderService) && inServiceLayer()")
public void advice(JoinPoint jp) { }
```
A named pointcut is referenced by its method name and is the single biggest readability win
available here.

View File

@@ -0,0 +1,78 @@
[&larr; Designators](02-designators.md) &middot; [Index](../README.md) &middot; [Advice types &rarr;](04-advice-types.md)
# 3. JDK dynamic proxies and CGLIB
Transcript: [`02-proxy-types.txt`](output/02-proxy-types.txt).
| | JDK dynamic proxy | CGLIB |
|---|---|---|
| Built by | `java.lang.reflect.Proxy` | subclassing the target |
| Requires | at least one interface | a non-final class |
| Proxy is an instance of | the interfaces only | the target class |
| Can advise | public interface methods | public, protected, package-private |
| Cannot advise | anything not on the interface | `final`, `private`, `static` |
| Class name | `$Proxy62` | `Foo$$SpringCGLIB$$0` |
## Spring Boot chooses CGLIB
The Spring Framework's own default is interface-based when an interface exists. **Spring Boot
sets `spring.aop.proxy-target-class=true`**, so you get CGLIB either way unless you change it.
Measured, on a bean that *does* implement an interface:
```
defaultOrderService CGLIB subclass class: DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
proxy is an instance of DefaultOrderService : True
```
And with `--spring.aop.proxy-target-class=false`:
```
defaultOrderService JDK dynamic proxy class: $Proxy62
interfaces : OrderService
proxy is an instance of DefaultOrderService : False
```
## What changes when you switch
Same aspects, same beans:
```
CGLIB: this(OrderService) -> place, cancel, interfaceless
JDK: this(OrderService) -> place, cancel
```
`interfaceless(..)` is not on `OrderService`, so under a JDK proxy it is invisible to advice —
and casting the bean to `DefaultOrderService` throws `ClassCastException`.
This is why `this()` and `target()` exist as separate designators. `this()` tests the proxy;
`target()` tests the object behind it. Under CGLIB they nearly always agree, which is exactly
why the distinction only ever bites *after* somebody changes the proxy type.
## CGLIB constraints worth knowing
- A **final class** cannot be proxied at all — this one fails loudly.
- A **final method** is silently not advised. The bean is still a proxy; the method just is not
overridden. This is the quiet one.
- **Private** methods are never advised by either strategy.
- The target's constructor is **not** called twice: Spring creates the proxy instance through
Objenesis. On a JVM that forbids constructor bypassing you may see double invocation and a
debug log line about it.
- On the module path, classes in `java.lang` cannot be proxied without
`--add-opens=java.base/java.lang=ALL-UNNAMED`.
## `@Proxyable`, new in Spring 7.0
Per-bean control, rather than one global switch:
```java
@Proxyable(ProxyType.INTERFACES)
@Bean
MyService myService() { ... }
```
Verified against the class file rather than the docs:
`org.springframework.context.annotation.Proxyable`, targets `TYPE` and `METHOD`, with
`ProxyType value()` (`DEFAULT`, `INTERFACES`, `TARGET_CLASS`) and `Class<?>[] interfaces()`.
Note the package — it is in `spring-context`, not `spring-aop`.

View File

@@ -0,0 +1,56 @@
[&larr; Proxy types](03-proxy-types.md) &middot; [Index](../README.md) &middot; [Broken aspect gallery &rarr;](05-broken-aspect-gallery.md)
# 4. Advice types and ordering
## The five
| Annotation | Runs | Can it stop the call? | Can it change the result? |
|---|---|---|---|
| `@Before` | before | only by throwing | no |
| `@AfterReturning` | after a normal return | no | no (can read it) |
| `@AfterThrowing` | after an exception | no | no (can read it) |
| `@After` | after either | no | no |
| `@Around` | wraps | yes | yes |
`@Around` is the only one that receives a `ProceedingJoinPoint` and therefore the only one that
can decide whether the target runs at all, retry it, cache around it, or replace its result.
```java
@Around("@annotation(Timed)")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
record(pjp.getSignature(), System.nanoTime() - start);
}
}
```
Two rules for `@Around`: it must return `Object` (or a compatible type) and it must actually
call `proceed()`. Forgetting `proceed()` silently turns every advised method into a method that
returns `null` and never runs — a failure that looks like the target method being broken.
## Ordering
Within one aspect, advice order for the same join point is **not** guaranteed and is not the
source order. If two pieces of advice in one aspect must be ordered, split them into two
aspects.
Between aspects, `@Order` or `Ordered` decides. Lower value = higher precedence = outermost.
Advice nests: the outermost aspect's `@Before` runs first and its `@After` runs last.
```
@Order(1) Tx : before ... [ @Order(2) Logging : before ... target ... after ] ... after
```
This matters when combining transactions with anything that must be inside or outside them.
`@Transactional` is ordered at `Ordered.LOWEST_PRECEDENCE` by default, so almost everything
else wraps *outside* it — meaning your logging advice sees the method return before the
transaction commits, and therefore before a commit failure has happened.
## `ExposeInvocationInterceptor`
You will see this at the head of every advisor chain in
[`02-proxy-types.txt`](output/02-proxy-types.txt). Spring adds it automatically so that
`AopContext.currentProxy()` and argument binding work. It is not something you configured.

View File

@@ -0,0 +1,84 @@
[&larr; Advice types](04-advice-types.md) &middot; [Index](../README.md) &middot; [Diagnosing &rarr;](06-diagnosing-a-silent-aspect.md)
# 5. Six aspects that do not fire
Sources: [`BrokenAspects`](../src/main/java/com/ankurm/aop/broken/BrokenAspects.java),
[`SelfInvokingService`](../src/main/java/com/ankurm/aop/broken/SelfInvokingService.java),
[`NewedUpService`](../src/main/java/com/ankurm/aop/broken/NewedUpService.java).
Transcript: [`03-broken-gallery.txt`](output/03-broken-gallery.txt).
None of these warn. None fail at startup. All of them look correct in review.
## 1. `@Aspect` without `@Component`
`@Aspect` is an AspectJ annotation. It tells Spring how to interpret a bean it already has; it
does not create one. Without a stereotype or an `@Bean` method, the class is never instantiated
and the pointcut is never registered.
The most common cause, and the most invisible — from the container's point of view nothing was
ever requested, so there is nothing to warn about.
**Fix:** add `@Component`.
## 2. A pointcut that matches nothing
```java
@Before("execution(* com.ankurm.aop.services.*.*(..))") // "services", plural
```
A package name matching no type is not an error; it is an empty match set. At runtime this is
indistinguishable from an aspect that was never registered.
**Fix:** assert on it. `AspectJExpressionPointcut#matches(Method, Class)` in a unit test is two
lines and catches every typo permanently.
## 3. A private method
Neither proxy strategy can override a private method, so neither can intercept it. The
annotation is legal and inert.
**Fix:** make it at least package-private *and* call it from outside the object — visibility
alone is not enough if the call is internal, which brings you to number 5.
## 4. A final method
CGLIB proxies by subclassing. A final method is inherited rather than overridden, so calls go
straight to the original. A final *class* fails loudly; a final *method* is silent.
Note `beanIsProxied: true` in the transcript. The bean is proxied. This one method is not.
**Fix:** remove `final`, or proxy by interface.
## 5. Self-invocation
The expensive one, because the code looks right and the annotation is visible.
```json
"5-self-invocation": {
"beanIsProxied": true,
"innerAdvisedWhenCalledFromOuter": false,
"innerAdvisedWhenCalledDirectly": true
}
```
Same method, same advice. Called through the proxy it is advised; reached by `this.inner()`
from another method of the same object it is not, because the proxy is not in that call path.
This is the same mechanism that makes `@Transactional` and `@Cacheable` silently do nothing on
internal calls. Learning it once here saves learning it three times.
**Fixes, best first:**
1. Move the method to another bean. This is almost always the right answer, and the resulting
design is usually better anyway.
2. Inject the bean into itself and call through that reference.
3. `AopContext.currentProxy()` with `exposeProxy = true`. Works; couples your code to Spring
AOP and makes the class aware it is proxied.
## 6. An object created with `new`
Spring AOP advises beans. An instance built by a factory, a helper or a test has no proxy and
never will.
**Fix:** get it from the container. If it genuinely must be constructed by hand and still
advised, that is what AspectJ load-time weaving is for.

View File

@@ -0,0 +1,75 @@
[&larr; Broken aspect gallery](05-broken-aspect-gallery.md) &middot; [Index](../README.md)
# 6. Diagnosing a silent aspect
Endpoint: [`AopDiagnosticsEndpoint`](../src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java).
Almost every non-firing aspect is one of three things, and all three are visible in one place.
## The three questions, in order
**1. Is the bean proxied at all?**
```java
AopUtils.isAopProxy(bean)
```
`false` means no pointcut matched this bean, or it is not a bean. Stop here and check the
pointcut and the registration.
**2. Which kind of proxy?**
```java
AopUtils.isJdkDynamicProxy(bean) // interfaces only
AopUtils.isCglibProxy(bean) // subclass
```
If it is a JDK proxy and your method is not on an interface, that is your answer.
**3. Is your advice in the advisor list?**
```java
if (bean instanceof Advised advised) {
Arrays.stream(advised.getAdvisors())
.map(a -> a.getAdvice().getClass().getSimpleName())
.forEach(System.out::println);
}
```
Proxied, right kind, and your advice missing means the pointcut matched the *bean* but not the
*method*.
## Output
```
defaultOrderService CGLIB subclass target=DefaultOrderService
class : DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
advisors : 11
```
## Testing a pointcut without an application
The fastest check of all, and it belongs in a test rather than in a diagnostic endpoint:
```java
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.example.service.*.*(..))");
assertThat(pointcut.matches(Foo.class.getMethod("bar"), Foo.class)).isTrue();
```
Remember that `setExpression` does not parse; the parse happens on first `matches`. So this is
also how you find out that a designator is unsupported before production does.
## Logging
```
logging.level.org.springframework.aop=DEBUG
```
reports proxy creation per bean. Verbose, but it answers question 1 for every bean at once.
## Delete the endpoint before shipping
It reports internal wiring. If you want it permanently, put it behind the management port and
authentication.

View File

@@ -0,0 +1,11 @@
== versions ==
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
spring-boot-starter-parent: 4.1.1
aspectjweaver: 1.9.25.1
== the Boot 4 starter rename ==
spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)
spring-boot-starter-aspectj first published: 4.0.0-M3

View File

@@ -0,0 +1,59 @@
== which designator matched which join point ==
Five methods are called once each: OrderService.place, OrderService.cancel,
InventoryService.reserve, InventoryService.finalCheck and
DefaultOrderService.interfaceless.
orderService proxy kind : CGLIB subclass
proxy is an instance of DefaultOrderService : True
args (bound: SKU-1/2) -> DefaultOrderService.place(..)
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
@within(Audited) -> DefaultOrderService.place(..)
execution (full signature) -> DefaultOrderService.place(..)
@annotation(Marker) -> DefaultOrderService.place(..)
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
execution (wildcards) -> DefaultOrderService.cancel(..)
@target(Audited) -> DefaultOrderService.cancel(..)
bean(inventoryService) -> InventoryService.reserve(..)
within -> InventoryService.reserve(..)
@args(Trackable) -> DefaultOrderService.interfaceless(..)
== what the parser accepts and refuses ==
supported (all parsed and evaluated):
execution(* com.ankurm.aop.service.OrderService.place(..)) OK
within(com.ankurm.aop.service..*) OK
this(com.ankurm.aop.service.OrderService) OK
target(com.ankurm.aop.service.DefaultOrderService) OK
args(String, int) OK
@target(com.ankurm.aop.service.Audited) OK
@args(com.ankurm.aop.service.Trackable) OK
@within(com.ankurm.aop.service.Audited) OK
@annotation(com.ankurm.aop.service.Marker) OK
bean(defaultOrderService) OK
unsupported in Spring AOP:
call(* com.ankurm.aop.service.OrderService.place(. rejected
get(* com.ankurm.aop.service.*.*) rejected
set(* com.ankurm.aop.service.*.*) rejected
initialization(com.ankurm.aop.service.*.new(..)) rejected
staticinitialization(com.ankurm.aop.service.*) rejected
preinitialization(com.ankurm.aop.service.*.new(..) rejected
handler(java.lang.Exception) rejected
adviceexecution() rejected
withincode(* com.ankurm.aop.service.*.*(..)) rejected
cflow(execution(* com.ankurm.aop.service.*.*(..))) rejected
cflowbelow(execution(* com.ankurm.aop.service.*.*( rejected
if() rejected
@this(com.ankurm.aop.service.Audited) rejected
@withincode(com.ankurm.aop.service.Marker) rejected
the exception, in full:
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
Pointcut expression 'call(* com.ankurm.aop.service.OrderService.place(..))' contains unsupported pointcut primitive 'call'
The reference documentation says these produce an IllegalArgumentException. They do
not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a
catch of IllegalArgumentException will not catch it.

View File

@@ -0,0 +1,53 @@
== Spring Boot default: spring.aop.proxy-target-class=true ==
$ java -jar target/spring-aop-demo-1.0.0.jar
defaultOrderService CGLIB subclass target=DefaultOrderService
class : DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
advisors : 11
inventoryService CGLIB subclass target=InventoryService
class : InventoryService$$SpringCGLIB$$0
interfaces : (none)
advisors : 6
selfInvokingService CGLIB subclass target=SelfInvokingService
class : SelfInvokingService$$SpringCGLIB$$0
interfaces : (none)
advisors : 2
proxy is an instance of DefaultOrderService : True
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
== framework default restored: spring.aop.proxy-target-class=false ==
$ java -jar target/spring-aop-demo-1.0.0.jar --spring.aop.proxy-target-class=false
defaultOrderService JDK dynamic proxy target=DefaultOrderService
class : $Proxy62
interfaces : OrderService
advisors : 11
inventoryService CGLIB subclass target=InventoryService
class : InventoryService$$SpringCGLIB$$0
interfaces : (none)
advisors : 6
selfInvokingService CGLIB subclass target=SelfInvokingService
class : SelfInvokingService$$SpringCGLIB$$0
interfaces : (none)
advisors : 2
proxy is an instance of DefaultOrderService : False
this(OrderService) -> OrderService.place(..), OrderService.cancel(..)
target(DefaultOrderService) -> OrderService.place(..), OrderService.cancel(..)
bean(*OrderService) -> OrderService.place(..), OrderService.cancel(..)
Same aspects, same beans, different proxy strategy:
- With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of
the implementation class and methods that are not on the interface are advised.
- With a JDK proxy the proxy implements OrderService only. It is NOT an instance of
DefaultOrderService, casting to that class throws ClassCastException, and any
method absent from the interface is invisible to advice.
This is why this() and target() differ. this() tests the proxy; target() tests the
object behind it. Under CGLIB they usually agree, which is exactly why the
distinction only bites after somebody switches the proxy type.

View File

@@ -0,0 +1,37 @@
== aspects that do not fire ==
{
"5-self-invocation": {
"beanIsProxied": true,
"result": "outer -> inner",
"innerAdvisedWhenCalledFromOuter": false,
"innerAdvisedWhenCalledDirectly": true
},
"6-created-with-new": {
"isProxy": false,
"adviceFired": false
},
"4-final-method": {
"beanIsProxied": true,
"proxyKind": "CGLIB subclass",
"adviceFired": false
},
"1-aspect-without-component-fired": false,
"2-pointcut-typo-fired": false,
"3-private-method-fired": false,
"note": "every value above should be false except the two that prove the method is advisable when reached through the proxy"
}
Reading it:
1 @Aspect without @Component - the class is never instantiated, so the pointcut
is never registered. No warning is produced.
2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and
matches nothing. An empty match set is not an error.
3 private method - cannot be overridden, so cannot be intercepted.
4 final method - CGLIB subclasses; a final method is inherited, not
overridden. Note beanIsProxied is still true.
5 self-invocation - innerAdvisedWhenCalledFromOuter is false and
innerAdvisedWhenCalledDirectly is true. Same method,
same advice: only the call path differs.
6 created with new - no container, no proxy, no advice.

60
spring-aop/pom.xml Normal file
View File

@@ -0,0 +1,60 @@
<?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>spring-aop-demo</artifactId>
<version>1.0.0</version>
<name>spring-aop-demo</name>
<description>Spring AOP: pointcut designators, proxy types, and why aspects do not fire</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- NOTE THE NAME. In Spring Boot 3 this was spring-boot-starter-aop. That artifact was
renamed to spring-boot-starter-aspectj during the Boot 4 milestones: its last
published version is 4.0.0-M2 and its last GA is 3.5.16, while
spring-boot-starter-aspectj starts at 4.0.0-M3. Copying an AOP dependency block out
of any pre-4 tutorial therefore fails resolution, because the old name is no longer
in the Boot BOM and therefore has no managed version.
Contents are unchanged: spring-boot-starter, spring-aop and aspectjweaver. It is
aspectjweaver that supplies the pointcut PARSER: Spring AOP does its own weaving
at runtime with proxies, and borrows only the expression language from AspectJ.
Without it, @Aspect classes are ordinary beans and every pointcut here silently
matches nothing. See docs/01-what-spring-aop-is.md. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</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,28 @@
#!/usr/bin/env bash
# The broken-aspect gallery: six aspects that do not fire, and why.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== aspects that do not fire =="
echo
start_app > /dev/null
curl -s "http://127.0.0.1:${APP_PORT}/aop/broken" | python3 -m json.tool
echo
echo "Reading it:"
echo
echo " 1 @Aspect without @Component - the class is never instantiated, so the pointcut"
echo " is never registered. No warning is produced."
echo " 2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and"
echo " matches nothing. An empty match set is not an error."
echo " 3 private method - cannot be overridden, so cannot be intercepted."
echo " 4 final method - CGLIB subclasses; a final method is inherited, not"
echo " overridden. Note beanIsProxied is still true."
echo " 5 self-invocation - innerAdvisedWhenCalledFromOuter is false and"
echo " innerAdvisedWhenCalledDirectly is true. Same method,"
echo " same advice: only the call path differs."
echo " 6 created with new - no container, no proxy, no advice."
stop_app
} > docs/output/03-broken-gallery.txt 2>&1
cat docs/output/03-broken-gallery.txt

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Every pointcut designator Spring AOP supports, with what it actually matched.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== which designator matched which join point =="
echo
echo "Five methods are called once each: OrderService.place, OrderService.cancel,"
echo "InventoryService.reserve, InventoryService.finalCheck and"
echo "DefaultOrderService.interfaceless."
echo
start_app > /dev/null
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" orderService proxy kind :", d["orderServiceProxyKind"])
print(" proxy is an instance of DefaultOrderService :",
d["orderServiceIsDefaultOrderServiceInstance"])
print()
for k,v in d["matches"].items():
print(" %-32s -> %s" % (k, ", ".join(v)))'
echo
echo "== what the parser accepts and refuses =="
echo
curl -s "http://127.0.0.1:${APP_PORT}/aop/parser" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" supported (all parsed and evaluated):")
for r in d["supported"]:
print(" %-52s %s" % (r["expression"], "OK" if r["accepted"] else "REJECTED"))
print()
print(" unsupported in Spring AOP:")
for r in d["unsupported"]:
print(" %-52s %s" % (r["expression"][:50], "accepted!" if r["accepted"] else "rejected"))
print()
first=[r for r in d["unsupported"] if not r["accepted"]][0]
print(" the exception, in full:")
print(" " + first["exception"])
print(" " + first["message"])'
echo
echo "The reference documentation says these produce an IllegalArgumentException. They do"
echo "not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a"
echo "catch of IllegalArgumentException will not catch it."
stop_app
} > docs/output/01-designators.txt 2>&1
cat docs/output/01-designators.txt

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# JDK dynamic proxies versus CGLIB, and what changes when you switch.
#
# Spring Boot sets spring.aop.proxy-target-class=true by default, so beans are proxied by
# CGLIB even when they implement an interface. Setting it to false restores the framework's
# own default and changes which designators match -- the same aspects, different results.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
snapshot() {
curl -s "http://127.0.0.1:${APP_PORT}/aop/proxies" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %-22s %-18s target=%s" % (r["bean"], r["proxyKind"], r["targetClass"].split(".")[-1]))
print(" class : %s" % r["class"].split(".")[-1])
print(" interfaces : %s" % (", ".join(r.get("proxiedInterfaces") or []) or "(none)"))
print(" advisors : %d" % r.get("advisorCount", 0))'
echo
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" proxy is an instance of DefaultOrderService :",
d["orderServiceIsDefaultOrderServiceInstance"])
for k in ("this(OrderService)","target(DefaultOrderService)","bean(*OrderService)"):
print(" %-30s -> %s" % (k, ", ".join(d["matches"].get(k, ["(no match)"]))))'
}
{
echo "== Spring Boot default: spring.aop.proxy-target-class=true =="
echo "\$ java -jar $JAR"
echo
start_app > /dev/null
snapshot
echo
echo "== framework default restored: spring.aop.proxy-target-class=false =="
echo "\$ java -jar $JAR --spring.aop.proxy-target-class=false"
echo
start_app --spring.aop.proxy-target-class=false > /dev/null
snapshot
echo
echo "Same aspects, same beans, different proxy strategy:"
echo
echo " - With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of"
echo " the implementation class and methods that are not on the interface are advised."
echo " - With a JDK proxy the proxy implements OrderService only. It is NOT an instance of"
echo " DefaultOrderService, casting to that class throws ClassCastException, and any"
echo " method absent from the interface is invisible to advice."
echo
echo "This is why this() and target() differ. this() tests the proxy; target() tests the"
echo "object behind it. Under CGLIB they usually agree, which is exactly why the"
echo "distinction only bites after somebody switches the proxy type."
stop_app
} > docs/output/02-proxy-types.txt 2>&1
cat docs/output/02-proxy-types.txt

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
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/')"
echo "aspectjweaver: $(find ~/.m2/repository -name 'aspectjweaver-*.jar' | sed 's/.*aspectjweaver-//;s/\.jar//' | sort | tail -1)"
echo
echo "== the Boot 4 starter rename =="
echo "spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)"
echo "spring-boot-starter-aspectj first published: 4.0.0-M3"
} > docs/output/00-versions.txt 2>&1
cat docs/output/00-versions.txt

61
spring-aop/scripts/env.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/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/spring-aop-demo-1.0.0.jar"
APP_MAIN="com.ankurm.aop.AopApplication"
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/aop-demo.log 2>&1 < /dev/null &
else
nohup java -jar "$JAR" "$@" > /tmp/aop-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}/aop/proxies" 2>/dev/null && return 0
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
tail -20 /tmp/aop-demo.log; return 1; }
sleep 1
done
echo "application did not answer"; tail -20 /tmp/aop-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 "spring-aop-demo" "/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.

16
spring-aop/scripts/run-all.sh Executable file
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 designators proxy-types broken-gallery; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
stop_app
echo
echo "regenerated:"
ls -1 docs/output/

View File

@@ -0,0 +1,20 @@
package com.ankurm.aop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for the ankurm.com article
* "Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn't Firing".
*
* <p>Two halves. {@code aspect/} and {@code service/} contain aspects that work, one per
* pointcut designator, so the designator reference in the article is generated from real
* matches. {@code broken/} contains aspects that do not fire, each for a different reason,
* each paired with the fix.
*/
@SpringBootApplication
public class AopApplication {
public static void main(String[] args) {
SpringApplication.run(AopApplication.class, args);
}
}

View File

@@ -0,0 +1,141 @@
package com.ankurm.aop.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import com.ankurm.aop.service.Audited;
import com.ankurm.aop.service.Marker;
import com.ankurm.aop.service.OrderService;
import com.ankurm.aop.service.Payload;
import org.springframework.stereotype.Component;
/**
* One advice per pointcut designator Spring AOP supports. Ten designators, ten pieces of
* advice, all on the same small set of beans, so the article's reference table can be
* generated from {@code /aop/designators} rather than transcribed.
*
* <p>The designators fall into three groups worth keeping straight:
* <ul>
* <li><strong>Signature matching</strong> &mdash; {@code execution}, {@code within}.
* Evaluated statically against the method signature.</li>
* <li><strong>Runtime type matching</strong> &mdash; {@code this}, {@code target},
* {@code args}, {@code @target}, {@code @args}. These force a runtime check on every
* candidate call, which is why they cost more than they look like they should.</li>
* <li><strong>Annotation and bean matching</strong> &mdash; {@code @within},
* {@code @annotation}, {@code bean}. The last is Spring's own, not AspectJ's.</li>
* </ul>
*/
@Aspect
@Component
public class DesignatorAspect {
private final MatchRecorder recorder;
public DesignatorAspect(MatchRecorder recorder) {
this.recorder = recorder;
}
/**
* A named pointcut, so the same expression is not repeated in six places. Naming
* pointcuts is the single biggest readability win available in Spring AOP.
*/
@Pointcut("within(com.ankurm.aop.service..*)")
public void inServiceLayer() {
}
// -- signature matching ------------------------------------------------------------
/** Matches method executions by signature. The workhorse; everything else is a filter. */
@Before("execution(public String com.ankurm.aop.service.OrderService.place(String, int))")
public void executionByFullSignature(JoinPoint jp) {
record("execution (full signature)", jp);
}
/** The same designator with wildcards, which is how it is normally written. */
@Before("execution(* com.ankurm.aop.service.*Service.cancel(..))")
public void executionWithWildcards(JoinPoint jp) {
record("execution (wildcards)", jp);
}
/** Limits matching to join points inside a type. Static: no runtime test. */
@Before("within(com.ankurm.aop.service.InventoryService)")
public void withinType(JoinPoint jp) {
record("within", jp);
}
// -- runtime type matching ---------------------------------------------------------
/**
* {@code this} tests the PROXY. With a JDK proxy the proxy implements the interface but
* is not an instance of the implementation class, so {@code this(DefaultOrderService)}
* does not match. With a CGLIB proxy it does, because the proxy is a subclass.
* That difference is measured in {@code docs/output/03-proxy-types.txt}.
*/
@Before("this(com.ankurm.aop.service.OrderService) && inServiceLayer()")
public void thisProxyIsOrderService(JoinPoint jp) {
record("this(OrderService)", jp);
}
/** {@code target} tests the object BEHIND the proxy, so the proxy type is irrelevant. */
@Before("target(com.ankurm.aop.service.DefaultOrderService)")
public void targetIsImplementation(JoinPoint jp) {
record("target(DefaultOrderService)", jp);
}
/** Matches on the runtime types of the arguments, and can bind them. */
@Before("args(sku, quantity) && inServiceLayer()")
public void argsByType(JoinPoint jp, String sku, int quantity) {
record("args (bound: " + sku + "/" + quantity + ")", jp);
}
/** The class of the executing object carries the annotation. Runtime test. */
@Before("@target(com.ankurm.aop.service.Audited) && execution(* *.cancel(..))")
public void targetClassAnnotated(JoinPoint jp) {
record("@target(Audited)", jp);
}
/** The runtime types of the arguments carry the annotation. */
@Before("@args(com.ankurm.aop.service.Trackable) && inServiceLayer()")
public void argumentTypesAnnotated(JoinPoint jp) {
record("@args(Trackable)", jp);
}
// -- annotation and bean matching --------------------------------------------------
/** Declaring type carries the annotation. Static, so cheaper than {@code @target}. */
@Before("@within(com.ankurm.aop.service.Audited) && execution(* *.place(..))")
public void declaringTypeAnnotated(JoinPoint jp) {
record("@within(Audited)", jp);
}
/** The method itself carries the annotation. The one most people reach for first. */
@Before("@annotation(com.ankurm.aop.service.Marker)")
public void methodAnnotated(JoinPoint jp) {
record("@annotation(Marker)", jp);
}
/** Spring's own designator: match by bean name, wildcards allowed. Not AspectJ. */
@Before("bean(inventoryService)")
public void byBeanName(JoinPoint jp) {
record("bean(inventoryService)", jp);
}
/** Wildcards work on bean names too, which is the usual reason to use this designator. */
@Before("bean(*OrderService)")
public void byBeanNameWildcard(JoinPoint jp) {
record("bean(*OrderService)", jp);
}
private void record(String designator, JoinPoint jp) {
recorder.record(designator, jp.getSignature().toShortString());
}
/** Referenced by {@code @args} so the argument type is used; keeps the compiler honest. */
@SuppressWarnings("unused")
private static Payload unused(OrderService service, Marker marker, Audited audited) {
return null;
}
}

View File

@@ -0,0 +1,30 @@
package com.ankurm.aop.aspect;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* Collects which designator matched which join point, so the designator table in the article
* is a record of what actually fired rather than a restatement of the reference documentation.
*/
@Component
public class MatchRecorder {
private final Map<String, Set<String>> matches = new LinkedHashMap<>();
public void record(String designator, String joinPoint) {
matches.computeIfAbsent(designator, key -> new LinkedHashSet<>()).add(joinPoint);
}
public Map<String, Set<String>> matches() {
return matches;
}
public void clear() {
matches.clear();
}
}

View File

@@ -0,0 +1,118 @@
package com.ankurm.aop.broken;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import com.ankurm.aop.aspect.MatchRecorder;
import org.springframework.stereotype.Component;
/**
* The gallery. Each aspect here is written the way it is commonly written and does not fire,
* for a different reason. {@code /aop/broken} exercises them all and reports which ones
* recorded a match, so the article's table of failure modes is generated from real misses.
*
* <p>The working versions live in {@link com.ankurm.aop.aspect.DesignatorAspect}; each entry
* below names the fix.
*/
public final class BrokenAspects {
private BrokenAspects() {
}
/**
* <strong>1. The aspect is not a Spring bean.</strong>
*
* <p>{@code @Aspect} is an AspectJ annotation. It tells Spring how to interpret a bean it
* already has; it does not make the class into a bean. Without {@code @Component} (or an
* {@code @Bean} method, or a component-scan stereotype) the class is never instantiated
* and the pointcut is never registered.
*
* <p>This is the single most common cause, and the most invisible: there is no warning,
* because from Spring's point of view nothing was ever asked for.
*
* <p><strong>Fix:</strong> add {@code @Component}.
*/
@Aspect
public static class NotABean {
@Before("execution(* com.ankurm.aop.service.*.*(..))")
public void neverRuns() {
Recorder.record("1. @Aspect without @Component");
}
}
/**
* <strong>2. The pointcut expression does not match anything.</strong>
*
* <p>A pointcut that matches nothing is indistinguishable at runtime from an aspect that
* was never registered. Here the package is {@code com.ankurm.aop.services} &mdash; note
* the plural &mdash; which does not exist. AspectJ parses it happily: a package name that
* matches no type is not an error, it is an empty match set.
*
* <p><strong>Fix:</strong> check the expression against
* {@code AspectJExpressionPointcut#matches} in a test, or start from
* {@code within(com.example..*)} and narrow.
*/
@Aspect
@Component
public static class PackageTypo {
@Before("execution(* com.ankurm.aop.services.*.*(..))")
public void neverRuns() {
Recorder.record("2. pointcut matches nothing (package typo)");
}
}
/**
* <strong>3. Advising a private method.</strong>
*
* <p>Both proxy strategies work by dispatching through something that wraps the target: a
* JDK proxy implements the interface, a CGLIB proxy extends the class. Neither can
* intercept a private method, because neither can override one. The pointcut is legal and
* simply never matches.
*
* <p><strong>Fix:</strong> make the method at least package-visible and call it from
* outside the object, or move it to a collaborator.
*/
@Aspect
@Component
public static class PrivateMethod {
@Before("execution(private * com.ankurm.aop.service.InventoryService.hidden(..))")
public void neverRuns() {
Recorder.record("3. advice on a private method");
}
}
/**
* <strong>4. Advising a final method.</strong>
*
* <p>Spring Boot proxies with CGLIB by default, which subclasses the target. A final
* method cannot be overridden, so the subclass inherits the original and calls go
* straight to it. No error is raised for a final method &mdash; only a final <em>class</em>
* fails loudly, because then the subclass itself is impossible.
*
* <p><strong>Fix:</strong> remove {@code final}, or proxy by interface instead.
*/
@Aspect
@Component
public static class FinalMethod {
@Before("execution(* com.ankurm.aop.service.InventoryService.finalCheck(..))")
public void neverRuns() {
Recorder.record("4. advice on a final method");
}
}
/** Static hand-off so the broken aspects can report without each taking a constructor. */
static final class Recorder {
private static MatchRecorder delegate;
static void bind(MatchRecorder recorder) {
delegate = recorder;
}
static void record(String what) {
if (delegate != null) {
delegate.record("FIRED " + what, "unexpected");
}
}
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
/**
* <strong>6. The object was never a bean.</strong>
*
* <p>Spring AOP advises beans. An instance created with {@code new} &mdash; in a helper, in a
* factory, in a test &mdash; has no proxy around it and never will, no matter how many
* annotations it carries. The annotation is a request to the container, and the container
* was not involved.
*
* <p><strong>Fix:</strong> get the instance from the container. If it genuinely must be
* constructed by hand and still advised, that is what AspectJ load-time weaving is for.
*/
public class NewedUpService {
@Marker
public String work() {
return "worked";
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
import org.springframework.stereotype.Service;
/**
* <strong>5. Self-invocation.</strong>
*
* <p>The most expensive failure mode in the gallery, because the code looks correct and the
* annotation is right there. {@link #outer()} is called through the proxy, so advice on it
* runs. The call it then makes to {@link #inner()} is a plain {@code this.inner()} on the
* target object &mdash; the proxy is not involved, so advice on {@code inner()} never runs.
*
* <p>This is the same mechanism that makes {@code @Transactional} and {@code @Cacheable}
* silently do nothing on internal calls, which is why it is worth understanding once rather
* than three times.
*
* <p><strong>Fix:</strong> move {@code inner()} to another bean. Failing that, inject the
* bean into itself and call through that reference, or use
* {@code AopContext.currentProxy()} with {@code exposeProxy = true} &mdash; both work and
* both are worse.
*/
@Service
public class SelfInvokingService {
@Marker
public String outer() {
return "outer -> " + inner();
}
/** Advised in principle. Never advised in practice when reached from {@link #outer()}. */
@Marker
public String inner() {
return "inner";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Type-level annotation, matched by {@code @within(..)} and {@code @target(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Audited {
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* Implements an interface, so by default Spring AOP proxies it with a JDK dynamic proxy
* &mdash; except that Spring Boot flips the global default to class-based proxies. Which
* one you actually get is measured, not assumed: see
* {@code docs/output/03-proxy-types.txt}.
*/
@Service
@Audited
public class DefaultOrderService implements OrderService {
@Override
@Marker
public String place(String sku, int quantity) {
return "placed " + quantity + " x " + sku;
}
@Override
public String cancel(String id) {
return "cancelled " + id;
}
@Override
public String slow() {
try {
Thread.sleep(25);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
return "slept";
}
/** Not on the interface. A JDK proxy cannot intercept this; a CGLIB proxy can. */
public String interfaceless(Payload payload) {
return "handled " + payload.value();
}
}

View File

@@ -0,0 +1,26 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* No interface, so this bean can only be proxied by CGLIB, and only its non-final,
* non-private methods can be advised.
*/
@Service
public class InventoryService {
public String reserve(String sku) {
return "reserved " + sku;
}
/** CGLIB proxies by subclassing. A final method cannot be overridden, so it cannot be advised. */
public final String finalCheck(String sku) {
return "checked " + sku;
}
/** Neither can a private one. Included so the article can show both misses in one class. */
@SuppressWarnings("unused")
private String hidden() {
return "hidden";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Method-level annotation, matched by {@code @annotation(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Marker {
}

View File

@@ -0,0 +1,8 @@
package com.ankurm.aop.service;
/** Interface, so this bean can be proxied by a JDK dynamic proxy. */
public interface OrderService {
String place(String sku, int quantity);
String cancel(String id);
String slow();
}

Some files were not shown because too many files have changed in this diff Show More