
Por qué dejar de usar @Value
@Value("${app.email.host}") funciona, pero arrastra problemas en proyectos grandes:
- Cada
@Valueestá diseminado por el código, lo que dificulta saber qué propiedades usa la aplicación. - No hay validación: si la propiedad no existe, el placeholder queda como
${app.email.host}literal o explota en runtime. - No hay autocompletado en el IDE al escribir el nombre.
- El refactoring es manual: si se renombra
app.email.hostaapp.mail.host, hay que buscar y reemplazar a mano cada@Value.
@ConfigurationProperties resuelve los cuatro problemas. Define una clase tipada que se enlaza a un prefijo de propiedades. Spring Boot la valida en arranque, el IDE autocompleta y el refactoring es de tipos.
Configuración tipada con records
En Java 25 el patrón idiomático combina @ConfigurationProperties con records inmutables:
@ConfigurationProperties(prefix = "app.email")
@Validated
public record EmailProperties(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
@NotBlank String password,
boolean tls,
@DurationMin(seconds = 1) Duration timeout
) {
// Constructor compacto con valores por defecto si conviene
public EmailProperties {
if (timeout == null) timeout = Duration.ofSeconds(10);
}
}
Y el archivo application.yml:
app:
email:
host: smtp.example.com
port: 587
username: notifications@example.com
password: ${SMTP_PASSWORD}
tls: true
timeout: 30s
Spring Boot resuelve los placeholders, valida y construye una instancia inmutable. Si falta cualquier campo @NotBlank, la aplicación no arranca: lanza BindValidationException con el detalle de qué falló.
Registrar las clases de configuración
Hay dos formas:
@ConfigurationPropertiesScan en la clase principal
Registra automáticamente todas las clases con @ConfigurationProperties del paquete:
@SpringBootApplication
@ConfigurationPropertiesScan
public class EmpleadosApplication {
public static void main(String[] args) {
SpringApplication.run(EmpleadosApplication.class, args);
}
}
Es la forma más cómoda. Spring Boot busca clases anotadas y las registra como beans automáticamente.
@EnableConfigurationProperties explícito
Útil cuando la clase de configuración no está en el paquete principal o cuando se quiere controlar exactamente qué se registra:
@Configuration
@EnableConfigurationProperties({EmailProperties.class, KafkaProperties.class})
public class AppConfig {}
Inyección y uso
La clase con @ConfigurationProperties se inyecta como cualquier otro bean:
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailSenderService {
private final EmailProperties props;
public void enviar(String destinatario, String asunto, String cuerpo) {
log.info("Enviando email a {} via {}:{} (tls={})",
destinatario, props.host(), props.port(), props.tls());
// ...
}
}
El acceso es por método (porque es un record): props.host() en lugar de props.getHost(). El servicio queda independiente de la fuente de configuración: igual le da que venga de application.yml, de variables de entorno o de Vault.
Jerarquías anidadas
Para configuraciones complejas se anidan records:
@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
@Valid Email email,
@Valid Database database,
@Valid Security security
) {
public record Email(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
String password,
boolean tls
) {}
public record Database(
@NotBlank String url,
@NotBlank String username,
String password,
@Min(1) int poolSize
) {}
public record Security(
@NotBlank String jwtSecret,
@DurationMin(minutes = 1) Duration tokenExpiration,
List<String> allowedOrigins
) {}
}
app:
email:
host: smtp.example.com
port: 587
username: notifications@example.com
password: ${SMTP_PASSWORD}
tls: true
database:
url: jdbc:postgresql://localhost:5432/empleados
username: empleados_user
password: ${DB_PASSWORD}
pool-size: 10
security:
jwt-secret: ${JWT_SECRET}
token-expiration: 1h
allowed-origins:
- https://app.empresa.com
- https://admin.empresa.com
@Valid en cada subrecord asegura que la validación se propaga. Spring Boot acepta tanto pool-size (kebab-case en YAML) como poolSize (camelCase) y los enlaza al campo poolSize del record.
Conversiones automáticas
Spring Boot convierte automáticamente entre tipos:
Duration:30s,5m,1h,2d(basado en ISO 8601 o sintaxis simplificada)DataSize:10MB,1GB,512KBList<T>: cualquier YAML array o coma-separadoMap<K,V>: bloque YAML con clave-valorEnum: nombre o número del enum (case-insensitive)Path,URL,URI: strings que representan rutas o URLs
Ejemplo:
@ConfigurationProperties(prefix = "app.cache")
public record CacheProperties(
Duration ttl,
DataSize maxSize,
Map<String, Duration> perKeyTtl,
@NotNull EvictionPolicy evictionPolicy
) {
public enum EvictionPolicy { LRU, LFU, FIFO }
}
app:
cache:
ttl: 10m
max-size: 100MB
per-key-ttl:
empleados: 5m
departamentos: 1h
eviction-policy: LRU
Validación en arranque (fail-fast)
@Validated + Bean Validation aborta la aplicación si una propiedad obligatoria falta o tiene un valor inválido. El mensaje de error es explícito:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target [Bindable@67c33749 type = com.empresa.config.EmailProperties]
failed:
Property: app.email.password
Value: null
Reason: must not be blank
Esto es exactamente lo que se quiere en producción: fallar en arranque antes de aceptar tráfico, no fallar en runtime tres horas después.
Anti-patrón habitual: validar la configuración con código defensivo en cada uso (
if (props.host() == null) throw new IllegalStateException()). Mejor declarativo con anotaciones de validación; se aplica una vez en arranque y queda fuera del código de negocio.
Constructor compacto para defaults y normalización
Cuando hace falta un valor por defecto o una normalización, el constructor compacto del record es el lugar:
@ConfigurationProperties(prefix = "app.api")
@Validated
public record ApiProperties(
@NotBlank String baseUrl,
Duration timeout,
int maxRetries,
@Valid Pagination pagination
) {
public ApiProperties {
// Normalización: quitar slash final
if (baseUrl != null && baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
// Defaults
if (timeout == null) timeout = Duration.ofSeconds(30);
if (maxRetries < 0) maxRetries = 3;
if (pagination == null) pagination = new Pagination(20, 100);
}
public record Pagination(int defaultSize, int maxSize) {}
}
Metadata para autocompletado
Spring Boot genera autocompletado en IDEs leyendo el archivo META-INF/spring-configuration-metadata.json. Para que se genere automáticamente, hay que añadir el processor:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
Con esto, escribiendo app.email. en application.yml desde IntelliJ IDEA o VS Code, el IDE sugiere host, port, username, etc., con su tipo y descripción.
Para añadir descripciones que aparezcan en el autocompletado, hay que documentar los componentes del record con Javadoc (Spring Boot 3.4+ lo lee):
/**
* Configuración del servidor SMTP para notificaciones por email.
*
* @param host servidor SMTP, sin esquema
* @param port puerto del servidor SMTP, típicamente 25, 465 o 587
* @param tls activa STARTTLS sobre la conexión
*/
@ConfigurationProperties(prefix = "app.email")
public record EmailProperties(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
String password,
boolean tls
) {}
Testing de @ConfigurationProperties
Hay dos enfoques:
Test aislado con ApplicationContextRunner
Carga solo la configuración propia, sin levantar el contexto Spring completo:
class EmailPropertiesTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(EmailPropertiesConfig.class);
@Test
void cargaDesdeYaml() {
contextRunner
.withPropertyValues(
"app.email.host=smtp.test.com",
"app.email.port=587",
"app.email.username=user",
"app.email.password=pass",
"app.email.tls=true"
)
.run(context -> {
assertThat(context).hasSingleBean(EmailProperties.class);
var props = context.getBean(EmailProperties.class);
assertThat(props.host()).isEqualTo("smtp.test.com");
assertThat(props.port()).isEqualTo(587);
assertThat(props.tls()).isTrue();
});
}
@Test
void fallaSiFaltaHost() {
contextRunner
.withPropertyValues(
"app.email.port=587",
"app.email.username=user",
"app.email.password=pass"
)
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.hasRootCauseInstanceOf(BindValidationException.class)
.hasMessageContaining("must not be blank");
});
}
@EnableConfigurationProperties(EmailProperties.class)
static class EmailPropertiesConfig {}
}
Este test es muy rápido (no carga JPA, ni web, ni nada más) y verifica el contrato de configuración.
Test con @SpringBootTest y @ActiveProfiles
Más pesado pero útil para validar el yaml de un perfil completo:
@SpringBootTest
@ActiveProfiles("test")
class AppPropertiesIntegrationTest {
@Autowired AppProperties props;
@Test
void cargaConfigDeTest() {
assertThat(props.email().host()).isEqualTo("localhost");
assertThat(props.database().url()).startsWith("jdbc:h2:mem:");
}
}
Patrón de records inmutables vs clase mutable
Los records hacen que la configuración sea inmutable. Esto es deseable: la configuración no debe cambiar en runtime. Si por alguna razón se necesita configuración mutable (refresh dinámico con Spring Cloud Config Server), entonces una clase con @ConfigurationProperties y setters sigue siendo válida:
@ConfigurationProperties(prefix = "app.email")
@Setter
@Getter
@Validated
public class EmailProperties {
@NotBlank private String host;
@Min(1) @Max(65535) private int port;
private String username;
private String password;
private boolean tls;
}
Pero la regla por defecto es records inmutables: lo que cambia entre entornos viene resuelto en arranque, no en runtime.
Buenas prácticas
- Un record por dominio funcional:
EmailProperties,KafkaProperties,SecurityProperties. Evitar megaclases con cien campos. - Prefijo claro:
app.{dominio}para configuración propia, evitar nombres genéricos comoconfig.*. @Validatedsiempre: el coste de añadir la anotación es cero, el beneficio en producción es enorme.- Defaults razonables en el constructor compacto: que la aplicación arranque con la mínima configuración posible en local.
- Documentar con Javadoc: los IDEs lo muestran en autocompletado. Una propiedad sin documentar es una propiedad que nadie sabe para qué sirve seis meses después.
Combinar
@ConfigurationPropertiestipado + records de Java 25 +@Validated+ profiles produce configuración robusta, autocompletada y validada. Es uno de los patrones de mayor retorno por mínimo esfuerzo en proyectos Spring Boot serios.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Modelar configuración con @ConfigurationProperties y records de Java 25. Validar valores en arranque con Bean Validation. Definir jerarquías anidadas y colecciones. Generar metadata.json para autocompletado. Probar la configuración con tests aislados.