Konfiguration mit @ConfigurationProperties statt @Value

1. Übersicht

@Value ist sehr praktisch, wenn Sie nur ein oder zwei Konfigurationswerte auslesen müssen. Wenn die Anzahl der Properties wächst, beginnen die Strings ${...} überall in den Beans zu verstreuen, sind schwer zu finden, leicht zu verwechseln und werden überhaupt nicht validiert.

Diese Anleitung zeigt Ihnen, wie Sie in Spring Boot 3.x zu @ConfigurationProperties wechseln. Danach wissen Sie, wie Sie Konfiguration in einem record mit klaren Typen zusammenfassen und Binding für Typen wie Duration, List, Map und verschachtelte Objekte durchführen. Sie werden auch lernen, wie Sie Konfiguration beim Starten validieren und Tests dafür schreiben.

2. Probleme mit @Value

Betrachten Sie einen PaymentService, der einen Payment-Gateway aufrufen muss:

@Service
public class PaymentService {

    @Value("${app.payment.gateway-url}")
    private String gatewayUrl;

    @Value("${app.payment.merchant-id}")
    private String merchantId;

    @Value("${app.payment.timeout:10s}")
    private Duration timeout;

    @Value("${app.payment.max-retries:3}")
    private int maxRetries;

    // ...
}

Der Code läuft, hat aber mehrere Probleme:

  • Keys sind Freitext. Wenn Sie app.payment.gatewayUrl statt gateway-url schreiben, erfahren Sie vom Fehler erst zur Laufzeit.
  • Keine Validierung. max-retries=-5 wird ohne Probleme injiziert.
  • Konfiguration ist verstreut. Wenn auch RefundService den merchant-id benötigt, wiederholen Sie die @Value-Annotation dort.
  • Schwer zu testen. Um PaymentService ohne Spring Context zu testen, müssen Sie Reflection verwenden, um die Fields zu setzen.
  • Komplexe Typen sind umständlich. Eine Map<String, BigDecimal> über @Value zu injizieren erfordert komplexes SpEL.

3. Konfigurationsklasse mit @ConfigurationProperties deklarieren

3.1. Einen record verwenden (empfohlen)

Datei application.yml:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3

Entsprechende Konfigurationsklasse:

package com.nonla.payment;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries) {
}

Erklärung:

  • prefix = "app.payment" teilt Spring mit, dass dieser record alle Keys unter app.payment erhält.
  • Spring Boot 3 nutzt automatisch constructor binding für records. Sie benötigen keine Setter oder @ConstructorBinding (wenn die Klasse nur einen Constructor hat).
  • Relaxed binding bildet gateway-url, gatewayUrl, GATEWAY_URL (Umgebungsvariable APP_PAYMENT_GATEWAYURL) auf denselben Parameter gatewayUrl ab.
  • @DefaultValue stellt einen Standardwert zur Verfügung, wenn der Key in der Konfiguration fehlt.
  • Der String 5s wird automatisch in Duration.ofSeconds(5) umgewandelt.

3.2. Registrierung und Verwendung

Der record oben ist noch kein Bean. Sie müssen ihn in Ihrer Hauptklasse aktivieren:

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@ConfigurationPropertiesScan sucht das aktuelle Package und seine Subpackages nach Klassen mit @ConfigurationProperties. Wenn Sie jede Klasse explizit deklarieren möchten, verwenden Sie @EnableConfigurationProperties(PaymentProperties.class) auf einer @Configuration-Klasse.

Jetzt kann PaymentService einfach ein Objekt injizieren:

@Service
public class PaymentService {

    private final PaymentProperties props;

    public PaymentService(PaymentProperties props) {
        this.props = props;
    }

