Configuration with @ConfigurationProperties Instead of @Value
1. Overview
@Value is convenient when you only need to read one or two configuration values. As the number of properties grows, those ${...} strings start appearing all over your beans, becoming hard to find, easy to mistype, and not validated at all.
This guide shows you how to switch to @ConfigurationProperties in Spring Boot 3.x. After reading, you’ll know how to group configuration into a typed record and bind complex types like Duration, List, Map, and nested objects. You’ll also learn how to validate configuration at startup and write tests for that configuration.
2. The Problem with @Value
Consider a PaymentService that needs to call a payment gateway:
@Service
public class PaymentService {
@Value("${app.payment.gateway-url}")
private String gatewayUrl;
@Value("${app.payment.merchant-id}")
private String merchantId;
@Value("${app.payment.timeout:10s}")
private Duration timeout;
@Value("${app.payment.max-retries:3}")
private int maxRetries;
// ...
}
The code runs, but there are several issues:
- Keys are free-form strings. Type
app.payment.gatewayUrlinstead ofgateway-urland you only find out about the mistake at runtime. - No validation.
max-retries=-5gets injected without any complaints. - Configuration is scattered. If
RefundServicealso needsmerchant-id, you have to repeat the@Valueannotation there. - Hard to test. If you want to test
PaymentServicewithout setting up a Spring context, you have to use reflection to set the fields. - Complex types are awkward. Injecting a
Map<String, BigDecimal>with@Valuerequires messy SpEL.
3. Declaring a Configuration Class with @ConfigurationProperties
3.1. Using a Record (Recommended)
File application.yml:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
The corresponding configuration class:
package com.nonla.payment;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
String gatewayUrl,
String merchantId,
@DefaultValue("10s") Duration timeout,
@DefaultValue("3") int maxRetries) {
}
Here’s what’s happening:
prefix = "app.payment"tells Spring that this record receives all keys underapp.payment.- Spring Boot 3 automatically uses constructor binding for records. You don’t need setters or
@ConstructorBinding(as long as the class has only one constructor). - Relaxed binding maps
gateway-url,gatewayUrl,GATEWAY_URL(or the environment variableAPP_PAYMENT_GATEWAYURL) to the same parametergatewayUrl. @DefaultValueprovides a default value when the key is missing from the configuration.- The string
5sis automatically converted toDuration.ofSeconds(5).
3.2. Registering and Using It
The record above is not yet a bean. You need to enable it in your main class:
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@ConfigurationPropertiesScan scans the current package and its subpackages for classes with @ConfigurationProperties. If you prefer to declare each class explicitly, use @EnableConfigurationProperties(PaymentProperties.class) on a @Configuration class.
Now PaymentService only needs to inject a single object:
@Service
public class PaymentService {
private final PaymentProperties props;
public PaymentService(PaymentProperties props) {
this.props = props;
}
public String describe() {
return "Calling %s with merchant %s, timeout %s, %d retries"
.formatted(props.gatewayUrl(), props.merchantId(),
props.timeout(), props.maxRetries());
}
}
The result when calling describe():
Calling https://pay.example.com/api with merchant NONLA-001, timeout PT5S, 3 retries
PaymentService is now a plain Java class. In a unit test, you only need new PaymentService(new PaymentProperties(...)) — no Spring required.
Note: Don’t add
@Componentto a record using constructor binding. Spring will then treat it as a regular bean and try to autowireString gatewayUrlas another bean. The application will fail at startup. Register it using@ConfigurationPropertiesScanor@EnableConfigurationPropertiesinstead.
3.3. JavaBean-Style Classes with Setters
If you need a mutable object, say to maintain compatibility with legacy code, you can use a class with getters and setters:
@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
private String host = "localhost";
private int port = 25;
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
}
Spring calls the default constructor, then calls setters for each key it finds. The default values are the field initializers. This works fine, but the object can be modified anywhere at runtime. That’s why records are still the safer choice.
4. Binding Complex Data Types
Expand the payment configuration to include lists, maps, and nested objects:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
max-payload: 2MB
supported-currencies:
- VND
- USD
fees:
card: 1.5
wallet: 0.8
webhook:
enabled: true
secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
String gatewayUrl,
String merchantId,
@DefaultValue("10s") Duration timeout,
@DefaultValue("3") int maxRetries,
@DefaultValue("1MB") DataSize maxPayload,
@DefaultValue({"VND"}) List<String> supportedCurrencies,
Map<String, BigDecimal> fees,
@DefaultValue Webhook webhook) {
public record Webhook(
@DefaultValue("false") boolean enabled,
String secret) {
}
}
Each parameter is bound as follows:
DataSizeunderstands units likeB,KB,MB,GB.2MBbecomesDataSize.ofMegabytes(2).List<String>accepts both YAML list format and comma-separated strings (VND,USD).Map<String, BigDecimal>uses the nested keys (card,wallet) as map keys.Webhookis a nested record, bound recursively using the prefixapp.payment.webhook.@DefaultValuewith no value onwebhooktells Spring to create aWebhookwith default values when the entirewebhookblock is missing. Without this annotation,webhook()would returnnull.
Note: For
Duration, a number without a unit is interpreted as milliseconds.timeout: 30means 30ms, not 30 seconds. Always include the unit (30s,500ms), or use@DurationUnit(ChronoUnit.SECONDS)on the parameter.
5. Validating Configuration at Startup
Add the validation starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Then mark the class with @Validated and add Jakarta Bean Validation constraints:
@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank String merchantId,
@DefaultValue("10s") Duration timeout,
@Min(0) @Max(10) @DefaultValue("3") int maxRetries,
@DefaultValue("1MB") DataSize maxPayload,
@NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
Map<String, BigDecimal> fees,
@Valid @DefaultValue Webhook webhook) {
public record Webhook(
@DefaultValue("false") boolean enabled,
String secret) {
}
}
@Validatedactivates validation right after binding completes.@Validonwebhookensures constraints insideWebhookare also validated. Without@Valid, Spring skips nested objects.
If someone leaves merchant-id blank, the application stops at startup with a clear error message:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.nonla.payment.PaymentProperties failed:
Property: app.payment.merchantId
Value: ""
Origin: class path resource [application.yml] - 4:17
Reason: must not be blank
Action:
Update your application's configuration
The system catches errors at deployment, not hours later when the first payment request fails. This is the biggest advantage over @Value.
6. IDE Hints with the Configuration Processor
Add the annotation processor:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
When you compile, the processor generates META-INF/spring-configuration-metadata.json, which describes every key under app.payment: type, default value, and description. IntelliJ IDEA and VS Code (with Spring Tools) use this file to offer key suggestions, warn about missing keys, and display documentation when you hover over a key in application.yml.
Property descriptions come from Javadoc. For records, write Javadoc using @param:
/**
* Payment gateway configuration.
*
* @param gatewayUrl Base URL of the payment gateway
* @param maxRetries Maximum number of retries when calling the gateway fails
*/
Note: If your project already declares
annotationProcessorPathsinmaven-compiler-plugin(for example, for Lombok or MapStruct), you must addspring-boot-configuration-processorto that list. Otherwise, the processor won’t run.
7. Testing the Configuration Class
You don’t need heavy @SpringBootTest. ApplicationContextRunner builds a lightweight context with just what you need:
package com.nonla.payment;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
class PaymentPropertiesTest {
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
static class TestConfig {
}
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestConfig.class);
@Test
void shouldBindValuesAndApplyDefaults() {
contextRunner
.withPropertyValues(
"app.payment.gateway-url=https://pay.example.com/api",
"app.payment.merchant-id=NONLA-001",
"app.payment.timeout=2s",
"app.payment.fees.card=1.5")
.run(context -> {
PaymentProperties props = context.getBean(PaymentProperties.class);
assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(props.maxRetries()).isEqualTo(3);
assertThat(props.supportedCurrencies()).containsExactly("VND");
assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
assertThat(props.webhook().enabled()).isFalse();
});
}
@Test
void shouldFailWhenMaxRetriesOutOfRange() {
contextRunner
.withPropertyValues(
"app.payment.gateway-url=https://pay.example.com/api",
"app.payment.merchant-id=NONLA-001",
"app.payment.max-retries=50")
.run(context -> assertThat(context)
.hasFailed()
.getFailure()
.hasRootCauseInstanceOf(BindValidationException.class));
}
}
These two tests check the following:
- The first test confirms values are bound to the correct types (
2sbecomesDuration,1.5becomesBigDecimal). It also confirms that@DefaultValueapplies tomaxRetries,supportedCurrencies, andwebhook. - The second test confirms that
max-retries=50violates@Max(10)and causes the context to fail startup with aBindValidationException.
Each test runs in a few hundred milliseconds because there’s no web server or other auto-configuration.
8. When to Still Use @Value
@ConfigurationProperties doesn’t completely replace @Value. You can still use @Value when:
- You need SpEL. For example,
@Value("#{${app.payment.timeout-seconds} * 1000}").@ConfigurationPropertiesdoesn’t support SpEL expressions. - You only need a single value used in just one place. For example,
@Value("${spring.application.name}")in a logging bean. Creating a whole record for this is overkill.
Common mistakes when switching to @ConfigurationProperties:
- Forgetting to register the class. Missing
@ConfigurationPropertiesScanor@EnableConfigurationPropertiescauses “No qualifying bean of type PaymentProperties”. - Wrong prefix convention. The prefix must be in kebab-case and lowercase (
app.payment, notapp.Payment). If you get it wrong, Spring errors at startup. - Multiple constructors. If your record or class has more than one constructor, you must mark the constructor used for binding with
@ConstructorBinding. - Using one class for everything. Don’t cram the entire application configuration into one massive
AppPropertiesclass. Split it by domain:PaymentProperties,MailProperties,StorageProperties…
Note: Configuration records are immutable. If you use Spring Cloud to refresh configuration at runtime, the bean must be recreated (via
@RefreshScopeor rebinding) for new values to take effect. Don’t expect the record to change by itself.
9. Conclusion
In this guide, you replaced scattered @Value annotations with a typed PaymentProperties record. Specifically, you:
- bound
Duration,DataSize,List,Map, and nested records, with defaults via@DefaultValue; - validated configuration at startup using
@Validatedwith Jakarta constraints; - got IDE key suggestions thanks to
spring-boot-configuration-processor; - tested the configuration quickly with
ApplicationContextRunner.
For any configuration group with two or more keys, @ConfigurationProperties should be your default choice. Reserve @Value for individual values or when you really need SpEL.