Ch. 8 · Spring Boot

Spring Configuration Properties and Validation

Bind Spring Boot settings to typed @ConfigurationProperties records, validate them at startup, and know when @Value is still the right tool.

~6 min readbeginnerupdated Oct 6, 2026

“What is the difference between @Value and @ConfigurationProperties?” comes up in almost every Spring Boot interview. The surface answer is “one binds a single value, the other binds a group”, but interviewers are listening for why that matters: type safety, validation at startup, relaxed binding from environment variables, and immutability. A misconfigured timeout that is discovered at 3 a.m. on the first real request is the story behind this question.

Before you start

You should know how application.yml and environment variables feed into a Spring Boot application, and what a Java record is. Jakarta Bean Validation annotations such as @NotNull and @Min will appear; you need spring-boot-starter-validation on the classpath for them to be enforced. Examples target Spring Boot 3.x and Java 17+.

The short answer

@ConfigurationProperties(prefix = "app.payment") binds every property under that prefix into a typed object, ideally an immutable record. Boot converts strings into Duration, DataSize, enums, lists and nested objects, applies relaxed binding so APP_PAYMENT_TIMEOUT and app.payment.timeout both work, and, with @Validated, fails startup if a value breaks a constraint. @Value is fine for one-off values but gives you no grouping, no validation and SpEL-only defaults.

How it works

Define the properties as a record. Since Spring Boot 3.0, a record (or a class with a single constructor) is bound through its constructor without any extra annotation.

@ConfigurationProperties(prefix = "app.payment")
@Validated
public record PaymentProperties(
        @NotNull URI baseUrl,
        @DefaultValue("2s") Duration connectTimeout,
        @DefaultValue("5s") Duration readTimeout,
        @Min(1) @Max(10) int maxRetries,
        @Valid @DefaultValue Retry retry) {   // empty @DefaultValue: create Retry even with no retry.* keys

    public record Retry(@DefaultValue("200ms") Duration backoff) { }
}
java

Register it, either explicitly or by scanning:

@SpringBootApplication
@ConfigurationPropertiesScan          // finds every @ConfigurationProperties class under this package
public class App { }
java

Then supply values:

app:
  payment:
    base-url: https://payments.internal.example.com
    read-timeout: 3s
    max-retries: 3
    retry:
      backoff: 500ms
yaml

Boot’s Binder walks the record’s constructor parameters, maps readTimeout to read-timeout (kebab case is the canonical form), converts 3s with the Duration converter, and calls the constructor. If @Validated is present and a validator exists, it validates the object before it becomes a bean. Any service can then inject PaymentProperties like any other dependency.

Step-by-step walkthrough

Step 1: Replace scattered @Value fields

A typical starting point looks like this:

@Service
class PaymentClient {
    @Value("${payment.url}") String url;
    @Value("${payment.timeout:5000}") int timeoutMs;     // millis? seconds? the name has to tell you
    @Value("${payment.retries:3}") int retries;
}
java

Three problems: the same key may be repeated in other classes with different defaults, nothing stops timeoutMs being negative, and the unit lives in a naming convention. Move all three into the record above and inject it.

@Service
class PaymentClient {
    private final PaymentProperties props;
    PaymentClient(PaymentProperties props) { this.props = props; }
}
java

Step 2: Use meaningful types

Duration accepts 500ms, 3s, 1m or ISO-8601 PT3S; a bare number defaults to milliseconds unless you add @DurationUnit(ChronoUnit.SECONDS). DataSize accepts 10MB. Enums bind case-insensitively. Lists bind from YAML sequences or comma-separated strings.

app:
  upload:
    max-size: 10MB
    allowed-types: image/png,image/jpeg
yaml
@ConfigurationProperties("app.upload")
public record UploadProperties(DataSize maxSize, List<String> allowedTypes) { }
java

Step 3: Validate at startup

Add spring-boot-starter-validation and @Validated. Leave max-retries at 0 and the application refuses to start:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.example.PaymentProperties failed:

    Property: app.payment.maxRetries
    Value: "0"
    Origin: class path resource [application.yml] - 7:18
    Reason: must be greater than or equal to 1
Text

The origin line points to the exact file and line. Fail-fast here is the entire point: a deploy with bad configuration stops in the pipeline rather than serving errors.

Step 4: Override from the environment

Relaxed binding maps environment variables by uppercasing, replacing dots with underscores and removing dashes. In a container you set:

APP_PAYMENT_BASEURL=https://payments.prod.example.com
APP_PAYMENT_READTIMEOUT=4s
Terminal