    public String describe() {
        return "Rufe %s mit merchant %s auf, timeout %s, retry %d mal"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

Das Ergebnis beim Aufrufen von describe():

Rufe https://pay.example.com/api mit merchant NONLA-001 auf, timeout PT5S, retry 3 mal

PaymentService ist jetzt eine reine Java-Klasse. Im Unit-Test benötigen Sie nur new PaymentService(new PaymentProperties(...)), keinen Spring.

Hinweis: Annotieren Sie den record, der constructor binding verwendet, nicht mit @Component. Spring würde ihn dann als normales Bean behandeln und versuchen, String gatewayUrl wie ein anderes Bean zu autowiren. Die Anwendung startet mit einem Fehler. Registrieren Sie ihn stattdessen über @ConfigurationPropertiesScan oder @EnableConfigurationProperties.

3.3. JavaBean-Klasse mit Settern

Wenn Sie ein änderbares Objekt benötigen, zum Beispiel für Kompatibilität mit altem Code, können Sie eine Klasse mit Gettern und Settern verwenden:

@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
    private String host = "localhost";
    private int port = 25;

    public String getHost() { return host; }
    public void setHost(String host) { this.host = host; }
    public int getPort() { return port; }
    public void setPort(int port) { this.port = port; }
}

Spring ruft den Standardconstructor auf, dann Setter für jeden gefundenen Key. Standardwerte sind die Initialisierungswerte der Fields. Dies funktioniert gut, aber das Objekt kann zur Laufzeit überall geändert werden. Daher ist ein record die sicherere Wahl.

4. Binding komplexer Datentypen

Erweitern Sie die Payment-Konfiguration mit Listen, Maps und verschachtelte Objekten:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3
    max-payload: 2MB
    supported-currencies:
      - VND
      - USD
    fees:
      card: 1.5
      wallet: 0.8
    webhook:
      enabled: true
      secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}

Jeder Parameter wird wie folgt gebunden:

  • DataSize versteht Einheiten wie B, KB, MB, GB. 2MB ergibt DataSize.ofMegabytes(2).
  • List<String> akzeptiert sowohl YAML-Listen als auch durch Kommas getrennte Strings (VND,USD).
  • Map<String, BigDecimal> nimmt die untergeordneten Keys (card, wallet) als Map-Keys.
  • Webhook ist ein verschachtelter record, der rekursiv mit dem Prefix app.payment.webhook gebunden wird.
  • @DefaultValue ohne Wert auf webhook bewirkt, dass Spring ein Webhook mit Standardwerten erstellt, wenn der gesamte webhook-Block fehlt. Ohne diese Annotation würde webhook() null zurückgeben.

Hinweis: Bei Duration wird eine Zahl ohne Einheit als Millisekunden interpretiert. timeout: 30 bedeutet 30ms, nicht 30 Sekunden. Sie sollten immer die Einheit angeben (30s, 500ms), oder @DurationUnit(ChronoUnit.SECONDS) auf dem Parameter setzen.

5. Validierung von Konfiguration beim Starten

Fügen Sie das Validation-Starter hinzu:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Markieren Sie dann @Validated und fügen Sie Jakarta Bean Validation Constraints hinzu:

@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        @NotBlank String gatewayUrl,
        @NotBlank String merchantId,
        @DefaultValue("10s") Duration timeout,
        @Min(0) @Max(10) @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @Valid @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}
  • @Validated aktiviert die Validierung unmittelbar nach dem Binding.
  • @Valid auf webhook stellt sicher, dass auch die Constraints innerhalb von Webhook geprüft werden. Ohne @Valid überspringt Spring verschachtelte Objekte.

Wenn jemand merchant-id leer lässt, stoppt die Anwendung beim Start mit einer klaren Fehlermeldung:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.nonla.payment.PaymentProperties failed:

    Property: app.payment.merchantId
    Value: ""
    Origin: class path resource [application.yml] - 4:17
    Reason: must not be blank

Action:

Update your application's configuration

Das System meldet den Fehler sofort beim Deploy, nicht erst nach einigen Stunden, wenn die erste Payment-Anfrage fehlschlägt. Das ist ein großer Vorteil gegenüber @Value.

6. IDE-Hinweise mit Configuration Processor

Fügen Sie den Annotation Processor hinzu:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

Beim Kompilieren erzeugt der Processor die Datei META-INF/spring-configuration-metadata.json, die alle Keys unter app.payment beschreibt: Typ, Standardwert und Beschreibung. IntelliJ IDEA und VS Code (mit Spring Tools) nutzen diese Datei, um Keys vorzuschlagen, nicht existierende Keys zu warnen und Dokumentation anzuzeigen, wenn Sie mit der Maus über einen Key in application.yml fahren.

Die Beschreibung jeder Property wird aus der Javadoc übernommen. Bei records schreiben Sie Javadoc mit @param:

/**
 * Konfiguration des Payment Gateways.
 *
 * @param gatewayUrl Basis-URL des Payment Gateways
 * @param maxRetries maximale Anzahl von Wiederholungsversuchen, wenn der Gateway-Aufruf fehlschlägt
 */

Hinweis: Wenn Ihr Projekt bereits annotationProcessorPaths im maven-compiler-plugin deklariert hat (zum Beispiel für Lombok oder MapStruct), müssen Sie spring-boot-configuration-processor zu dieser Liste hinzufügen. Sonst läuft der Processor nicht.

