Skip to content
CalliCoder

Spring Boot @ConfigurationProperties Example

Spring Boot 11 min read

Bind external configuration to typed objects: records with constructor binding, relaxed binding rules, validation that fails at startup instead of at 3am, and when @Value is still the right tool.

@Value("${some.property}") works, scattered across the classes that need it, until the day someone deploys with some.propety misspelled and the failure arrives as a NullPointerException in a scheduled job at three in the morning.

@ConfigurationProperties binds a whole group of properties to one typed object, once, and can refuse to start the application if the configuration is wrong. That last part is the reason to use it.

Written against Spring Boot 3.2 and Java 17.

A record is the best shape for this

Spring Boot 3 binds through a constructor when the type has a single one, and a record has exactly one. No setters, no mutable state, no @ConstructorBinding annotation needed:

package com.example.notes.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
import java.util.List;

@ConfigurationProperties(prefix = "notes.storage")
public record StorageProperties(
        String location,
        Duration retention,
        List<String> allowedExtensions,
        Limits limits) {

    public record Limits(long maxFileSizeBytes, int maxFilesPerUser) { }
}
notes:
  storage:
    location: /var/lib/notes
    retention: 30d
    allowed-extensions: [pdf, png, txt]
    limits:
      max-file-size-bytes: 10485760
      max-files-per-user: 200

Nested records bind to nested YAML. Duration accepts 30d, 45s, PT10M; DataSize accepts 10MB. Both are Spring conversions, not Java ones. A String field would just hold the text.

Register it

The type has to be picked up. Three ways, in order of preference:

// 1. scan a package — the usual choice
@SpringBootApplication
@ConfigurationPropertiesScan("com.example.notes.config")
public class NotesApplication { }

// 2. name it explicitly
@Configuration
@EnableConfigurationProperties(StorageProperties.class)
class StorageConfig { }

// 3. annotate the class as a component — only works with setter binding
@Component
@ConfigurationProperties(prefix = "notes.storage")
class StorageProperties { ... }

The third does not work for records or any constructor-bound type; Spring needs to construct the object itself rather than receive it from the container. Mixing @Component with constructor binding is a common and confusing failure.

Then inject it like any bean:

@Service
public class StorageService {

    private final StorageProperties props;

    public StorageService(StorageProperties props) {
        this.props = props;
    }

    public Path resolve(String filename) {
        return Path.of(props.location()).resolve(filename).normalize();
    }
}

Relaxed binding

One property can be written several ways and they all bind to the same field:

notes.storage.max-file-size-bytes    <- kebab-case, the canonical form
notes.storage.maxFileSizeBytes       <- camelCase
notes.storage.max_file_size_bytes    <- underscores
NOTES_STORAGE_MAXFILESIZEBYTES       <- environment variable

That last form is the point. Environment variables cannot contain dots or dashes, so relaxed binding is what makes @ConfigurationProperties work with container configuration and secret managers without a translation layer.

Use kebab-case in your own files. It is the documented canonical form, and it is what the configuration metadata generator emits.

@Value does not do this. @Value("${notes.storage.maxFileSizeBytes}") will not resolve from max-file-size-bytes. Another reason the two are not interchangeable.

Validation, at startup

This is the feature that pays for itself:

@ConfigurationProperties(prefix = "notes.storage")
@Validated
public record StorageProperties(
        @NotBlank String location,
        @NotNull @DurationMin(days = 1) Duration retention,
        @NotEmpty List<String> allowedExtensions,
        @Valid Limits limits) {

    public record Limits(
            @Positive long maxFileSizeBytes,
            @Min(1) @Max(10_000) int maxFilesPerUser) { }
}

Needs spring-boot-starter-validation on the classpath, since Spring Boot 2.3 it is not transitive. With it, a missing or invalid value fails the context:

Binding to target StorageProperties failed:

    Property: notes.storage.location
    Value: null
    Reason: must not be blank

The application does not start. That is the correct outcome: a misconfigured deployment that refuses to boot is caught by the rollout, while one that starts and misbehaves is caught by a user.

@Valid on the nested field is required, without it the nested record’s constraints are not evaluated.

Profiles

Put the shape in the main file and the environment differences in profile files:

