@Value: An Engineering Opinion on Configuration in Spring Boot
Configuration is one of the most underrated parts of code. As long as the application runs, the matter is settled. As a result, @Value is scattered everywhere — in services, in controllers, even in utility classes — and nobody minds until six months later when the team has to rename one property and finds itself having to grep the entire codebase. The problem isn’t that @Value is bad. The problem is that @Value is too easy, so it’s often used without realizing its long-term consequences. This article discusses configuration not just from the how side of using it, but from the why side of how the wrong choice can become hidden technical debt that’s expensive to pay.
Configuration Is a Contract, Not Just Values
Before discussing code, there’s a mindset shift that needs to happen. Configuration isn’t a regular variable that happens to be stored outside the binary. Engineering-wise, configuration is:
Configuration = Contract between the application and its environment
This means configuration has the same consequences as a public API: changes must be conscious, structured, and documented. If configuration is treated like random strings thrown into fields without structure, the application design degrades along with it. Misconfig bugs aren’t immediately visible — they only explode at runtime, often in production.
flowchart LR
subgraph "Configuration as Random Strings"
A1["@Value scattered<br/>across 12 different classes"] --> B1["One property rename<br/>= grep the entire codebase"]
B1 --> C1["Wrong config<br/>= crash at runtime"]
end
subgraph "Configuration as a Contract"
A2["One class/record<br/>per config domain"] --> B2["Changes localized<br/>in one place"]
B2 --> C2["Validation at startup<br/>not at runtime"]
end@Value — Convenient at the Start, Expensive at the End
@Value is the easiest way to take a value from application.properties or application.yaml. Spring injects the value directly into a field using SpEL (Spring Expression Language).
// Looks neat — but this is the start of a problem
@Service
public class JwtService {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expired}")
private long expired;
}
The problem doesn’t appear on the first day. The problem appears six months later:
// ANTI-PATTERN: @Value scattered across many classes
@Service
public class JwtService {
@Value("${jwt.secret}")
private String jwtSecret; // duplicated in TokenValidator.java
}
@Component
public class TokenValidator {
@Value("${jwt.secret}") // duplicate — no single source of truth
private String secret;
}
@Component
public class RefreshTokenService {
@Value("${jwt.expired}") // this property was renamed to jwt.access.expired
private long expired; // will crash at startup — but only here
}
Four Structural Problems with @Value
Configuration scattering. There’s no single place describing the entire configuration structure. New developers must grep the whole codebase to find which properties are used.
No type contract. @Value is always a String until runtime. Nothing prevents you from writing @Value("${jwt.expired}") into a String-typed field even though what’s stored is a number — the error only appears when that field is first used.
High-risk refactors. Renaming one property in YAML means finding and replacing all the scattered @Value("${name.property}") occurrences. Missed one? Crash at runtime, not at compile time.
Near-zero validation. If a property doesn’t exist in the config file and has no default value, Spring fails startup — but without a clear error message about what’s missing.
When @Value Is Still Acceptable
Use @Value if:
✓ A single standalone value (boolean feature flag)
✓ PoCs, prototypes, or internal tooling
✓ A property genuinely used in only one place
Avoid @Value if:
✗ The configuration has more than 2–3 interrelated properties
✗ The property is used in more than one class
✗ The application will be managed by a team and grow long-term
✗ Values need validation (format, range, existence)
@ConfigurationProperties — Configuration as a Domain
If @Value is a shortcut, @ConfigurationProperties is a design decision. By defining a configuration class, you explicitly declare: “This configuration is one complete domain, and I’m responsible for its structure.”
# application.yaml
jwt:
secret: my-super-secret-key-minimum-256-bits
access-token:
expired: 15m
refresh-token:
expired: 7d
// CORRECT: one class for one configuration domain
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtProperties {
private String secret;
private Duration accessTokenExpired;
private Duration refreshTokenExpired;
// getters and setters...
}
The change is immediately felt:
// ANTI-PATTERN: inject @Value in every class needing JWT config
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token.expired}")
private Duration accessTokenExpired;
}
// CORRECT: inject one properties object, everything is available
@Service
public class JwtService {
private final JwtProperties jwtProperties;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
public String generateToken(String subject) {
return Jwts.builder()
.setSubject(subject)
.setExpiration(Date.from(
Instant.now().plus(jwtProperties.getAccessTokenExpired())
))
.signWith(Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes()))
.compact();
}
}
Validation with @Validated
One of @ConfigurationProperties’s biggest advantages is its ability to combine with Bean Validation to validate configuration at startup — not at runtime:
@ConfigurationProperties(prefix = "jwt")
@Component
@Validated
public class JwtProperties {
@NotBlank(message = "JWT secret must not be empty")
@Size(min = 32, message = "JWT secret must be at least 32 characters for security")
private String secret;
@NotNull(message = "Access token expiry must be configured")
@DurationMin(minutes = 5, message = "Access token must be at least 5 minutes")
private Duration accessTokenExpired;
@NotNull(message = "Refresh token expiry must be configured")
private Duration refreshTokenExpired;
// getters and setters...
}
With @Validated, the application refuses to start if the configuration is invalid. This is far better than letting the application run with wrong configuration and crash in the middle of business processes.
Fail-fast is an important principle in designing resilient systems. Validating configuration at startup — not when first used — is one of the most concrete implementations of this principle. Configuration errors detected at startup are far easier to debug than ones that only appear in production after traffic comes in.
record — Immutable Configuration
@ConfigurationProperties with a regular class still leaves one weakness: configuration is mutable. There are setters, the possibility of state changing, the possibility of being misused as a place to store runtime state. Java record closes this gap.
Configuration should be read at startup and not change for the application’s lifetime. record enforces this discipline structurally — no setters, no mutation, no ambiguity.
// ANTI-PATTERN: a mutable class that can be changed after injection
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtProperties {
private String secret;
private Duration accessTokenExpired;
// setters exist — nothing prevents this from being called from anywhere
public void setSecret(String secret) { this.secret = secret; }
public void setAccessTokenExpired(Duration d) { this.accessTokenExpired = d; }
}
// CORRECT: record — immutable by design
@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
String secret,
Duration accessTokenExpired,
Duration refreshTokenExpired
) {}
With a record, configuration becomes self-documented: anyone reading the declaration immediately knows all available fields, their types, and doesn’t need to trace setters to understand the structure.
// Using a record is cleaner — access via accessor methods, not getters
@Service
public class JwtService {
private final JwtProperties jwt;
public JwtService(JwtProperties jwt) {
this.jwt = jwt;
}
public String generateAccessToken(String subject) {
return Jwts.builder()
.setSubject(subject)
.setExpiration(Date.from(Instant.now().plus(jwt.accessTokenExpired())))
.signWith(Keys.hmacShaKeyFor(jwt.secret().getBytes()))
.compact();
}
}
Validation on record
record can still be combined with @Validated:
@ConfigurationProperties(prefix = "jwt")
@Validated
public record JwtProperties(
@NotBlank @Size(min = 32)
String secret,
@NotNull
Duration accessTokenExpired,
@NotNull
Duration refreshTokenExpired
) {}
To userecordwith@ConfigurationProperties, make sure Spring Boot is version 2.6 or above and there’s no@Componenton therecord— just register it via@EnableConfigurationProperties(JwtProperties.class)on a configuration class, or via@ConfigurationPropertiesScanon the main class.
Automatic Name Conversion (Relaxed Binding)
Spring Boot normalizes property names automatically — this is called relaxed binding. All the following formats will bind to the same Java field:
| Format in YAML / Properties | Java Field |
|---|---|
access-token (kebab-case) | accessToken |
access_token (underscore) | accessToken |
accessToken (camelCase) | accessToken |
ACCESSTOKEN (uppercase) | accessToken |
ACCESS_TOKEN (screaming snake) | accessToken |
This means you can write YAML in kebab-case (which is Spring’s recommended convention) and Java in camelCase without any additional configuration:
# Recommended: kebab-case in YAML
app:
max-connection-pool-size: 20
default-page-size: 50
cache-ttl-seconds: 300
// camelCase in Java — Spring connects the two automatically
@ConfigurationProperties(prefix = "app")
public record AppProperties(
int maxConnectionPoolSize, // ← from max-connection-pool-size
int defaultPageSize, // ← from default-page-size
int cacheTtlSeconds // ← from cache-ttl-seconds
) {}
Managing Multi-Environment Configuration
Real projects have more than one environment. Spring Boot handles this with profile-specific configuration files:
src/main/resources/
├── application.yaml ← default, all environments
├── application-dev.yaml ← overrides for development
├── application-staging.yaml ← overrides for staging
└── application-prod.yaml ← overrides for production
# application.yaml — default values applying to all environments
app:
jwt:
secret: dev-secret-change-in-prod
access-token:
expired: 1h
database:
pool-size: 5
cache:
enabled: true
ttl: 300s
# application-prod.yaml — only the differing overrides
app:
jwt:
access-token:
expired: 15m # stricter in production
database:
pool-size: 20 # larger in production
flowchart TD
A["application.yaml<br/>(base config)"] --> D["Merged Config"]
B["application-{profile}.yaml<br/>(profile override)"] --> D
C["Environment Variables<br/>(highest priority)"] --> D
D --> E["@ConfigurationProperties<br/>bound & validated"]
E --> F["Application Ready"]Never store secrets (database passwords, API keys, JWT secrets) in configuration files that enter version control. Use environment variables, a secret manager (AWS Secrets Manager, HashiCorp Vault), or Kubernetes Secrets for sensitive values. An application-prod.yaml file in the repository should only contain non-sensitive configuration.Comparing the Three Approaches
flowchart TD
A{How many related<br/>properties?} -- "1–2, standalone" --> B{"Will it grow<br/>in the future?"}
B -- No --> C["@Value<br/>✓ Simple<br/>✗ Not scalable"]
B -- Yes --> D["@ConfigurationProperties<br/>✓ Structured<br/>✓ Validatable"]
A -- "3+, one domain" --> D
D --> E{"Large team or<br/>critical system?"}
E -- Yes --> F["@ConfigurationProperties<br/>+ record<br/>✓ Immutable<br/>✓ Self-documented"]
E -- No --> D| Criterion | @Value | @ConfigurationProperties | + record |
|---|---|---|---|
| Initial speed | ✓ Fast | ✗ Needs a class | ✗ Needs a class |
| Single source of truth | ✗ Scattered | ✓ Centralized | ✓ Centralized |
| Type safety | ✗ All strings | ✓ Explicit types | ✓ Explicit types |
| Startup validation | ✗ None | ✓ With @Validated | ✓ With @Validated |
| Immutability | ✗ Mutable | ✗ Mutable | ✓ Immutable |
| Refactor safety | ✗ Miss-prone | ✓ IDE-friendly | ✓ IDE-friendly |
| Large-team friendly | ✗ | ✓ | ✓ |
| Suitable for microservices | ✗ | ⚠ | ✓ |
Recommendations Based on Context
Startup / PoC / internal tooling:
→ @Value is allowed, but be aware this becomes debt if continued
A product that will grow:
→ Go straight to @ConfigurationProperties from day one
→ Add @Validated for all critical properties
Microservices / systems with teams of more than 3 people:
→ @ConfigurationProperties + record as the standard
→ All secrets via environment variables or a secret manager
Regulated systems (fintech, healthcare):
→ @ConfigurationProperties + record + @Validated is the minimum
→ An audit trail of configuration changes is mandatory
Spring Boot Configuration Checklist
STRUCTURE:
□ One class/record per configuration domain (jwt, database, cache, ...)
□ No @Value for properties related to a domain that already has a Properties class
□ YAML names use kebab-case, Java names use camelCase
VALIDATION:
□ @Validated used on all critical @ConfigurationProperties
□ Constraint annotations (@NotBlank, @NotNull, @Min, etc.) present on all required fields
□ The application is startup-tested with deliberately emptied configuration
SECURITY:
□ No secrets in configuration files entering version control
□ Sensitive values use environment variables or a secret manager
□ application-prod.yaml contains no passwords, keys, or tokens
ARCHITECTURE:
□ @ConfigurationProperties injected via constructor, not field injection
□ Properties objects are not used as state — only read
□ Consider records for all new Properties classes
Summary
- Configuration is a contract between the application and its environment — treat it like a public API, not plain strings.
@Valueis too easy — its initial convenience is inversely proportional to its future maintenance cost; use it only for truly standalone single values.@ConfigurationPropertiesturns scattered configuration into one centralized domain with explicit types and startup validation capability.@Validatedon@ConfigurationPropertiesis the most valuable fail-fast implementation — the application refuses to start if configuration is invalid, rather than crashing mid-process.recordbrings configuration to the immutable level — no setters, no mutation, no ambiguity; this is the standard for serious systems.- Spring Boot’s relaxed binding allows kebab-case in YAML and camelCase in Java without extra configuration — use this convention consistently.
- Secrets must not be in version control — use environment variables, a secret manager, or Kubernetes Secrets for all sensitive values.
- Choose the approach based on context:
@Valuefor PoCs,@ConfigurationPropertiesfor growing products,recordfor critical systems and large teams.