SupportWriter space ↗
← Back to the journal
Spring Boot

Type-safe configuration in Spring Boot with @ConfigurationProperties

Replace scattered @Value annotations with validated, immutable configuration classes using @ConfigurationProperties and Java records.

DPutu Adi Guna Permana · 05 Oct 2026 · 6 min read

The problem with @Value everywhere

Most Spring Boot apps start reading configuration like this:

@Value("${payment.gateway.url}")
private String gatewayUrl;

@Value("${payment.gateway.timeout:5s}")
private Duration timeout;

It works, but as the app grows the same keys get repeated in many classes, typos only show up at runtime, defaults are scattered, and there is no single place that documents what can be configured.

@ConfigurationProperties solves this by binding a whole group of related settings to one strongly typed object. The examples here target Spring Boot 3.x or later with Java 17+.

Defining properties with a record

Start with the configuration in application.yml:

payment:
  gateway:
    url: https://sandbox.payments.example.com
    api-key: ${PAYMENT_API_KEY}
    timeout: 10s
    max-retries: 3
    supported-currencies:
      - IDR
      - USD
      - EUR

Then describe it with a record:

import java.net.URI;
import java.time.Duration;
import java.util.List;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "payment.gateway")
public record PaymentGatewayProperties(
        URI url,
        String apiKey,
        @DefaultValue("5s") Duration timeout,
        @DefaultValue("2") int maxRetries,
        List<String> supportedCurrencies
) {}

A few things happen automatically:

  • Constructor binding. Records are bound through their canonical constructor, so the object is immutable once created.
  • Relaxed binding. api-key, apiKey, api_key, and the environment variable PAYMENT_GATEWAY_APIKEY all map to apiKey.
  • Type conversion. 10s becomes a Duration, the URL becomes a URI, and the YAML list becomes a List<String>.
  • Defaults. @DefaultValue provides a fallback when a key is missing.

Registering the properties

Spring needs to know about the class. The simplest option is to scan for all properties classes once:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Alternatively, register specific classes with @EnableConfigurationProperties(PaymentGatewayProperties.class) on a @Configuration class. This is common in libraries and auto-configurations where you want explicit control.

Using the properties

Inject the record like any other bean:

@Service
class PaymentClient {

    private final RestClient restClient;
    private final PaymentGatewayProperties properties;

    PaymentClient(RestClient.Builder builder, PaymentGatewayProperties properties) {
        this.properties = properties;
        this.restClient = builder.baseUrl(properties.url().toString())
                .defaultHeader("X-Api-Key", properties.apiKey())
                .build();
    }

    boolean supports(String currency) {
        return properties.supportedCurrencies().contains(currency);
    }
}

All payment settings now live in one type. Your IDE can find every usage, and renaming a property is a refactoring instead of a search-and-replace.

Fail fast with validation

A missing API key should stop the application at startup, not cause a confusing error on the first payment. Add spring-boot-starter-validation and annotate the record with @Validated:

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "payment.gateway")
public record PaymentGatewayProperties(
        @NotNull URI url,
        @NotBlank String apiKey,
        @DefaultValue("5s") Duration timeout,
        @DefaultValue("2") @Min(0) @Max(10) int maxRetries,
        @NotEmpty List<String> supportedCurrencies
) {}

If PAYMENT_API_KEY is not set, startup fails with a clear message:

Binding to target payment.gateway failed:

    Property: payment.gateway.apiKey
    Value: ""
    Reason: must not be blank

That is exactly the kind of error you want to see during deployment rather than in production traffic.

Nested groups

Larger configurations can be split into nested records:

app:
  mail:
    from: no-reply@example.com
    smtp:
      host: smtp.example.com
      port: 587
  storage:
    bucket: user-uploads
    max-file-size: 10MB
@ConfigurationProperties(prefix = "app")
public record AppProperties(Mail mail, Storage storage) {

    public record Mail(String from, Smtp smtp) {
        public record Smtp(String host, @DefaultValue("587") int port) {}
    }

    public record Storage(String bucket, @DefaultValue("5MB") DataSize maxFileSize) {}
}

DataSize understands values like 10MB or 512KB, just as Duration understands 30s, 5m, or 2h.

IDE autocompletion with the configuration processor

Add the annotation processor to generate metadata for your properties:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

After a build, IntelliJ IDEA and VS Code (with the Spring Boot extension) offer autocompletion and inline documentation for payment.gateway.* in application.yml. Javadoc comments on record components become the property descriptions. With Gradle, declare it as annotationProcessor "org.springframework.boot:spring-boot-configuration-processor".

Profiles and environment-specific values

Combine properties classes with profiles for per-environment values:

# application.yml (defaults)
payment:
  gateway:
    url: https://sandbox.payments.example.com
    supported-currencies: [IDR, USD]

---
spring:
  config:
    activate:
      on-profile: production
payment:
  gateway:
    url: https://api.payments.example.com
    max-retries: 5

Secrets such as api-key should come from environment variables or a secret manager, never from a committed file. Because of relaxed binding, setting PAYMENT_GATEWAY_APIKEY in your container or CI pipeline is enough.

Testing configuration

Properties classes are easy to test. ApplicationContextRunner starts a tiny context without the rest of your app:

class PaymentGatewayPropertiesTest {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withUserConfiguration(Config.class);

    @Test
    void bindsDurationsAndDefaults() {
        runner.withPropertyValues(
                        "payment.gateway.url=https://example.com",
                        "payment.gateway.api-key=test",
                        "payment.gateway.supported-currencies=IDR")
                .run(context -> {
                    var properties = context.getBean(PaymentGatewayProperties.class);
                    assertThat(properties.timeout()).isEqualTo(Duration.ofSeconds(5));
                    assertThat(properties.maxRetries()).isEqualTo(2);
                });
    }

    @EnableConfigurationProperties(PaymentGatewayProperties.class)
    static class Config {}
}

When @Value is still fine

@Value remains handy for a single, isolated value, such as a feature flag used in one place. Reach for @ConfigurationProperties as soon as you have a group of related settings, need validation, or want the configuration to be documented and discoverable.

Exercise

Create a RateLimitProperties record bound to app.rate-limit with requests-per-minute (default 60, between 1 and 10,000) and a Duration called ban-duration (default 15 minutes). Validate it, use it in a filter, and write an ApplicationContextRunner test proving that an invalid value stops the context from starting.

← Explore more notes