@Value: An Engineering Opinion on Configuration in Spring Boot
10 min read

@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 use record with @ConfigurationProperties, make sure Spring Boot is version 2.6 or above and there’s no @Component on the record — just register it via @EnableConfigurationProperties(JwtProperties.class) on a configuration class, or via @ConfigurationPropertiesScan on 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 / PropertiesJava 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.
  • @Value is too easy — its initial convenience is inversely proportional to its future maintenance cost; use it only for truly standalone single values.
  • @ConfigurationProperties turns scattered configuration into one centralized domain with explicit types and startup validation capability.
  • @Validated on @ConfigurationProperties is the most valuable fail-fast implementation — the application refuses to start if configuration is invalid, rather than crashing mid-process.
  • record brings 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: @Value for PoCs, @ConfigurationProperties for growing products, record for critical systems and large teams.

Portfolio