Records as Configuration Properties
Bind configuration into immutable records through constructor binding, with defaults, nested records and lists.
@ConfigurationProperties("app.prefix")
record MyProperties(
String name,
@DefaultValue("30s") Duration timeout,
@DefaultValue Nested nested) {
record Nested(@DefaultValue("3") int retries) { }
}A Java record is the natural shape for configuration: a fixed set of named values that never change after startup. Annotate a record with @ConfigurationProperties and Spring Boot binds through its canonical constructor instead of setters. The result is immutable, thread-safe by construction, and impossible to half-populate from code.
@ConfigurationProperties("app.storage")
public record StorageProperties(String bucket, Duration urlExpiry, DataSize maxUpload) { }Constructor binding
A record has one canonical constructor, so Boot uses it without being told. You only need @ConstructorBinding on a class with several constructors, to say which one to bind. Registration is the same as for any properties class: @ConfigurationPropertiesScan or @EnableConfigurationProperties. Records cannot be registered by putting @Component on them, because constructor binding does not apply to scanned components.
Defaults
A record has no field initialisers, so defaults move into annotations. @DefaultValue("15m") on a component supplies the value as text and Boot converts it like any other property. A missing key without a default binds as null for objects and zero or false for primitives, so either give it a default or validate it.
Nested records are left null when none of their keys are set. Annotating the nested component with a bare @DefaultValue tells Boot to create it anyway, so its own defaults apply and callers never meet a null.
Collections
Lists, sets and maps bind as in a JavaBean class. Wrap them in List.copyOf or Map.copyOf in a compact constructor if you want the record to be fully immutable; the binder may hand you a mutable collection. Take care that List.copyOf(null) throws, so substitute an empty list when the key is absent.
Mistakes
- Putting behaviour into the record that needs other beans. A properties record is bound before most of the application exists; keep it to values and small derived methods such as
accepts(...). - Expecting a validation annotation alone to reject a missing key. You also need
@Validatedon the record (next lesson). - A component named after a Java keyword, such as
default, cannot be declared at all. Name itdefaultValueand use@Name("default")to bind it to the keydefault.
For new code, prefer records over JavaBean classes. They read as a contract ("this service needs exactly these settings"), they make tests trivial with new StorageProperties(...), and nobody can call a setter at runtime and change behaviour for every request.
Example
import java.net.URI;
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;
import org.springframework.util.unit.DataSize;
("app.storage")
public record StorageProperties(
URI endpoint,
String bucket,
("15m") Duration urlExpiry,
("10MB") DataSize maxUpload,
({"image/png", "image/jpeg", "application/pdf"}) List<String> allowedTypes,
Map<String, String> tags,
Retry retry) {
public StorageProperties {
// The binder may pass mutable or null collections; normalise them once.
allowedTypes = List.copyOf(allowedTypes);
tags = tags == null ? Map.of() : Map.copyOf(tags);
}
public record Retry(("3") int maxAttempts, ("200ms") Duration backoff) {
}
public boolean accepts(String contentType, long bytes) {
return allowedTypes.contains(contentType) && bytes <= maxUpload.toBytes();
}
}When to use it
- Describing an object storage integration (endpoint, bucket, URL expiry, upload size limit) as one immutable record.
- Holding pricing rules such as VAT percentage and quote validity in a record that services read but can never change.
- Replacing a mutable JavaBean settings class whose setters were being called from a controller at runtime.
More examples
Properties for StorageProperties
`url-expiry` binds to the `urlExpiry` component, `25MB` converts to a `DataSize`, and each `tags.<key>` line becomes one map entry. Because `retry` has a bare `@DefaultValue`, the nested record is created with three attempts and a 200 ms back-off even though no `retry` key is present.
app.storage.endpoint=https://s3.eu-west-2.amazonaws.com
app.storage.bucket=shop-uploads-prod
app.storage.url-expiry=10m
app.storage.max-upload=25MB
app.storage.tags.team=checkout
app.storage.tags.cost-centre=cc-1042
# allowed-types and retry.* are not set: the @DefaultValue values applyUsing the record in a service
The service depends on the record, not on property keys, so it reads like plain Java: `pricing.vatPercent()`. `@EnableConfigurationProperties` registers the record explicitly, which suits a feature package that wants to own its configuration. `freeShippingCountries` has no default, so it is null when unset, and the code handles that rather than assuming a list.
import java.time.Duration;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
@ConfigurationProperties("app.pricing")
record PricingProperties(
@DefaultValue("GBP") String currency,
@DefaultValue("20") int vatPercent,
@DefaultValue("PT10M") Duration quoteValidity,
List<String> freeShippingCountries) {
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(PricingProperties.class)
class PricingConfig {
}
@Service
public class PriceCalculator {
private final PricingProperties pricing;
public PriceCalculator(PricingProperties pricing) {
this.pricing = pricing;
}
public long grossPence(long netPence) {
return netPence + netPence * pricing.vatPercent() / 100;
}
public boolean freeShipping(String countryCode) {
List<String> countries = pricing.freeShippingCountries();
return countries != null && countries.contains(countryCode);
}
}Testing the binding
`ApplicationContextRunner` starts a tiny context with only the configuration you give it, so the test runs fast and proves the real binding rules: kebab-case keys, conversion and defaults. Use it for any properties class with defaults or conversion that matters, because a wrong default is otherwise only found in production.
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
@ConfigurationProperties("app.export")
record ExportProperties(String directory, @DefaultValue("1h") Duration interval, @DefaultValue("500") int batchSize) {
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ExportProperties.class)
class ExportConfig {
}
class ExportPropertiesTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner().withUserConfiguration(ExportConfig.class);
@Test
void bindsKebabCaseKeysAndKeepsDefaults() {
runner.withPropertyValues("app.export.directory=/var/exports", "app.export.batch-size=1000")
.run(context -> {
ExportProperties props = context.getBean(ExportProperties.class);
assertThat(props.directory()).isEqualTo("/var/exports");
assertThat(props.batchSize()).isEqualTo(1000);
assertThat(props.interval()).isEqualTo(Duration.ofHours(1));
});
}
}
Discussion