Typed settings
A recipe card says “bake at 180 °C for 25 minutes”. The oven doesn’t care where the numbers came from; it only needs them before it starts. Configuration is the recipe card for your service: values that are read once at startup and used everywhere.
In Spring Boot they go in src/main/resources/application.yml. You can read a single value with @Value, but the better way is to group related settings into one class with a field per setting. Spring fills it in at startup, checks the values, and any service that needs them gets the whole object.
@ConfigurationProperties binds a prefix to an immutable, typed object (a record or Kotlin data class bound through its constructor). You get type conversion (Duration, DataSize, enums, lists, maps), defaults in one place, Bean Validation with @Validated so a bad value fails startup, IDE completion through the configuration processor, and a single class to inject and to fake in tests. @Value is fine for one-off values but scatters string keys across the codebase, can’t be validated as a group, and makes typos silent when combined with defaults.
@ConfigurationProperties("payment")
@Validated
data class PaymentProperties(
@field:NotNull val baseUrl: URI,
val timeout: Duration = Duration.ofSeconds(5),
@field:Valid val retry: Retry = Retry(),
@field:NotEmpty val allowedCurrencies: List<String>,
) {
data class Retry(
@field:Min(1) @field:Max(10) val maxAttempts: Int = 3,
val backoff: Duration = Duration.ofMillis(200),
)
}
@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication
@Service
class PaymentClient(private val props: PaymentProperties)@ConfigurationProperties("payment")
@Validated
public record PaymentProperties(
@NotNull URI baseUrl,
@NotNull Duration timeout,
@Valid Retry retry,
@NotEmpty List<String> allowedCurrencies) {
public record Retry(@Min(1) @Max(10) int maxAttempts, Duration backoff) {
public Retry {
if (backoff == null) backoff = Duration.ofMillis(200); // a default in one place
}
}
}
@SpringBootApplication
@ConfigurationPropertiesScan // finds every @ConfigurationProperties class
public class ShopApplication { /* main */ }
@Service
public class PaymentClient {
private final PaymentProperties props;
public PaymentClient(PaymentProperties props) { this.props = props; }
}# src/main/resources/application.yml
payment:
base-url: https://api.payments.example.com
timeout: 5s
retry:
max-attempts: 3
backoff: 200ms
allowed-currencies: [EUR, USD, RSD]- Names:
base-urlin YAML binds tobaseUrlin code (Spring’s relaxed binding acceptsbase-url,baseUrlandBASE_URL). Use kebab-case in files. - Types:
5sbecomes aDuration,[EUR, USD]aList. A value that can’t be converted (timeout: five) stops the app at startup with a clear message. - Validation: with
@Validated, a missingbase-urlormax-attempts: 0also stops startup, naming the property. Failing at startup is good: the old version keeps running and the deploy is rolled back, instead of the first payment failing in production. - Defaults live in the class, so every reader sees them in one place.
Kotlin note
In Kotlin, validation annotations need the @field: target, otherwise they land on the constructor parameter and Bean Validation never sees them. Default parameter values work as property defaults.
Tap the line with the bug.
@Service
class ShippingClient(
@Value("${shipping.timeout:10}")
private val timeoutSeconds: Int,
@Value("${shiping.base-url:http://localhost:8089}")
private val baseUrl: String,
)@Service
public class ShippingClient {
@Value("${shipping.timeout:10}")
private int timeoutSeconds;
@Value("${shiping.base-url:http://localhost:8089}")
private String baseUrl;
}Hint: Production sets shipping.base-url in its configuration. Which URL does the client actually call?
The key has a typo (shiping), so it never matches the shipping.base-url that production sets, and the default quietly takes over: production calls http://localhost:8089, and every shipment fails with a connection error that has nothing to do with configuration at first sight. Defaults in @Value turn typos into silent bugs, and these string keys are spread over the codebase where nobody reviews them together.
Bind the group to a typed class instead. A misspelled prefix then leaves the required field empty, and validation stops the app at startup:
@ConfigurationProperties("shipping")
@Validated
data class ShippingProperties(
@field:NotNull val baseUrl: URI?,
val timeout: Duration = Duration.ofSeconds(10),
)@ConfigurationProperties("shipping")
@Validated
public record ShippingProperties(@NotNull URI baseUrl, Duration timeout) {
public ShippingProperties {
if (timeout == null) timeout = Duration.ofSeconds(10);
}
}Also note int timeoutSeconds: the unit lives only in the field name. Duration with 10s in YAML can’t be misread as milliseconds.
Replace @Value with a properties class
A notification service reads these settings with @Value in three different classes: push.enabled (boolean, default true), push.batch-size (between 1 and 500, default 100), push.apns.key-id (required) and push.apns.team-id (required). Write one validated properties class, and the YAML for it.
›
Nest the apns settings as their own record or data class, and mark the nested object @Valid so its fields are validated too.
SolutionTry it yourself first
@ConfigurationProperties("push")
@Validated
data class PushProperties(
val enabled: Boolean = true,
@field:Min(1) @field:Max(500) val batchSize: Int = 100,
@field:Valid val apns: Apns,
) {
data class Apns(
@field:NotBlank val keyId: String,
@field:NotBlank val teamId: String,
)
}@ConfigurationProperties("push")
@Validated
public record PushProperties(
Boolean enabled,
@Min(1) @Max(500) Integer batchSize,
@NotNull @Valid Apns apns) {
public PushProperties {
if (enabled == null) enabled = true;
if (batchSize == null) batchSize = 100;
}
public record Apns(@NotBlank String keyId, @NotBlank String teamId) {}
}push:
batch-size: 200
apns:
key-id: ${APNS_KEY_ID}
team-id: ${APNS_TEAM_ID}${APNS_KEY_ID} is a placeholder resolved from the environment. Every class that needs push settings now injects PushProperties; a test can create one with new PushProperties(true, 10, new Apns("k", "t")) without starting Spring.
Binding details worth knowing
- Constructor binding is automatic for records and for classes with a single constructor (Boot 3); the object is immutable, so nothing can change it after startup.
- Unknown keys are ignored by default: a misspelled nested key under a valid prefix is silently dropped. Required fields with validation catch most of it;
ignoreUnknownFields = falsecatches the rest, at the cost of breaking on keys meant for other code. - Metadata:
spring-boot-configuration-processorgeneratesMETA-INF/spring-configuration-metadata.json, so IDEs complete and document your keys, and Javadoc on fields becomes the description. /actuator/configpropsshows the bound values (sanitized) in a running app;/actuator/envshows where each value came from.
@ConfigurationProperties+ records/data classes +@ConfigurationPropertiesScan: typed, immutable, withDuration/DataSize/enum conversion and defaults in one place.@Validated+ Bean Validation (@field:in Kotlin,@Validon nested objects) fails startup on missing or invalid values.@Valuewith defaults turns typos into silent misconfiguration; unknown keys are ignored by default.- Configuration processor for IDE metadata;
/actuator/configpropsand/actuator/envto inspect.
Check this part
Why is a validated @ConfigurationProperties class better than many @Value fields?
In application.yml you write
timeout: 5s. What type can the field be?What happens to a misspelled key under a valid prefix by default?