Skip to main content

@ConfigurationProperties vs @Value in Spring Boot 4: Binding, Validation and Relaxed Rules

@Value and @ConfigurationProperties are different mechanisms, not two styles. A measured matrix of which property spellings actually resolve (including the widely repeated claim about @Value and relaxed binding, which is wrong inside Spring Boot), record constructor binding and defaults, validation, and the JDK 23 change that silently stops your IDE metadata being generated. Verified on Spring Boot 4.1.1 and JDK 25.

Spring Boot 4.1.1 · Spring Framework 7.0.9 · JDK 25. Every table below was produced by running the companion project, not by quoting the reference documentation — and three of them were produced more than once, because the first answer was wrong. Two ways to read a property, and a decade of blog posts telling you they are a matter of taste. They are not. @Value and @ConfigurationProperties are different mechanisms that happen to read from the same Environment, and almost every practical difference between them follows from that one fact. This article is about the differences that cost time: which spellings of a property name actually resolve, what a record does with a missing value, why your IDE stopped auto-completing property names after a JDK upgrade, and which piece of received wisdom about validation is simply not true.
PartFor you ifCovers
1 — Beginneryou use both and pick by habitthe two mechanisms, the smallest working version of each, what relaxed binding means
2 — Intermediatea property is set and the app cannot see itthe measured spelling matrix, records and defaults, validation, finding where a value came from
3 — Advancedyou own the build and the conventionsthe JDK 23 change that silently kills IDE metadata, the @Valid myth, when @Value still wins
Versions this was verified against. Spring Boot 4.1.1 (GA), Spring Framework 7.0.9, Eclipse Temurin JDK 25.0.4.1 LTS, Hibernate Validator via spring-boot-starter-validation. Versions were checked against maven-metadata.xml on Maven Central rather than release announcements — note that the <release> element in that file currently points at 4.2.0-M1, a milestone, so it is not a reliable GA marker on its own.

Companion code: spring-boot-demo, directory configuration-properties/. Seven contract tests, six captured transcripts, all regenerated by scripts/run-all.sh.

Part 1 — Two mechanisms, not two styles

What @Value does

@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.
@Component
public class ValueHolder {

    @Value("${demo.mail.host}")
    private String host;

    @Value("${demo.mail.port:587}")
    private int port;

    @Value("${demo.mail.timeout:30s}")
    private Duration timeout;
}
Conversion works, because the same ConversionService is involved either way — 30s becomes a Duration. Defaulting works, via the : inside the placeholder. That is the whole feature set, plus SpEL.

What @ConfigurationProperties does

Binder walks a target type, works out which properties it needs from the type’s shape, asks each ConfigurationPropertySource for them, converts, and constructs the object. The type is the model.
@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) {

    public record RetryProperties(
            @DefaultValue("3") int maxAttempts,
            @DefaultValue("2s") Duration backoff) {
    }
}
A record has exactly one canonical constructor, so Spring Boot uses constructor binding with no annotation at all. @ConstructorBinding is only needed to disambiguate a type with several candidate constructors, and since Boot 3 it goes on the constructor rather than the type.

The difference this produces

Environment application.yaml env vars, -D, args config trees placeholder resolution ${demo.mail.host} Binder walks the target type one field @Value one object record + nesting configurationProperties Spring Boot attaches the dashed source to every environment it prepares. It routes placeholder lookups through the binder’s name matching, which is why the two paths agree on spelling inside Spring Boot, and disagree in a plain Spring application. Only the lower path can produce nested objects, lists, maps, and a validated result.

Relaxed binding, in one sentence

Every property has one canonical name: lower case, words separated by -, levels separated by .. For a record component apiKey under prefix demo.relaxed, that is demo.relaxed.api-key. The binder then accepts several spellings of that name, because ConfigurationPropertyName compares name elements with separators removed and case folded. Which spellings, exactly, is Part 2.

Part 2 — The matrix, and how to find where a value came from

Which spellings actually resolve

