1
0

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

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

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

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

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

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

View File

@@ -0,0 +1,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) {
}
}
}