Configuración con @ConfigurationProperties en lugar de @Value
1. Visión general
@Value es muy conveniente cuando solo necesitas leer uno o dos valores de configuración. Cuando aumenta el número de propiedades, las cadenas ${...} comienzan a dispersarse por todos los beans, son difíciles de encontrar, fáciles de escribir incorrectamente y no se validan en absoluto.
Este artículo te guía para migrar a @ConfigurationProperties en Spring Boot 3.x. Después de leerlo, sabrás cómo agrupar la configuración en un record con tipos explícitos y cómo hacer binding de tipos como Duration, List, Map y objetos anidados. También aprenderás a validar la configuración al iniciar la aplicación y a escribir tests para esa parte de la configuración.
2. Problemas con @Value
Considera un PaymentService que necesita llamar a una puerta de pago:
@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;
// ...
}
El código funciona, pero tiene algunos problemas:
- La clave es una cadena libre. Si escribes
app.payment.gatewayUrlen lugar degateway-url, solo descubrirás el error al ejecutar. - No hay validación.
max-retries=-5se inyecta sin problemas. - La configuración se dispersa. Si
RefundServicetambién necesitamerchant-id, tienes que repetir@Valueallí. - Difícil de testear. Para testear
PaymentServicesin crear un contexto de Spring, tienes que usar reflection para asignar campos. - Los tipos complejos son incómodos. Inyectar un
Map<String, BigDecimal>con@Valuerequiere SpEL bastante complicado.
3. Declaración de la clase de configuración con @ConfigurationProperties
3.1. Usar record (recomendado)
Archivo application.yml:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
Clase de configuración correspondiente:
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) {
}
Explicación:
prefix = "app.payment"le indica a Spring que este record acepta todas las claves bajoapp.payment.- Spring Boot 3 usa automáticamente constructor binding para records. No necesitas setters ni
@ConstructorBinding(cuando la clase tiene solo un constructor). - Relaxed binding mapea
gateway-url,gatewayUrl,GATEWAY_URL(variable de entornoAPP_PAYMENT_GATEWAYURL) al mismo parámetrogatewayUrl. @DefaultValueproporciona un valor por defecto cuando la clave no está en la configuración.- La cadena
5sse convierte automáticamente enDuration.ofSeconds(5).
3.2. Registrar y usar
El record anterior aún no es un bean. Necesitas habilitarlo en la clase main:
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@ConfigurationPropertiesScan escanea el paquete actual y los paquetes secundarios para encontrar clases con @ConfigurationProperties. Si prefieres declararlas explícitamente, usa @EnableConfigurationProperties(PaymentProperties.class) en una clase @Configuration.
Ahora PaymentService solo necesita inyectar un objeto:
@Service
public class PaymentService {
private final PaymentProperties props;
public PaymentService(PaymentProperties props) {
this.props = props;
}
public String describe() {
return "Llamar a %s con el comerciante %s, timeout %s, reintentar %d veces"
.formatted(props.gatewayUrl(), props.merchantId(),
props.timeout(), props.maxRetries());
}
}
Resultado cuando se llama a describe():
Llamar a https://pay.example.com/api con el comerciante NONLA-001, timeout PT5S, reintentar 3 veces
PaymentService ahora es una clase Java pura. En unit tests, solo necesitas new PaymentService(new PaymentProperties(...)), sin Spring.
Nota: No agregues
@Componental record que usa constructor binding. En ese caso, Spring lo considera un bean normal e intenta hacer autowire deString gatewayUrlcomo otro bean. La aplicación fallará al iniciar. Regístralo usando@ConfigurationPropertiesScano@EnableConfigurationProperties.
3.3. Tipo JavaBean con setters
Si necesitas un objeto mutable, por ejemplo para compatibilidad con código antiguo, puedes usar una clase con 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 llama al constructor por defecto y luego llama a los setters para cada clave encontrada. Los valores por defecto son los valores iniciales de los campos. Este enfoque funciona bien, pero el objeto puede ser modificado en cualquier lugar en runtime. Por eso, los records siguen siendo la opción más segura.
4. Binding de tipos de datos complejos
Amplía la configuración de pagos con listas, mapas y objetos anidados:
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 se vincula de la siguiente manera:
DataSizeentiende las unidadesB,KB,MB,GB.2MBproduceDataSize.ofMegabytes(2).List<String>acepta tanto listas YAML como cadenas separadas por comas (VND,USD).Map<String, BigDecimal>toma las subclaves (card,wallet) como claves del mapa.Webhookes un record anidado y se vincula recursivamente bajo el prefijoapp.payment.webhook.@DefaultValuesin valor, colocado enwebhook, hace que Spring cree unWebhookcon valores por defecto cuando falta todo el bloquewebhook. Sin esta anotación,webhook()devolveríanull.
Nota: Con
Duration, un número sin unidad se interpreta como milisegundos.timeout: 30significa 30ms, no 30 segundos. Siempre especifica la unidad (30s,500ms), o usa@DurationUnit(ChronoUnit.SECONDS)en el parámetro.
5. Validar la configuración al iniciar
Añade el starter de validación:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Luego marca con @Validated y añade las restricciones 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) {
}
}
@Validatedactiva la validación inmediatamente después de completar el binding.@Validenwebhookasegura que las restricciones dentro deWebhooktambién se verifiquen. Sin@Valid, Spring ignora el objeto anidado.
Si alguien deja merchant-id vacío, la aplicación se detiene al iniciar con un mensaje claro:
***************************
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
El sistema reporta el error inmediatamente al desplegar, en lugar de fallar después de ejecutarse unas horas en la primera solicitud de pago. Esta es la mayor ventaja sobre @Value.
6. Sugerencias en el IDE con configuration processor
Añade el annotation processor:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
Al compilar, el procesador genera el archivo META-INF/spring-configuration-metadata.json que describe todas las claves bajo app.payment: tipo, valor por defecto y descripción. IntelliJ IDEA y VS Code (con Spring Tools) usan este archivo para sugerir claves, advertir sobre claves inexistentes y mostrar documentación cuando pasas el ratón sobre las claves en application.yml.
La descripción de cada propiedad se toma del Javadoc. Con records, escribes Javadoc usando @param:
/**
* Configuración de la puerta de pago.
*
* @param gatewayUrl URL base de la puerta de pago
* @param maxRetries número máximo de reintentos cuando falla la llamada a la puerta
*/
Nota: Si tu proyecto ya ha declarado
annotationProcessorPathsenmaven-compiler-plugin(por ejemplo para Lombok o MapStruct), debes añadirspring-boot-configuration-processora esa lista. Si no, el procesador no se ejecutará.
7. Testeando la clase de configuración
No necesitas el pesado @SpringBootTest. ApplicationContextRunner crea un contexto pequeño que solo contiene lo que necesitas:
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));
}
}
Estos dos tests verifican lo siguiente:
- El primer test confirma que los valores se vinculan correctamente por tipo (
2saDuration,1.5aBigDecimal). También confirma que@DefaultValuese aplica amaxRetries,supportedCurrenciesywebhook. - El segundo test confirma que
max-retries=50viola@Max(10)y causa que el contexto falle al iniciar conBindValidationException.
Cada test se ejecuta en unos pocos cientos de milisegundos porque no hay servidor web ni otra auto-configuración.
8. Cuándo seguir usando @Value
@ConfigurationProperties no reemplaza completamente a @Value. Aún puedes usar @Value cuando:
- Necesitas SpEL. Por ejemplo
@Value("#{${app.payment.timeout-seconds} * 1000}").@ConfigurationPropertiesno soporta expresiones SpEL. - Solo necesitas un valor único, usado en un solo lugar. Por ejemplo
@Value("${spring.application.name}")en un bean de log. Crear un record completo para este caso sería excesivo.
Algunos errores comunes al migrar a @ConfigurationProperties:
- Olvidas registrar la clase. La falta de
@ConfigurationPropertiesScano@EnableConfigurationPropertiescausa el error “No qualifying bean of type PaymentProperties”. - El prefijo no sigue la convención. El prefijo debe estar en kebab-case, en minúsculas (
app.payment, noapp.Payment). Si es incorrecto, Spring reporta el error al iniciar. - Hay múltiples constructores. Cuando el record o la clase tiene más de un constructor, debes marcar el constructor usado para binding con
@ConstructorBinding. - Usa una clase para todo. No metas toda la configuración de la aplicación en un gigantesco
AppProperties. Divídela por dominio de negocio:PaymentProperties,MailProperties,StorageProperties…
Nota: Los records de configuración son inmutables. Si usas Spring Cloud para actualizar la configuración en tiempo de ejecución, el bean debe recrearse (a través de
@RefreshScopeu otro mecanismo de rebind) para que los nuevos valores surtan efecto. No esperes que el record se actualice automáticamente.
9. Conclusión
En este artículo, reemplazaste los @Value dispersos por un record PaymentProperties con tipos explícitos. Específicamente, aprendiste a:
- hacer binding de
Duration,DataSize,List,Mapy records anidados, con valores por defecto mediante@DefaultValue; - validar la configuración al iniciar usando
@Validatedjunto con restricciones de Jakarta; - obtener sugerencias de claves en el IDE gracias a
spring-boot-configuration-processor; - testear la parte de configuración de manera rápida y eficiente usando
ApplicationContextRunner.
Para cualquier grupo de configuración con dos o tres claves o más, @ConfigurationProperties debe ser tu opción por defecto. Reserva @Value para valores únicos o cuando realmente necesites SpEL.