Records as Configuration Properties

Bind configuration into immutable records through constructor binding, with defaults, nested records and lists.

Syntax@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 @Validated on the record (next lesson).
  • A component named after a Java keyword, such as default, cannot be declared at all. Name it defaultValue and use @Name("default") to bind it to the key default.

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

Example · java
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;

@ConfigurationProperties("app.storage")
public record StorageProperties(
        URI endpoint,
        String bucket,
        @DefaultValue("15m") Duration urlExpiry,
        @DefaultValue("10MB") DataSize maxUpload,
        @DefaultValue({"image/png", "image/jpeg", "application/pdf"}) List<String> allowedTypes,
        Map<String, String> tags,
        @DefaultValue 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(@DefaultValue("3") int maxAttempts, @DefaultValue("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.

Example · properties
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 apply

Using 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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.