Configuração com @ConfigurationProperties em vez de @Value

1. Visão geral

@Value é muito conveniente quando você precisa ler apenas uma ou duas valores de configuração. Quando o número de propriedades aumenta, as strings ${...} começam a se espalhar por todos os beans, ficam difíceis de encontrar, fáceis de digitar errado e não são validadas de forma alguma.

Este artigo o guia na transição para @ConfigurationProperties no Spring Boot 3.x. Após ler, você saberá como consolidar a configuração em um record com tipos explícitos e fazer binding de tipos como Duration, List, Map, objetos aninhados. Você também aprenderá como validar a configuração no momento da inicialização e escrever testes para essa parte da configuração.

2. Vấn đề com @Value

Considere um PaymentService que precisa chamar um gateway de pagamento:

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

    // ...
}

O código roda, mas tem alguns problemas:

  • Chaves são strings livres. Se digitar app.payment.gatewayUrl em vez de gateway-url, você só descobre o erro quando executa.
  • Sem validação. max-retries=-5 ainda é injetado normalmente.
  • A configuração fica espalhada. Se RefundService também precisar de merchant-id, você precisa repetir @Value lá.
  • Difícil de testar. Para testar PaymentService sem montar um contexto Spring, você precisa usar reflection para atribuir campos.
  • Tipos complexos são desajeitados. Injetar um Map<String, BigDecimal> com @Value requer SpEL bem complicado.

3. Declarando a classe de configuração com @ConfigurationProperties

3.1. Usando record (recomendado)

Arquivo application.yml:

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

Classe de configuração correspondente:

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

Explicação:

  • prefix = "app.payment" informa ao Spring que este record recebe todas as chaves sob app.payment.
  • Spring Boot 3 usa automaticamente constructor binding para records. Você não precisa de setters ou @ConstructorBinding (quando a classe tem apenas um constructor).
  • Relaxed binding mapeia gateway-url, gatewayUrl, GATEWAY_URL (variável de ambiente APP_PAYMENT_GATEWAYURL) para o mesmo parâmetro gatewayUrl.
  • @DefaultValue fornece um valor padrão quando a chave não está na configuração.
  • A string 5s é convertida automaticamente em Duration.ofSeconds(5).

3.2. Registrando e usando

O record acima ainda não é um bean. Você precisa ativá-lo na classe main:

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

@ConfigurationPropertiesScan varre o pacote atual e subpacotes para encontrar classes com @ConfigurationProperties. Se preferir declarar explicitamente cada classe, use @EnableConfigurationProperties(PaymentProperties.class) em uma classe @Configuration.

Agora PaymentService só precisa injetar um objeto:

@Service
public class PaymentService {

    private final PaymentProperties props;

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

