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.gatewayUrl instead of gateway-url and you only find out about the mistake at runtime.
  • No validation. max-retries=-5 gets injected without any complaints.
  • Configuration is scattered. If RefundService also needs merchant-id, you have to repeat the @Value annotation there.
  • Hard to test. If you want to test PaymentService without setting up a Spring context, you have to use reflection to set the fields.
  • Complex types are awkward. Injecting a Map<String, BigDecimal> with @Value requires messy SpEL.

3. Declaring a Configuration Class with @ConfigurationProperties

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 under app.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 variable APP_PAYMENT_GATEWAYURL) to the same parameter gatewayUrl.
  • @DefaultValue provides a default value when the key is missing from the configuration.
  • The string 5s is automatically converted to Duration.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 @Component to a record using constructor binding. Spring will then treat it as a regular bean and try to autowire String gatewayUrl as another bean. The application will fail at startup. Register it using @ConfigurationPropertiesScan or @EnableConfigurationProperties instead.

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:

  • DataSize understands units like B, KB, MB, GB. 2MB becomes DataSize.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.
  • Webhook is a nested record, bound recursively using the prefix app.payment.webhook.
  • @DefaultValue with no value on webhook tells Spring to create a Webhook with default values when the entire webhook block is missing. Without this annotation, webhook() would return null.

Note: For Duration, a number without a unit is interpreted as milliseconds. timeout: 30 means 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) {
    }
}
  • @Validated activates validation right after binding completes.
  • @Valid on webhook ensures constraints inside Webhook are 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 annotationProcessorPaths in maven-compiler-plugin (for example, for Lombok or MapStruct), you must add spring-boot-configuration-processor to 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 (2s becomes Duration, 1.5 becomes BigDecimal). It also confirms that @DefaultValue applies to maxRetries, supportedCurrencies, and webhook.
  • The second test confirms that max-retries=50 violates @Max(10) and causes the context to fail startup with a BindValidationException.

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}"). @ConfigurationProperties doesn’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 @ConfigurationPropertiesScan or @EnableConfigurationProperties causes “No qualifying bean of type PaymentProperties”.
  • Wrong prefix convention. The prefix must be in kebab-case and lowercase (app.payment, not app.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 AppProperties class. 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 @RefreshScope or 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 @Validated with 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.

spring-bootconfigurationconfigurationpropertiesvalidationjava