Note that base-url becomes BASEURL, not BASE_URL: dashes are removed, and underscores stand for dots. Lists can be indexed as APP_UPLOAD_ALLOWEDTYPES_0.

Worked scenario

A team reads a downstream timeout like this:

@Value("${inventory.timeout}")
private long timeout;   // used as Duration.ofSeconds(timeout)
java

In production, an operator sets INVENTORY_TIMEOUT=3000, assuming milliseconds like every other timeout in the platform. The service now waits 50 minutes for a hung inventory call, every Tomcat thread blocks, and the service becomes unresponsive. Nothing failed at startup because 3000 is a perfectly valid long.

The fix moves the setting to a typed, validated record:

@ConfigurationProperties("inventory")
@Validated
public record InventoryProperties(
        @NotNull @DurationMax(seconds = 30) Duration timeout) { }
java

@DurationMax comes from Hibernate Validator (org.hibernate.validator.constraints.time). Now INVENTORY_TIMEOUT=3000 means 3000 milliseconds because Duration defaults to milliseconds, 3s is accepted explicitly, and a value above 30 seconds stops startup with a clear message. The unit ambiguity is gone and the guard rail is in code.

Common mistake

  • Forgetting to register the class. @ConfigurationProperties alone does nothing; use @ConfigurationPropertiesScan, @EnableConfigurationProperties(X.class), or a @Component on a mutable class.
  • Expecting @Validated to work without a validator. Without spring-boot-starter-validation, constraints are silently ignored.
  • Mutable JavaBean properties with setters for configuration that should never change at runtime. Records make the object immutable and thread safe.
  • Putting @Value SpEL defaults everywhere. Defaults scatter across classes and drift. Use @DefaultValue in one record.
  • Assuming BASE_URL maps to base-url. It maps to base.url. The environment variable is BASEURL.
  • Nested objects without @Valid. Constraints inside a nested record are only checked if the field is annotated @Valid.

Verify the behavior

ApplicationContextRunner proves both binding and validation without starting the whole application.

class PaymentPropertiesTest {

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

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration.class))
            .withUserConfiguration(Config.class);

    @Test
    void bindsDurationsAndDefaults() {
        runner.withPropertyValues("app.payment.base-url=https://x.test", "app.payment.max-retries=2")
              .run(ctx -> {
                  PaymentProperties p = ctx.getBean(PaymentProperties.class);
                  assertThat(p.connectTimeout()).isEqualTo(Duration.ofSeconds(2));
                  assertThat(p.retry().backoff()).isEqualTo(Duration.ofMillis(200));
              });
    }

    @Test
    void rejectsZeroRetries() {
        runner.withPropertyValues("app.payment.base-url=https://x.test", "app.payment.max-retries=0")
              .run(ctx -> assertThat(ctx).hasFailed());
    }
}
java

Adding spring-boot-configuration-processor as an annotation processor also generates metadata so IDEs autocomplete your keys and flag typos.

Follow-up questions

What is relaxed binding? Boot accepts read-timeout, readTimeout, read_timeout and READTIMEOUT (environment) for the same property. Use kebab case in files and annotations.

What is the property source precedence? Command-line arguments beat environment variables, which beat profile-specific files, which beat application.yml. Test properties beat almost everything.

Can @ConfigurationProperties be refreshed at runtime? Not by Boot itself. Spring Cloud’s @RefreshScope can rebind beans, but immutable records plus a restart is simpler to reason about.

When would you still use @Value? For a single value used in one place, or when you need a SpEL expression.

Interview exercise

Design configuration for an email client that needs a host, port (1 to 65535), optional TLS (default on), a connection timeout, and a list of allowed sender domains with at least one entry. Show how an operator overrides the host in Kubernetes.

Answer and reasoning

@ConfigurationProperties("app.mail")
@Validated
public record MailProperties(
        @NotBlank String host,
        @Min(1) @Max(65535) int port,
        @DefaultValue("true") boolean tls,
        @DefaultValue("5s") Duration connectTimeout,
        @NotEmpty List<@NotBlank String> allowedSenderDomains) { }
java

Register it with @ConfigurationPropertiesScan, and the operator sets APP_MAIL_HOST=smtp.prod.internal in the pod spec. The reasoning: a record keeps it immutable, the constraints turn bad configuration into a startup failure with an origin, Duration removes unit guessing, and @DefaultValue keeps defaults in one place. Container-element constraints such as List<@NotBlank String> validate each entry, not only the list.

Continue learning

More in Spring Boot

read ✓Spring Boot · mid

Spring Bean Validation Groups

Validate create and update requests differently with groups, avoid silently skipping Default constraints, and order checks with @GroupSequence.

~6 min readread →
esc