    public String describe() {
        return "Chamando %s com merchant %s, timeout %s, %d tentativas de retry"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

Resultado ao chamar describe():

Chamando https://pay.example.com/api com merchant NONLA-001, timeout PT5S, 3 tentativas de retry

PaymentService agora é uma classe Java pura. No unit test, você só precisa de new PaymentService(new PaymentProperties(...)), sem precisar do Spring.

Nota: Não coloque @Component em um record que usa constructor binding. Caso contrário, Spring o tratará como um bean comum e tentará fazer autowire de String gatewayUrl como se fosse outro bean. O aplicativo falhará na inicialização. Registre usando @ConfigurationPropertiesScan ou @EnableConfigurationProperties.

3.3. Tipo JavaBean com setters

Se precisar de um objeto mutável, por exemplo para compatibilidade com código antigo, pode usar uma classe com getters/setters:

@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 chama o constructor padrão e depois chama o setter para cada chave encontrada. Os valores padrão são os valores iniciais dos campos. Esta abordagem funciona bem, mas o objeto pode ser modificado em qualquer lugar durante a execução. Por isso, records ainda são uma escolha mais segura.

4. Binding de tipos de dados complexos

Expanda a configuração de pagamento com listas, mapa e objetos aninhados:

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

Cada parâmetro é vinculado da seguinte forma:

  • DataSize entende as unidades B, KB, MB, GB. 2MB retorna DataSize.ofMegabytes(2).
  • List<String> aceita tanto listas YAML quanto strings separadas por vírgula (VND,USD).
  • Map<String, BigDecimal> pega as subchaves (card, wallet) como chaves do mapa.
  • Webhook é um record aninhado e é vinculado recursivamente sob o prefixo app.payment.webhook.
  • @DefaultValue sem valor, colocado em webhook, faz com que Spring crie um Webhook com valores padrão quando o bloco webhook inteiro está ausente. Se faltar essa anotação, webhook() retornará null.

Nota: Com Duration, um número sem unidade é entendido como milissegundos. timeout: 30 significa 30ms, não 30 segundos. Você deve sempre especificar a unidade (30s, 500ms), ou colocar @DurationUnit(ChronoUnit.SECONDS) no parâmetro.

5. Validando a configuração na inicialização

Adicione o starter de validação:

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

Depois marque com @Validated e adicione as restrições de 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 ativa a validação logo após o binding terminar.
  • @Valid em webhook faz com que as restrições dentro de Webhook também sejam verificadas. Sem @Valid, Spring ignora o objeto aninhado.

Se alguém deixar merchant-id em branco, o aplicativo para na inicialização com uma mensagem clara:

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

Description:

Falha na vinculação com com.nonla.payment.PaymentProperties:

    Propriedade: app.payment.merchantId
    Valor: ""
    Origem: class path resource [application.yml] - 4:17
    Motivo: não deve estar em branco

Ação:

Atualize a configuração do seu aplicativo

O sistema reporta o erro assim que fazer deploy, em vez de funcionar por algumas horas e quebrar na primeira solicitação de pagamento. Este é o maior benefício comparado com @Value.

6. Dicas no IDE com configuration processor

Adicione o annotation processor:

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

Ao compilar, o processor gera o arquivo META-INF/spring-configuration-metadata.json que descreve todas as chaves sob app.payment: tipo, valor padrão e descrição. IntelliJ IDEA e VS Code (com Spring Tools) usam esse arquivo para sugerir chaves, alertar chaves inexistentes e mostrar documentação quando você passa o mouse sobre uma chave em application.yml.

A descrição de cada propriedade vem do Javadoc. Com records, você escreve Javadoc usando @param:

/**
 * Configuração do gateway de pagamento.
 *
 * @param gatewayUrl URL base do gateway de pagamento
 * @param maxRetries número máximo de tentativas quando a chamada ao gateway falha
 */

Nota: Se o projeto já declarou annotationProcessorPaths no maven-compiler-plugin (por exemplo, para Lombok ou MapStruct), você deve adicionar spring-boot-configuration-processor à lista. Caso contrário, o processor não será executado.

7. Testando a classe de configuração

Você não precisa de @SpringBootTest pesado. ApplicationContextRunner monta um contexto pequeno contendo apenas o que você precisa:

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

Estes dois testes verificam os seguintes pontos:

  • O primeiro teste confirma que os valores são vinculados com o tipo correto (2s para Duration, 1.5 para BigDecimal). Ele também confirma que @DefaultValue é aplicado para maxRetries, supportedCurrencies e webhook.
  • O segundo teste confirma que max-retries=50 viola @Max(10) e faz com que o contexto falhe na inicialização com BindValidationException.

Cada teste executa em algumas centenas de milissegundos porque não há servidor web ou outras auto-configurações.

8. Quando ainda usar @Value

@ConfigurationProperties não substitui completamente @Value. Você ainda pode usar @Value quando:

  • Precisa de SpEL. Por exemplo @Value("#{${app.payment.timeout-seconds} * 1000}"). @ConfigurationProperties não suporta expressões SpEL.
  • Precisa apenas de um único valor, usado em um único lugar. Por exemplo @Value("${spring.application.name}") em um bean de log. Criar um record inteiro para este caso é excessivo.

Alguns erros comuns ao migrar para @ConfigurationProperties:

  • Esquecer de registrar a classe. Sem @ConfigurationPropertiesScan ou @EnableConfigurationProperties, você terá um erro “No qualifying bean of type PaymentProperties”.
  • Prefixo escrito incorretamente. O prefixo deve estar em kebab-case, letras minúsculas (app.payment, não app.Payment). Se estiver errado, Spring reporta o erro na inicialização.
  • Múltiplos construtores. Quando um record ou classe tem mais de um construtor, você deve marcar o construtor usado para binding com @ConstructorBinding.
  • Usar uma classe para tudo. Não coloque toda a configuração do aplicativo em um AppProperties gigante. Divida por domínio de negócio: PaymentProperties, MailProperties, StorageProperties…

Nota: Records de configuração são imutáveis. Se você usar Spring Cloud para atualizar a configuração em tempo de execução, o bean deve ser recriado (via @RefreshScope ou mecanismo rebind) para que os novos valores entrem em vigor. Não espere que o record mude automaticamente.

9. Conclusão

Neste artigo, você substituiu os @Value dispersos por um record PaymentProperties com tipos explícitos. Especificamente, você aprendeu como:

  • fazer binding de Duration, DataSize, List, Map e records aninhados, com valores padrão via @DefaultValue;
  • validar a configuração na inicialização usando @Validated e restrições de Jakarta;
  • ter sugestões de chaves no IDE graças ao spring-boot-configuration-processor;
  • testar a parte de configuração de forma rápida e concisa com ApplicationContextRunner.

Para qualquer grupo de configuração com três ou mais chaves, @ConfigurationProperties deve ser a escolha padrão. Reserve @Value para valores únicos ou quando você realmente precisa de SpEL.

spring-bootconfigurationconfigurationpropertiesvalidationjava