Configurer avec @ConfigurationProperties au lieu de @Value

1. Aperçu

@Value est très pratique quand vous n’avez besoin que de lire une ou deux valeurs de configuration. Quand le nombre de propriétés augmente, les chaînes ${...} commencent à se disperser dans les beans, deviennent difficiles à trouver, faciles à mal taper et ne sont pas du tout vérifiées.

Cet article vous guide pour passer à @ConfigurationProperties dans Spring Boot 3.x. À la fin, vous saurez comment regrouper la configuration dans un record avec un type clair et binder des types comme Duration, List, Map, des objets imbriqués. Vous saurez aussi comment valider la configuration au démarrage et écrire des tests pour cette partie.

2. Les problèmes avec @Value

Considérez un PaymentService qui doit appeler une passerelle de paiement :

@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;

    // ...
}

Le code fonctionne, mais il y a plusieurs problèmes :

  • La clé est une chaîne libre. Si vous tapez app.payment.gatewayUrl au lieu de gateway-url, vous ne découvrirez l’erreur qu’à l’exécution.
  • Aucune validation. max-retries=-5 peut être injecté sans problème.
  • La configuration est dispersée. Si RefundService a aussi besoin de merchant-id, vous devez répéter @Value là aussi.
  • Difficile à tester. Pour tester PaymentService sans créer un contexte Spring, vous devez utiliser la réflexion pour assigner les champs.
  • Les types complexes sont maladroits. Injecter une Map<String, BigDecimal> avec @Value nécessite une SpEL assez complexe.

3. Déclarer une classe de configuration avec @ConfigurationProperties

3.1. Utiliser un record (recommandé)

Fichier application.yml :

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

La classe de configuration correspondante :

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) {
}

Explications :

  • prefix = "app.payment" indique à Spring que ce record reçoit toutes les clés sous app.payment.
  • Spring Boot 3 utilise automatiquement constructor binding pour les records. Vous n’avez pas besoin de setter ou @ConstructorBinding (quand la classe n’a qu’un seul constructeur).
  • Relaxed binding mappe gateway-url, gatewayUrl, GATEWAY_URL (variable d’environnement APP_PAYMENT_GATEWAYURL) au même paramètre gatewayUrl.
  • @DefaultValue fournit une valeur par défaut quand la clé n’existe pas dans la configuration.
  • La chaîne 5s est convertie automatiquement en Duration.ofSeconds(5).

3.2. Enregistrement et utilisation

Le record ci-dessus n’est pas encore un bean. Vous devez l’activer dans votre classe principale :

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

@ConfigurationPropertiesScan scanne le package courant et ses sous-packages pour trouver les classes avec @ConfigurationProperties. Si vous préférez déclarer explicitement chaque classe, utilisez @EnableConfigurationProperties(PaymentProperties.class) sur une classe @Configuration.

Maintenant, PaymentService ne doit injecter qu’un seul objet :

@Service
public class PaymentService {

    private final PaymentProperties props;

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

    public String describe() {
        return "Appeler %s avec le marchand %s, délai %s, réessai %d fois"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

Résultat quand on appelle describe() :

Appeler https://pay.example.com/api avec le marchand NONLA-001, délai PT5S, réessai 3 fois

PaymentService est maintenant une classe Java ordinaire. Dans les tests unitaires, vous avez juste besoin de new PaymentService(new PaymentProperties(...)), pas besoin de Spring.

Note : N’ajoutez pas @Component sur un record utilisant constructor binding. Spring le traiterait comme un bean ordinaire et essaierait de faire l’autowiring de String gatewayUrl comme un autre bean. L’application planterait au démarrage. Enregistrez-le plutôt via @ConfigurationPropertiesScan ou @EnableConfigurationProperties.

3.3. Type JavaBean avec setter

Si vous avez besoin d’un objet mutable, par exemple pour compatibilité avec du code existant, vous pouvez utiliser une classe avec getter/setter :

@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 appelle le constructeur par défaut, puis appelle le setter pour chaque clé trouvée. Les valeurs par défaut sont les valeurs d’initialisation des champs. Cette approche fonctionne bien, mais l’objet peut être modifié n’importe où pendant l’exécution. C’est pourquoi les records restent un choix plus sûr.

4. Binding de types de données complexes

Étendons la configuration de paiement avec des listes, des maps et des objets imbriqués :

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) {
    }
}

Chaque paramètre est bindé comme suit :

  • DataSize comprend les unités B, KB, MB, GB. 2MB donne DataSize.ofMegabytes(2).
  • List<String> accepte à la fois le format de liste YAML et les chaînes séparées par des virgules (VND,USD).
  • Map<String, BigDecimal> utilise les clés enfants (card, wallet) comme clés de la map.
  • Webhook est un record imbriqué et est bindé récursivement selon le préfixe app.payment.webhook.
  • @DefaultValue sans valeur, placé sur webhook, amène Spring à créer un Webhook avec les valeurs par défaut quand tout le bloc webhook est absent. Sans cette annotation, webhook() retournerait null.