This table is generated by binding each spelling and re-checked by launching a real JVM per spelling. The canonical name is demo.relaxed.api-key.
SpellingSourceBinder${} plain Spring${} in Spring Boot
demo.relaxed.api-keyfile / -Dboundboundbound
demo.relaxed.apiKeyfile / -DboundMISSbound
demo.relaxed.api_keyfile / -DboundMISSbound
demo.relaxed.APIKEYfile / -DboundMISSbound
demo.relaxed.apikeyfile / -DboundMISSbound
demo.relaxed.api.keyfile / -DMISSMISSMISS
DEMO_RELAXED_API_KEYenv varboundboundbound
DEMO_RELAXED_APIKEYenv varboundMISSbound
Three things fall out of it. Dashes, underscores and case are noise. api-key, apiKey, api_key and apikey are the same name as far as the binder is concerned. 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 — and this is the spelling people write when they are guessing at a property name they half-remember. The middle column is the one everybody quotes, and it is not what you get.
@Value doesn’t support relaxed binding” is a statement about the Spring Framework, not about Spring Boot. Boot calls ConfigurationPropertySources.attach(environment) while preparing every environment it creates. That inserts a property source named configurationProperties at the front, which resolves placeholder lookups through the binder’s name matching. Inside a Boot application, @Value("${demo.relaxed.api-key}") happily finds a property written demo.relaxed.apiKey.

Rely on it in a Boot application. Do not rely on it in a plain ApplicationContext, a bare StandardEnvironment, or a test that builds one by hand — there you get the middle column.

An aside on how that table was almost wrong

The first version of the probe reported that DEMO_RELAXED_APIKEY does not bind demo.relaxed.api-key. It was a striking result, it would have made a memorable paragraph about Kubernetes, and it was entirely an artefact of the test harness. Spring Boot decides whether 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. The probe had constructed a genuine SystemEnvironmentPropertySource and named it "probe-env", so it was mapped with the ordinary rules and the underscore spellings stopped resolving. Launching one real JVM per spelling disagreed, and the real JVM was right. That is worth knowing in itself: a synthesised property source is not a running Spring Boot application, and when the two disagree, believe the application.

Lists, and the one shape both understand

demo:
  mail:
    recipients:
      - [email protected]
      - [email protected]
$ curl -s localhost:8080/diag/bound
{
    "mail": {
        "recipients": [ "[email protected]", "[email protected]" ]
    },
    "fromValueAnnotation": {
        "recipients": []
    }
}
The binder produced both elements. @Value("${demo.mail.recipients:}") produced an empty list, because placeholder resolution has no concept of a YAML sequence and fell through to its own default. Write the same list as a comma-separated string and both see it. That is the only list shape @Value understands, and it is why comma-separated lists persist in configuration long after they stopped being pleasant to read.
Lists do not merge across sources. A list is bound from the highest-precedence source that holds the property, in its entirety. A source setting three elements replaces a lower source’s two; it does not append. This catches people who expect a profile-specific file to add one entry to a base list.

Defaults, and the rule about whole objects

A record cannot have field initialisers, so a default is attached to the component with @DefaultValue. Bare @DefaultValue on a nested type means “construct it with its own defaults” rather than “bind it to null”, and on a collection it gives you an empty one instead of null — which removes a whole class of NullPointerException from startup code. There is one rule that only bites when you call Binder yourself:
// Only demo.relaxed.api.key is set -- a DIFFERENT property.
BindResult<RelaxedProperties> result =
        binder.bind("demo.relaxed", RelaxedProperties.class);

result.isBound();   // false -- not "an object full of defaults"
result.get();       // throws NoSuchElementException
If nothing under the prefix matches, the binder returns no result at all. @DefaultValue applies to a component of an object being constructed; it does not cause one to be constructed.

Validation, which @Value cannot do at all

@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) {
    }
}
A bad value becomes a startup failure that names the property, the value, the file, the line and the constraint — all violations at once, not one per restart:
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.ankurm.configprops.props.ValidatedProperties failed:

    Property: demo.validated.port
    Value: "99999"
    Origin: class path resource [application-badvalidation.yaml] - 6:11
    Reason: must be less than or equal to 65535

    Property: demo.validated.name
    Value: "  "
    Origin: class path resource [application-badvalidation.yaml] - 4:11
    Reason: must not be blank
The alternative is a NumberFormatException in a request handler at 3am. One prerequisite that fails quietly: @Validated with no validator implementation on the classpath is a no-op. Nothing is checked and nothing says so. Add spring-boot-starter-validation.

Finding where a value came from

