Injecting Values with @Value

Inject single configuration values into beans with @Value, set safe defaults, and know when to switch to @ConfigurationProperties.

Syntax@Value("${property.key}") // required @Value("${property.key:default}") // with fallback @Value("#{expression}") // SpEL, evaluated

@Value injects one value from the Environment into a bean. The placeholder syntax ${key} names the property, and ${key:default} supplies a fallback when it is missing.

public PaymentClient(@Value("${app.payments.base-url}") String baseUrl,
                     @Value("${app.payments.timeout:5s}") Duration timeout) { ... }

Put it on constructor parameters

You can annotate fields, but constructor parameters are better. The value is set once, the field can be final, and a unit test can create the class with new and plain values. Field injection forces every test to start a Spring context or use reflection.

Conversion

Spring Boot converts the text to the parameter type. int, boolean and enums work as you would expect. A Duration accepts 500ms, 30s or 2m, or an ISO-8601 value such as PT30S. A comma-separated value converts to a List<String>. A value that cannot be converted, such as port=eighty, stops the application at startup, which is what you want.

Missing keys

Without a default, a missing key fails startup with Could not resolve placeholder 'app.payments.base-url'. That is often the right outcome: a required URL should not quietly become an empty string. Add a default only when there is a genuinely sensible one. ${key:} (empty default) is a frequent source of bugs, because the code then calls an endpoint at "" and fails at the first request instead of at deploy time.

SpEL is a different syntax

#{...} is a Spring Expression Language expression, evaluated rather than looked up. You can combine the two, as in #{'${app.flags}'.toUpperCase()}, but logic in annotations is hard to read and impossible to test on its own. Keep expressions trivial or move the logic into code.

Where @Value stops being the right tool

  • Static fields are never injected. The annotation is ignored and the field stays null.
  • It only works on beans Spring creates. An object you build with new gets nothing.
  • Once a class needs three or more related keys under one prefix, @ConfigurationProperties gives you a typed object, validation and IDE completion. The same key repeated as a string in five classes is a rename waiting to break.

Write keys in their canonical kebab-case form inside @Value. Spring Boot can then still match an environment variable such as APP_PAYMENTS_BASEURL to app.payments.base-url; a camelCase key in the annotation does not get the same relaxed matching.

Example

Example · java
import java.net.URI;
import java.time.Duration;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class PaymentGateway {

    private final URI baseUrl;
    private final Duration timeout;
    private final int maxRetries;

    public PaymentGateway(
            @Value("${app.payments.base-url}") URI baseUrl,      // required: startup fails if missing
            @Value("${app.payments.timeout:5s}") Duration timeout,
            @Value("${app.payments.max-retries:2}") int maxRetries) {
        this.baseUrl = baseUrl;
        this.timeout = timeout;
        this.maxRetries = maxRetries;
    }

    public String describe() {
        return "Calling " + baseUrl + " with timeout " + timeout.toMillis()
                + " ms and up to " + maxRetries + " retries";
    }
}

When to use it

  • Injecting the base URL and timeout of a single downstream payment API into the client that calls it.
  • Reading a feature toggle such as app.search.fuzzy-enabled into one service without creating a properties class.
  • Passing a comma-separated list of allowed CORS origins from configuration into a web configuration bean.

More examples

Lists and simple expressions

A comma-separated property converts to a `List<String>` without any expression. The second parameter shows the limit of what belongs in an annotation: one comparison. Anything more complicated (parsing, branching, lookups) should be ordinary Java in the class, where it can be read and tested.

Example · java
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class CorsSettings {

    private final List<String> allowedOrigins;
    private final boolean strictMode;

    public CorsSettings(
            // app.cors.allowed-origins=https://shop.example.com,https://admin.example.com
            @Value("${app.cors.allowed-origins}") List<String> allowedOrigins,
            // SpEL: evaluated, not looked up. Keep it this simple.
            @Value("#{'${app.cors.mode:strict}' == 'strict'}") boolean strictMode) {
        this.allowedOrigins = List.copyOf(allowedOrigins);
        this.strictMode = strictMode;
    }

    public boolean allows(String origin) {
        return !strictMode || allowedOrigins.contains(origin);
    }
}

Testing without starting Spring

Because the values arrive through the constructor, the test passes them directly with `new`. No application context, no property files, and it runs in milliseconds. With field injection the same test would need Spring or `ReflectionTestUtils` to set private fields.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
class ShippingQuoteClient {

    private final URI baseUrl;
    private final Duration timeout;

    ShippingQuoteClient(@Value("${app.shipping.base-url}") URI baseUrl,
                        @Value("${app.shipping.timeout:3s}") Duration timeout) {
        this.baseUrl = baseUrl;
        this.timeout = timeout;
    }

    URI quoteUri(String postcode) {
        return baseUrl.resolve("/quotes?postcode=" + URLEncoder.encode(postcode, StandardCharsets.UTF_8));
    }

    Duration timeout() {
        return timeout;
    }
}

class ShippingQuoteClientTest {

    @Test
    void buildsTheQuoteUriFromTheConfiguredBase() {
        var client = new ShippingQuoteClient(URI.create("https://shipping.test"), Duration.ofSeconds(1));

        assertThat(client.quoteUri("SW1A1AA").toString())
                .isEqualTo("https://shipping.test/quotes?postcode=SW1A1AA");
        assertThat(client.timeout()).isEqualTo(Duration.ofSeconds(1));
    }
}

Values the examples above read

Durations accept a number with a unit (`ms`, `s`, `m`, `h`, `d`) or the ISO-8601 form. Leaving a key out on purpose, as with `max-retries`, is fine when the default is a deliberate choice; comment it so the next reader knows it was not forgotten.

Example · properties
app.payments.base-url=https://payments.example.com/v2
app.payments.timeout=2s
# app.payments.max-retries is not set, so the default of 2 applies

app.cors.allowed-origins=https://shop.example.com,https://admin.example.com
app.cors.mode=strict

app.shipping.base-url=https://shipping.example.com

Discussion

  • Be the first to comment on this lesson.