Note : Avec Duration, un nombre sans unité est interprété comme millisecondes. timeout: 30 signifie 30ms, pas 30 secondes. Vous devriez toujours spécifier l’unité (30s, 500ms), ou utiliser @DurationUnit(ChronoUnit.SECONDS) sur le paramètre.

5. Valider la configuration au démarrage

Ajoutez le starter validation :

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

Puis annotez avec @Validated et ajoutez les contraintes Jakarta Bean Validation :

@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 active la validation dès que le binding est terminé.
  • @Valid sur webhook s’assure que les contraintes à l’intérieur de Webhook sont aussi vérifiées. Sans @Valid, Spring ignore l’objet imbriqué.

Si quelqu’un laisse merchant-id vide, l’application s’arrête au démarrage avec un message clair :

***************************
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

Le système signale l’erreur dès le déploiement, au lieu de tourner quelques heures puis de casser au premier appel de paiement. C’est le plus grand avantage par rapport à @Value.

6. Autocomplétion dans l’IDE avec configuration processor

Ajoutez l’annotation processor :

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

Lors de la compilation, le processor génère le fichier META-INF/spring-configuration-metadata.json qui décrit chaque clé sous app.payment : le type, la valeur par défaut et la description. IntelliJ IDEA et VS Code (avec Spring Tools) utilisent ce fichier pour l’autocomplétion des clés, pour avertir si une clé n’existe pas et pour afficher la documentation quand vous survolez une clé dans application.yml.

La description de chaque propriété vient du Javadoc. Avec un record, vous écrivez le Javadoc avec @param :

/**
 * Configuration de la passerelle de paiement.
 *
 * @param gatewayUrl URL racine de la passerelle de paiement
 * @param maxRetries nombre de tentatives maximum en cas d'appel échoué à la passerelle
 */

Note : Si le projet a déjà déclaré annotationProcessorPaths dans maven-compiler-plugin (par exemple pour Lombok ou MapStruct), vous devez ajouter spring-boot-configuration-processor à cette liste. Sinon, le processor ne s’exécutera pas.

7. Tester la classe de configuration

Vous n’avez pas besoin de @SpringBootTest lourd. ApplicationContextRunner crée un contexte léger contenant juste ce dont vous avez besoin :

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));
    }
}

Ces deux tests vérifient les points suivants :

  • Le premier test confirme que les valeurs sont bindées avec le bon type (2s en Duration, 1.5 en BigDecimal). Il confirme aussi que @DefaultValue est appliqué pour maxRetries, supportedCurrencies et webhook.
  • Le second test confirme que max-retries=50 viole @Max(10) et provoque l’échec du démarrage du contexte avec une BindValidationException.

Chaque test s’exécute en quelques centaines de millisecondes car il n’y a pas de serveur web ni d’autre auto-configuration.

8. Quand utiliser encore @Value

@ConfigurationProperties ne remplace pas entièrement @Value. Vous pouvez toujours utiliser @Value quand :

  • Vous avez besoin de SpEL. Par exemple @Value("#{${app.payment.timeout-seconds} * 1000}"). @ConfigurationProperties ne supporte pas les expressions SpEL.
  • Vous n’avez besoin que d’une seule valeur utilisée à un seul endroit. Par exemple @Value("${spring.application.name}") dans un bean de log. Créer un record entier pour ce cas serait excessif.

Quelques erreurs courantes lors du passage à @ConfigurationProperties :

  • Oublier d’enregistrer la classe. L’absence de @ConfigurationPropertiesScan ou @EnableConfigurationProperties causera une erreur “No qualifying bean of type PaymentProperties”.
  • Le préfixe ne respecte pas la convention. Le préfixe doit être en kebab-case, minuscules (app.payment, pas app.Payment). S’il est incorrect, Spring rapporte une erreur au démarrage.
  • Plusieurs constructeurs. Quand un record ou une classe a plus d’un constructeur, vous devez marquer le constructeur utilisé pour le binding avec @ConstructorBinding.
  • Utiliser une seule classe pour tout. Ne mettez pas toute la configuration de l’application dans une énorme AppProperties. Divisez selon les domaines métier : PaymentProperties, MailProperties, StorageProperties…

Note : Un record de configuration est immuable. Si vous utilisez Spring Cloud pour rafraîchir la configuration pendant l’exécution, le bean doit être recréé (via @RefreshScope ou un mécanisme de rebind) pour que les nouvelles valeurs aient effet. N’attendez pas que le record se change tout seul.

9. Conclusion

Dans cet article, vous avez remplacé les @Value dispersés par un record PaymentProperties avec un type clair. Concrètement, vous avez :

  • pu binder les types Duration, DataSize, List, Map et les records imbriqués, avec des valeurs par défaut via @DefaultValue ;
  • valider la configuration au démarrage avec @Validated et les contraintes Jakarta ;
  • obtenir l’autocomplétion des clés dans l’IDE grâce à spring-boot-configuration-processor ;
  • tester la configuration rapidement et facilement avec ApplicationContextRunner.

Pour tout groupe de configuration ayant deux ou trois clés ou plus, @ConfigurationProperties devrait être le choix par défaut. Réservez @Value pour les valeurs isolées ou quand vous avez vraiment besoin de SpEL.

spring-bootconfigurationconfigurationpropertiesvalidationjava