“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) { }
}Register it, either explicitly or by scanning:
@SpringBootApplication
@ConfigurationPropertiesScan // finds every @ConfigurationProperties class under this package
public class App { }Then supply values:
app:
payment:
base-url: https://payments.internal.example.com
read-timeout: 3s
max-retries: 3
retry:
backoff: 500msBoot’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;
}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; }
}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@ConfigurationProperties("app.upload")
public record UploadProperties(DataSize maxSize, List<String> allowedTypes) { }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 1The 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=4sNote 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)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) { }@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.
@ConfigurationPropertiesalone does nothing; use@ConfigurationPropertiesScan,@EnableConfigurationProperties(X.class), or a@Componenton a mutable class. - Expecting
@Validatedto work without a validator. Withoutspring-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
@ValueSpEL defaults everywhere. Defaults scatter across classes and drift. Use@DefaultValuein one record. - Assuming
BASE_URLmaps tobase-url. It maps tobase.url. The environment variable isBASEURL. - 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());
}
}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) { }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.