“The property is set but the application does not see it” is the most common configuration bug, and it is hard for exactly one reason: a value carries no visible provenance. Spring Boot tracks it anyway.
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
    ConfigurationProperty property =
            source.getConfigurationProperty(ConfigurationPropertyName.of(name));
    if (property != null) {
        // property.getValue(), property.getOrigin(), source.getUnderlyingSource()
    }
}
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'
{
    "property": "demo.mail.host",
    "effectiveValue": "smtp.example.com",
    "candidatesInPrecedenceOrder": [
        {
            "source": "OriginTrackedMapPropertySource {name='...[application.yaml]'}",
            "value": "smtp.example.com",
            "origin": "class path resource [application.yaml] - 15:11"
        }
    ]
}
Line 15, column 11. Not “somewhere in your configuration”. Iterating those sources gives them in precedence order, so the first hit is the winner and anything after it is a value that exists and lost — which is what “my change had no effect” looks like from the inside. The profiles article is entirely about that case. In anything real, prefer Actuator’s /actuator/env, which does the same job with sanitisation built in.

Part 3 — Two things that are quietly wrong in most write-ups

Your IDE stopped auto-completing, and the build is green

spring-boot-configuration-processor is an annotation processor. At compile time it reads your @ConfigurationProperties types and writes META-INF/spring-configuration-metadata.json, which is what makes property names auto-complete and shows record-component Javadoc on hover. Nothing at runtime reads it. Declared the way every pre-2024 tutorial shows it:
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-configuration-processor</artifactId>
  <optional>true</optional>
</dependency>
On JDK 23 and later, that no longer works. 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. A processor that is only on the classpath is simply not run. 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 fingerprint of this bug is that there is no fingerprint. Both compilations succeed. The jar is valid. The application behaves identically. The only symptom is that property auto-completion quietly stops working — which people blame on the IDE, and which nobody writes a ticket for.
The fix is to 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:
<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 deliberately — the stated goal was to make builds robust against processors landing on the classpath unintentionally. Check with ls target/classes/META-INF/spring-configuration-metadata.json. Nothing else will tell you.

@Valid is not what makes nested constraints run

The advice is everywhere: annotate nested @ConfigurationProperties types with @Valid or their constraints are ignored. It is true of ordinary bean validation, where cascading is opt-in. It is not true here. Spring Boot’s ValidationBindHandler validates every object the binder finishes constructing, nested ones included:
@Validated
@ConfigurationProperties(prefix = "demo.unchecked")
record Unchecked(@DefaultValue Pool pool) {          // note: no @Valid anywhere
    record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
    }
}
BindValidationException: Binding validation errors on demo.unchecked.pool
   - Field error in object 'demo.unchecked.pool' on field 'size':
     rejected value [4000]; default message [must be less than or equal to 100]
That test was written to assert the opposite and failed, which is how it ended up in the article. Keep writing @Valid if you like — it is harmless and it is what a reader expects. Just do not believe it is load-bearing, and do not go hunting for a missing @Valid when a nested constraint appears not to fire, because that is not the cause.

When @Value is still the right answer

Binding wins most of the time, and most of this article is an argument for it. The exceptions are real:
  • SpEL. @Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}") has no @ConfigurationProperties equivalent. The binder maps a value; it does not derive one.
  • One value in a class that is not about configuration. A component needing a single feature flag does not benefit from a type that would exist to hold one field.
  • Reading a property you do not own. @Value("${server.port}") reads Spring Boot’s property; declaring a type for it would imply otherwise.
The rough line: if you would name the type after the group of settings and that name would be meaningful, bind. If you would have to invent a name, use @Value. What are not good reasons: “it’s less code” (true for one property, false by four), “I need a default” (both do defaults), and “@Value doesn’t do relaxed binding” (inside Boot, it does).

The long tail

Each of these has a chapter in the companion repository rather than a section here:
  • Getting the bean registered — @ConfigurationPropertiesScan vs @EnableConfigurationProperties vs @Component, and why constructor binding and @Autowired cannot mix: chapter 3
  • @ConfigurationProperties on an @Bean method, which uses setter binding rather than constructor binding: chapter 3
  • Custom validation for rules a constraint annotation cannot express, via a bean named configurationPropertiesValidator: chapter 5
  • Why the violation order in a validation failure report is not stable between runs, and why you should not assert on it: chapter 5
Should you go and convert everything? No. A codebase with fifty @Value fields spread across ten classes has a real problem, and a codebase with three does not. The conversion is worth doing where a group of settings genuinely travels together, where a bad value should stop startup rather than a request, or where the property names are ones operators will type. Everywhere else, a mass rewrite of working code buys you a diff and a risk.

The one change worth making unconditionally is the build one: check that spring-configuration-metadata.json is actually being generated. That costs nothing and you have probably already lost it.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.