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.gatewayUrlem vez degateway-url, você só descobre o erro quando executa. - Sem validação.
max-retries=-5ainda é injetado normalmente. - A configuração fica espalhada. Se
RefundServicetambém precisar demerchant-id, você precisa repetir@Valuelá. - Difícil de testar. Para testar
PaymentServicesem montar um contexto Spring, você precisa usar reflection para atribuir campos. - Tipos complexos são desajeitados. Injetar um
Map<String, BigDecimal>com@Valuerequer 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 sobapp.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 ambienteAPP_PAYMENT_GATEWAYURL) para o mesmo parâmetrogatewayUrl. @DefaultValuefornece um valor padrão quando a chave não está na configuração.- A string
5sé convertida automaticamente emDuration.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
@Componentem um record que usa constructor binding. Caso contrário, Spring o tratará como um bean comum e tentará fazer autowire deString gatewayUrlcomo se fosse outro bean. O aplicativo falhará na inicialização. Registre usando@ConfigurationPropertiesScanou@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:
DataSizeentende as unidades B, KB, MB, GB.2MBretornaDataSize.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 prefixoapp.payment.webhook.@DefaultValuesem valor, colocado emwebhook, faz com que Spring crie umWebhookcom valores padrão quando o blocowebhookinteiro está ausente. Se faltar essa anotação,webhook()retornaránull.
Nota: Com
Duration, um número sem unidade é entendido como milissegundos.timeout: 30significa 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) {
}
}
@Validatedativa a validação logo após o binding terminar.@Validemwebhookfaz com que as restrições dentro deWebhooktambé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
annotationProcessorPathsnomaven-compiler-plugin(por exemplo, para Lombok ou MapStruct), você deve adicionarspring-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 (
2sparaDuration,1.5paraBigDecimal). Ele também confirma que@DefaultValueé aplicado paramaxRetries,supportedCurrenciesewebhook. - O segundo teste confirma que
max-retries=50viola@Max(10)e faz com que o contexto falhe na inicialização comBindValidationException.
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}").@ConfigurationPropertiesnã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
@ConfigurationPropertiesScanou@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ãoapp.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
AppPropertiesgigante. 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
@RefreshScopeou 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,Mape records aninhados, com valores padrão via@DefaultValue; - validar a configuração na inicialização usando
@Validatede 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.