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,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",