Externalized Configuration with @ConfigurationProperties
@ConfigurationProperties binds external configuration to a typed Java class — replacing scattered @Value annotations with a single, validated, testable object.
Why @ConfigurationProperties Over @Value
// @Value — scattered, no type safety, no validation
@Service
public class PaymentService {
@Value("${payment.gateway.url}") private String gatewayUrl;
@Value("${payment.gateway.timeout:5000}") private int timeoutMs;
@Value("${payment.gateway.api-key}") private String apiKey;
@Value("${payment.gateway.max-retries:3}") private int maxRetries;
}
// @ConfigurationProperties — one place, typed, validated
@Service
public class PaymentService {
private final PaymentProperties properties;
// all config in one place, injected as a single object
}
@ConfigurationProperties gives you:
- Type-safe binding with automatic conversion
- Validation with JSR-303 annotations
- IDE auto-completion in
application.yml - Testable — easy to instantiate in unit tests
Basic Usage
@ConfigurationProperties(prefix = "payment.gateway")
public record PaymentProperties(
String url,
int timeoutMs,
String apiKey,
int maxRetries
) {}
# application.yml
payment:
gateway:
url: https://api.payment.com
timeout-ms: 5000 # Spring converts kebab-case → camelCase
api-key: sk-abc123
max-retries: 3
Register it:
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class OrderServiceApplication { }
Or use @ConfigurationPropertiesScan to auto-discover all:
@SpringBootApplication
@ConfigurationPropertiesScan // scans for all @ConfigurationProperties in the package
public class OrderServiceApplication { }
Inject it like any bean:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentProperties properties;
public PaymentResponse charge(ChargeRequest request) {
return httpClient
.timeout(Duration.ofMillis(properties.timeoutMs()))
.post(properties.url() + "/charge")
.header("Authorization", "Bearer " + properties.apiKey())
.body(request)
.send();
}
}
Nested Configuration
For complex configuration trees, nest records:
@ConfigurationProperties(prefix = "app")
public record AppProperties(
String name,
String version,
DatabaseProperties database,
SecurityProperties security,
FeatureFlags features
) {
public record DatabaseProperties(
int maxPoolSize,
Duration connectionTimeout,
Duration idleTimeout,
boolean autoMigrate
) {}
public record SecurityProperties(
Duration jwtExpiration,
Duration refreshTokenExpiration,
String issuer,
List<String> allowedOrigins
) {}
public record FeatureFlags(
boolean newCheckoutFlow,
boolean aiRecommendations,
boolean betaFeatures
) {}
}
app:
name: order-service
version: 1.2.3
database:
max-pool-size: 20
connection-timeout: 30s
idle-timeout: 10m
auto-migrate: true
security:
jwt-expiration: 15m
refresh-token-expiration: 7d
issuer: https://auth.devopsmonk.com
allowed-origins:
- https://app.devopsmonk.com
- https://admin.devopsmonk.com
features:
new-checkout-flow: true
ai-recommendations: false
beta-features: false
Spring automatically converts:
30s→Duration.ofSeconds(30)10m→Duration.ofMinutes(10)7d→Duration.ofDays(7)500MB→DataSize.ofMegabytes(500)
Validation
Add Bean Validation annotations to validate configuration at startup:
@ConfigurationProperties(prefix = "payment.gateway")
@Validated
public record PaymentProperties(
@NotBlank String url,
@Min(100) @Max(30000) int timeoutMs,
@NotBlank @Pattern(regexp = "sk-.*") String apiKey,
@Min(0) @Max(10) int maxRetries,
@Valid RateLimitProperties rateLimit
) {
public record RateLimitProperties(
@Min(1) int requestsPerSecond,
@Min(1) int burstCapacity
) {}
}
If any constraint fails, the application refuses to start:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target org.springframework.boot.context.properties.bind.BindException:
Failed to bind properties under 'payment.gateway' to PaymentProperties:
Property: payment.gateway.api-key
Value: ""
Reason: must not be blank
Fail fast at startup is much better than failing in production mid-request.
Custom Validation
@ConfigurationProperties(prefix = "cache")
@Validated
public record CacheProperties(
@Min(1) int maxSize,
Duration ttl,
Duration refreshAfterWrite
) implements Validatable {
@AssertTrue(message = "refresh-after-write must be less than ttl")
public boolean isRefreshBeforeTtl() {
if (refreshAfterWrite == null || ttl == null) return true;
return refreshAfterWrite.compareTo(ttl) < 0;
}
}
Or use a custom validator:
@Component
public class CachePropertiesValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return CacheProperties.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
CacheProperties props = (CacheProperties) target;
if (props.refreshAfterWrite() != null && props.ttl() != null) {
if (props.refreshAfterWrite().compareTo(props.ttl()) >= 0) {
errors.rejectValue("refreshAfterWrite",
"invalid",
"refresh-after-write must be less than ttl");
}
}
}
}
Collections and Maps
@ConfigurationProperties(prefix = "notification")
public record NotificationProperties(
List<String> adminEmails,
Map<String, String> templates,
Map<String, ChannelConfig> channels
) {
public record ChannelConfig(
boolean enabled,
String endpoint,
int retries
) {}
}
notification:
admin-emails:
- ops@devopsmonk.com
- alerts@devopsmonk.com
templates:
order-created: "Your order {orderId} has been placed"
order-shipped: "Your order {orderId} is on its way"
channels:
email:
enabled: true
endpoint: smtp://mail.devopsmonk.com
retries: 3
sms:
enabled: false
endpoint: https://sms.provider.com/api
retries: 2
slack:
enabled: true
endpoint: https://hooks.slack.com/services/T00/B00/xxxx
retries: 1
Using @ConfigurationProperties as a Bean
Annotate the class with @Component directly (alternative to @EnableConfigurationProperties):
@Component
@ConfigurationProperties(prefix = "email")
public record EmailProperties(
String host,
int port,
String username,
String password,
boolean ssl,
Duration connectionTimeout
) {}
Profile-Specific Overrides
# application.yml (defaults)
payment:
gateway:
url: https://sandbox.payment.com
timeout-ms: 5000
max-retries: 3
---
spring:
config:
activate:
on-profile: prod
payment:
gateway:
url: https://api.payment.com
timeout-ms: 3000
max-retries: 5
IDE Auto-Completion
Generate metadata for IDE auto-completion:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
This generates META-INF/spring-configuration-metadata.json at compile time. IntelliJ IDEA and VS Code use it to auto-complete property names and show documentation in application.yml.
Testing Configuration
Test your properties in isolation without starting the full application:
@SpringBootTest(properties = {
"payment.gateway.url=https://test.payment.com",
"payment.gateway.timeout-ms=1000",
"payment.gateway.api-key=sk-test",
"payment.gateway.max-retries=1"
})
class PaymentPropertiesTest {
@Autowired PaymentProperties properties;
@Test
void propertiesAreLoadedCorrectly() {
assertThat(properties.url()).isEqualTo("https://test.payment.com");
assertThat(properties.timeoutMs()).isEqualTo(1000);
assertThat(properties.apiKey()).isEqualTo("sk-test");
assertThat(properties.maxRetries()).isEqualTo(1);
}
}
Or use @ConfigurationPropertiesTest (lighter — no full Spring context):
@ConfigurationPropertiesTest
class AppPropertiesTest {
@EnableConfigurationProperties(AppProperties.class)
@TestConfiguration
static class Config {}
@Autowired AppProperties properties;
@Test
void featureFlagsDefaultToFalse() {
assertThat(properties.features().betaFeatures()).isFalse();
}
}
For the lightest test — just bind properties directly:
class PaymentPropertiesValidationTest {
@Test
void rejectsBlankApiKey() {
var binder = new Binder(new MapConfigurationPropertySource(Map.of(
"payment.gateway.url", "https://api.payment.com",
"payment.gateway.timeout-ms", "5000",
"payment.gateway.api-key", "", // blank — should fail
"payment.gateway.max-retries", "3"
)));
assertThatThrownBy(() ->
binder.bindOrCreate("payment.gateway", PaymentProperties.class))
.isInstanceOf(BindException.class);
}
}
Full Example: Order Service Configuration
@ConfigurationProperties(prefix = "order-service")
@Validated
public record OrderServiceProperties(
@Valid ApiProperties api,
@Valid DatabaseProperties database,
@Valid PaymentProperties payment,
@Valid NotificationProperties notification
) {
public record ApiProperties(
@Min(1) @Max(1000) int maxPageSize,
@Min(1) int defaultPageSize,
@NotBlank String baseUrl
) {}
public record DatabaseProperties(
@Min(2) @Max(100) int maxPoolSize,
@NotNull Duration connectionTimeout,
boolean slowQueryLoggingEnabled,
@Min(100) int slowQueryThresholdMs
) {}
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank @Pattern(regexp = "sk-.*") String apiKey,
@NotNull Duration timeout,
@Min(0) @Max(5) int maxRetries
) {}
public record NotificationProperties(
boolean enabled,
@Email String fromEmail,
@NotBlank String fromName
) {}
}
order-service:
api:
max-page-size: 100
default-page-size: 20
base-url: https://api.devopsmonk.com
database:
max-pool-size: 20
connection-timeout: 30s
slow-query-logging-enabled: true
slow-query-threshold-ms: 500
payment:
gateway-url: https://api.payment.com
api-key: ${PAYMENT_API_KEY} # from environment variable
timeout: 5s
max-retries: 3
notification:
enabled: true
from-email: noreply@devopsmonk.com
from-name: DevOpsMonk Platform
Notice ${PAYMENT_API_KEY} — Spring resolves environment variables inline. Secrets stay out of config files.
@ConfigurationProperties vs @Value — When to Use Each
| Scenario | Use |
|---|---|
| Multiple related properties | @ConfigurationProperties |
| Single property | Either (both are fine) |
| Nested configuration | @ConfigurationProperties |
| Validation required | @ConfigurationProperties |
| IDE auto-completion needed | @ConfigurationProperties |
| Dynamic SpEL expression | @Value("#{...}") |
| One-off property in a utility class | @Value is simpler |
What You’ve Learned
@ConfigurationProperties(prefix = "...")binds all matching properties to a typed class- Java records work perfectly — compact, immutable, auto-generates
accessor()methods - Nested records map to nested YAML objects automatically
@Validated+ JSR-303 annotations fail fast at startup if configuration is wrong@ConfigurationPropertiesScanauto-discovers all@ConfigurationPropertiesclasses- Spring converts
Duration(30s,5m),DataSize(100MB), and lists/maps automatically - Add
spring-boot-configuration-processorfor IDE auto-completion inapplication.yml - Test properties with
@SpringBootTest(properties = {...})or by binding directly withBinder
Next: Article 37 — Graceful Shutdown and Production Readiness — shutdown hooks, health checks during shutdown, deployment checklist.