# application.yaml
notes:
  storage:
    location: /tmp/notes
    retention: 7d

# application-prod.yaml
notes:
  storage:
    location: /var/lib/notes
    retention: 365d

Profile files override, key by key, rather than replacing the block: allowed-extensions set only in the main file still applies under prod.

In a single file, use activation blocks rather than the removed spring.profiles:

notes.storage.retention: 7d
---
spring.config.activate.on-profile: prod
notes.storage.retention: 365d

Activate with SPRING_PROFILES_ACTIVE=prod, --spring.profiles.active=prod, or @ActiveProfiles("prod") in a test.

A list does not merge. If application.yaml defines three extensions and application-prod.yaml defines one, production has one. This surprises people every time; there is no append.

@Value is still fine sometimes

@ConfigurationProperties is right for a group of related settings that belong to a component. @Value is right for one value used in one place:

@Value("${notes.storage.location}")
private String location;                 // fine for a single value

@Value("${notes.retention:30d}")
private Duration retention;              // with a default after the colon

What @Value cannot do: relaxed binding, validation, nested types, collections beyond simple comma-splitting, or IDE completion. It also spreads knowledge of your configuration keys across every class that reads one, so a rename becomes a search.

Binding a type you do not own

@ConfigurationProperties also works on a @Bean factory method, which is how you configure a third-party class from your own property namespace without wrapping it:

@Configuration
class ClientConfig {

    @Bean
    @ConfigurationProperties(prefix = "notes.upstream")
    ThirdPartyClientOptions upstreamOptions() {
        return new ThirdPartyClientOptions();
    }
}

Spring creates the object, then binds notes.upstream.* onto its setters. This is setter binding by necessity, you cannot add a constructor to a class you did not write, so the type needs mutable properties, which most configuration-holder classes have.

It is also the cleanest way to expose a library’s settings under names that match the rest of your configuration, rather than leaking the library’s own vocabulary into every environment file. If the library later renames a setter, the failure appears at startup rather than as a silently unset value.

Metadata for your editor

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

This annotation processor writes META-INF/spring-configuration-metadata.json at compile time, and IDEs read it to autocomplete your properties in application.yaml and flag unknown keys. Javadoc on the record components becomes the hover documentation. It costs one optional dependency and removes the most common configuration error, a typo, from the editor rather than from runtime.

Frequently asked questions

Why are my properties all null?

The type was never registered. Add @ConfigurationPropertiesScan to the application class, or @EnableConfigurationProperties(X.class) on a configuration class.

Can I use a record?

Yes, and it is the preferred shape on Spring Boot 3, a single constructor means constructor binding is used automatically, with no annotation.

Why does @Component not work with my record?

@Component makes the container instantiate the bean, but constructor binding requires Spring to construct it from resolved properties. Use @ConfigurationPropertiesScan or @EnableConfigurationProperties instead.

Do I still need @ConstructorBinding?

Only when a class has more than one constructor and you must indicate which to bind. With a single constructor, including every record. It is implicit.

How do environment variables map to properties?

Upper-case with underscores for separators: notes.storage.location becomes NOTES_STORAGE_LOCATION. Relaxed binding is what makes this work.

Why is validation not running?

Two likely causes: @Validated is missing from the class, or spring-boot-starter-validation is not on the classpath, it stopped being transitive in Spring Boot 2.3. For nested types, @Valid on the field is also required.

Do lists merge across profiles?

No. A list defined in a profile file replaces the base list entirely. There is no append semantics for collections.

What is the difference between @Value and @ConfigurationProperties?

@Value reads one key with exact spelling and no validation. @ConfigurationProperties binds a group with relaxed binding, type conversion, nested objects and startup validation.

Can I bind a Map?

Yes: Map<String, String> binds from notes.labels.env=prod style keys, and Map<String, SomeRecord> binds nested blocks. Useful for a bounded set of named configurations.

How do I see the resolved values at runtime?

Actuator’s /actuator/configprops reports bound properties. Keep it off any public port, see securing Actuator endpoints.

Where should I go next?

The Spring Boot REST API guide uses this pattern for datasource and upload settings, and the other Spring Boot guides build on the same application.