Injecting Values with @Value
Inject single configuration values into beans with @Value, set safe defaults, and know when to switch to @ConfigurationProperties.
@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
newgets nothing. - Once a class needs three or more related keys under one prefix,
@ConfigurationPropertiesgives 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
import java.net.URI;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
public class PaymentGateway {
private final URI baseUrl;
private final Duration timeout;
private final int maxRetries;
public PaymentGateway(
("${app.payments.base-url}") URI baseUrl, // required: startup fails if missing
("${app.payments.timeout:5s}") Duration timeout,
("${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.
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.
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.
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