7. Testen von Konfigurationsklassen

Sie benötigen keinen schweren @SpringBootTest. ApplicationContextRunner erstellt einen minimalen Context, der nur das Nötigste enthält:

package com.nonla.payment;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;

class PaymentPropertiesTest {

    @Configuration
    @EnableConfigurationProperties(PaymentProperties.class)
    static class TestConfig {
    }

    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
            .withUserConfiguration(TestConfig.class);

    @Test
    void shouldBindValuesAndApplyDefaults() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.timeout=2s",
                        "app.payment.fees.card=1.5")
                .run(context -> {
                    PaymentProperties props = context.getBean(PaymentProperties.class);

                    assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
                    assertThat(props.maxRetries()).isEqualTo(3);
                    assertThat(props.supportedCurrencies()).containsExactly("VND");
                    assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
                    assertThat(props.webhook().enabled()).isFalse();
                });
    }

    @Test
    void shouldFailWhenMaxRetriesOutOfRange() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.max-retries=50")
                .run(context -> assertThat(context)
                        .hasFailed()
                        .getFailure()
                        .hasRootCauseInstanceOf(BindValidationException.class));
    }
}

Diese beiden Tests prüfen folgende Punkte:

  • Der erste Test bestätigt, dass Werte korrekt gebunden werden (2s zu Duration, 1.5 zu BigDecimal). Er bestätigt auch, dass @DefaultValue für maxRetries, supportedCurrencies und webhook angewendet wird.
  • Der zweite Test bestätigt, dass max-retries=50 gegen @Max(10) verstößt und den Context-Start mit BindValidationException fehlschlagen lässt.

Jeder Test läuft in wenigen hundert Millisekunden, weil kein Webserver oder andere Auto-Konfiguration geladen wird.

8. Wann Sie @Value noch verwenden

@ConfigurationProperties ersetzt @Value nicht vollständig. Sie können @Value noch verwenden, wenn:

  • Sie SpEL benötigen. Zum Beispiel @Value("#{${app.payment.timeout-seconds} * 1000}"). @ConfigurationProperties unterstützt SpEL-Ausdrücke nicht.
  • Sie nur einen einzelnen Wert benötigen, der nur an einer Stelle verwendet wird. Zum Beispiel @Value("${spring.application.name}") in einem Log-Bean. Einen ganzen record dafür zu erstellen ist Overkill.

Einige häufige Fehler beim Wechsel zu @ConfigurationProperties:

  • Vergessen der Registrierung. Ohne @ConfigurationPropertiesScan oder @EnableConfigurationProperties erhalten Sie den Fehler “No qualifying bean of type PaymentProperties”.
  • Prefix in falscher Schreibweise. Das Prefix muss in kebab-case in Kleinbuchstaben sein (app.payment, nicht app.Payment). Wenn es falsch ist, meldet Spring den Fehler beim Start.
  • Mehrere Constructors. Wenn ein record oder eine Klasse mehr als einen Constructor hat, müssen Sie den Constructor für das Binding mit @ConstructorBinding markieren.
  • Eine Klasse für alles. Passen Sie nicht die gesamte Anwendungskonfiguration in ein riesiges AppProperties. Teilen Sie nach Geschäftsdomäne auf: PaymentProperties, MailProperties, StorageProperties…

Hinweis: Konfiguration-records sind unveränderlich (immutable). Wenn Sie Spring Cloud zur Laufzeit-Aktualisierung von Konfiguration verwenden, muss das Bean neu erstellt werden (über @RefreshScope oder einen Rebind-Mechanismus), damit neue Werte gelten. Erwarten Sie nicht, dass der record sich automatisch ändert.

9. Fazit

In dieser Anleitung haben Sie die verstreuten @Value-Annotationen durch einen PaymentProperties-record mit klaren Typen ersetzt. Konkret haben Sie:

  • Binding für Duration, DataSize, List, Map und verschachtelte records mit Standardwerten über @DefaultValue durchgeführt;
  • Konfiguration beim Start mit @Validated und Jakarta Constraints validiert;
  • IDE-Hinweise für Keys durch spring-boot-configuration-processor erhalten;
  • Konfigurationsklassen schnell und einfach mit ApplicationContextRunner getestet.

Für jede Gruppe von Konfiguration mit zwei oder mehr Keys sollte @ConfigurationProperties die Standardwahl sein. Behalten Sie @Value für einzelne Werte oder wenn Sie wirklich SpEL benötigen.

spring-bootconfigurationconfigurationpropertiesvalidationjava