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.gatewayUrl en lugar de gateway-url, solo descubrirás el error al ejecutar.
  • No hay validación. max-retries=-5 se inyecta sin problemas.
  • La configuración se dispersa. Si RefundService también necesita merchant-id, tienes que repetir @Value allí.
  • Difícil de testear. Para testear PaymentService sin crear un contexto de Spring, tienes que usar reflection para asignar campos.
  • Los tipos complejos son incómodos. Inyectar un Map<String, BigDecimal> con @Value requiere 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 bajo app.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 entorno APP_PAYMENT_GATEWAYURL) al mismo parámetro gatewayUrl.
  • @DefaultValue proporciona un valor por defecto cuando la clave no está en la configuración.
  • La cadena 5s se convierte automáticamente en Duration.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 @Component al record que usa constructor binding. En ese caso, Spring lo considera un bean normal e intenta hacer autowire de String gatewayUrl como otro bean. La aplicación fallará al iniciar. Regístralo usando @ConfigurationPropertiesScan o @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:

  • DataSize entiende las unidades B, KB, MB, GB. 2MB produce DataSize.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.
  • Webhook es un record anidado y se vincula recursivamente bajo el prefijo app.payment.webhook.
  • @DefaultValue sin valor, colocado en webhook, hace que Spring cree un Webhook con valores por defecto cuando falta todo el bloque webhook. Sin esta anotación, webhook() devolvería null.

Nota: Con Duration, un número sin unidad se interpreta como milisegundos. timeout: 30 significa 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) {
    }
}
  • @Validated activa la validación inmediatamente después de completar el binding.
  • @Valid en webhook asegura que las restricciones dentro de Webhook tambié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 annotationProcessorPaths en maven-compiler-plugin (por ejemplo para Lombok o MapStruct), debes añadir spring-boot-configuration-processor a 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 (2s a Duration, 1.5 a BigDecimal). También confirma que @DefaultValue se aplica a maxRetries, supportedCurrencies y webhook.
  • El segundo test confirma que max-retries=50 viola @Max(10) y causa que el contexto falle al iniciar con BindValidationException.

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}"). @ConfigurationProperties no 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 @ConfigurationPropertiesScan o @EnableConfigurationProperties causa 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, no app.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 @RefreshScope u 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, Map y records anidados, con valores por defecto mediante @DefaultValue;
  • validar la configuración al iniciar usando @Validated junto 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.

spring-bootconfigurationconfigurationpropertiesvalidationjava