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:

  • 30sDuration.ofSeconds(30)
  • 10mDuration.ofMinutes(10)
  • 7dDuration.ofDays(7)
  • 500MBDataSize.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

ScenarioUse
Multiple related properties@ConfigurationProperties
Single propertyEither (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
  • @ConfigurationPropertiesScan auto-discovers all @ConfigurationProperties classes
  • Spring converts Duration (30s, 5m), DataSize (100MB), and lists/maps automatically
  • Add spring-boot-configuration-processor for IDE auto-completion in application.yml
  • Test properties with @SpringBootTest(properties = {...}) or by binding directly with Binder

Next: Article 37 — Graceful Shutdown and Production Readiness — shutdown hooks, health checks during shutdown, deployment checklist.