En la lección anterior sacamos la configuración de CicloUrbana del código, pero la solución quedó a medio camino: las claves están repartidas por varias clases, un camelCase mal escrito en un @Value falla en silencio, nadie comprueba que el precio por minuto sea positivo y el IDE no ayuda a escribirlas. Esta lección resuelve las cuatro cosas con @ConfigurationProperties, la forma tipada de leer configuración: agrupa las propiedades relacionadas en objetos inmutables, enlaza listas y mapas de forma natural, convierte Duration, DataSize y enumerados sin intervención, valida los valores en el arranque con Bean Validation y genera metadatos para que tu IDE autocomplete. Al terminar, toda la configuración de tarifas y de la red de Ribalta estará en dos record validados, y ese será su hogar definitivo durante el resto del curso.
Contenido
- Qué es
@ConfigurationProperties @Valuefrente a@ConfigurationProperties- Registrar las propiedades: tres formas
- Enlazado a
recordy a clase con setters - Estructuras anidadas, listas y mapas
- La configuración completa de CicloUrbana
- Validación con
@Validatedy Bean Validation - Conversión de tipos
- Metadatos para el IDE
- Propiedades sensibles
- Errores Comunes y Consejos
- Ejercicios
- Qué es
@ConfigurationProperties
@ConfigurationProperties@ConfigurationProperties enlaza un grupo de propiedades con un prefijo común a los campos de un objeto Java. En lugar de repartir cinco @Value por cuatro clases, defines un objeto que representa "la configuración de tarifas" y Spring lo rellena.
La idea en una imagen:
flowchart LR
Y["application.yaml<br/><br/>ciclourbana:<br/> tarifa:<br/> desbloqueo: 0.50<br/> precio-minuto: 0.12"] --> B["Binder de Spring Boot<br/>relajación de nombres<br/>+ conversión de tipos<br/>+ validación"]
B --> O["TarifaProperties<br/>desbloqueo = 0.50 (BigDecimal)<br/>precioMinuto = 0.12 (BigDecimal)"]
O --> S["Inyectado en TarifaEstandar,<br/>SelectorTarifa, ..."]
El objeto resultante es un bean como cualquier otro: se inyecta por constructor y se usa con seguridad de tipos.
@Value frente a @ConfigurationProperties
@Value frente a @ConfigurationProperties| Criterio | @Value |
@ConfigurationProperties |
|---|---|---|
| Unidad de trabajo | Una propiedad suelta | Un grupo con prefijo común |
| Relajación de nombres | No: coincidencia exacta | Sí: precio-minuto ≡ precioMinuto ≡ PRECIO_MINUTO |
| Listas YAML | No (solo cadenas con comas) | Sí, de forma natural |
| Mapas | No | Sí |
| Objetos anidados | No | Sí, a cualquier profundidad |
Validación (@NotBlank, @Min...) |
No | Sí, con @Validated |
| Metadatos para el IDE | No | Sí, con el processor |
| Conversión de tipos | Básica | Completa (Duration, DataSize, enums, Period...) |
SpEL (#{...}) |
Sí | No |
| Mensaje de error al fallar | Una propiedad cada vez | Todos los fallos de golpe |
| Dónde vive la configuración | Repartida por el código | Centralizada en clases dedicadas |
| Recomendación | Valores aislados y ocasionales | Todo lo demás |
La única capacidad exclusiva de @Value es SpEL. A cambio pierde todo lo demás. La regla que seguiremos en CicloUrbana:
Si una propiedad la lee una sola clase y no necesita validación,
@Valuees aceptable. En cuanto hay dos o más propiedades relacionadas, o dos clases que leen la misma, o cualquier restricción sobre el valor:@ConfigurationProperties.
- Registrar las propiedades: tres formas
Una clase con @ConfigurationProperties no se convierte en bean por sí sola: hay que registrarla. Hay tres mecanismos.
Forma 1: @ConfigurationPropertiesScan (la recomendada)
Se anota una vez la clase principal y Spring escanea el paquete base buscando clases @ConfigurationProperties:
package com.ciclourbana;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan // busca @ConfigurationProperties en com.ciclourbana
public class CicloUrbanaApplication {
public static void main(String[] args) {
SpringApplication.run(CicloUrbanaApplication.class, args);
}
}A partir de ahí, cualquier clase anotada con @ConfigurationProperties en el árbol de paquetes se registra automáticamente. Es la opción que usaremos: una anotación y ya no hay que acordarse de nada.
Forma 2: @EnableConfigurationProperties
Registra clases concretas, enumerándolas:
@Configuration
@EnableConfigurationProperties({TarifaProperties.class, RedProperties.class})
public class ConfiguracionCicloUrbana { }Es más verboso, pero tiene una virtud: es explícito. Es la forma habitual dentro de una autoconfiguración o de un starter, donde no hay escaneo de componentes del usuario. La usaremos así en la lección 02-06.
Forma 3: estereotipo directo
@Component
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public class TarifaProperties { /* ... */ }Funciona, pero solo con clases mutables (con setters): un record no puede ser @Component porque necesita el enlazado por constructor. Es la forma menos recomendable.
| Forma | Verbosidad | Cuándo usarla |
|---|---|---|
@ConfigurationPropertiesScan |
Mínima | Aplicaciones: una vez y listo |
@EnableConfigurationProperties |
Media | Autoconfiguraciones y starters |
@Component sobre la clase |
Mínima | Casi nunca: no admite record |
- Enlazado a
record y a clase con setters
record y a clase con settersSpring Boot 3 admite dos estilos de enlazado.
Enlazado por constructor: record inmutable (recomendado)
package com.ciclourbana.alquileres;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
/**
* Configuración de las tarifas de la red de Ribalta.
* Prefijo: ciclourbana.tarifa
*/
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public record TarifaProperties(
/** Importe fijo de desbloqueo, en euros. */
BigDecimal desbloqueo,
/** Importe por minuto de uso, en euros. */
BigDecimal precioMinuto
) {
}Con este YAML:
Un detalle importante de Spring Boot 3: @ConstructorBinding ya no hace falta si la clase tiene un único constructor con parámetros, que es siempre el caso de un record. En Boot 2 había que ponerlo explícitamente; verás mucho código antiguo con él. Si una clase tiene varios constructores, @ConstructorBinding marca cuál usar, y a partir de Boot 3 se anota sobre el constructor, no sobre la clase.
Enlazado por setters: clase mutable
package com.ciclourbana.alquileres;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public class TarifaProperties {
private BigDecimal desbloqueo = new BigDecimal("0.50"); // valor por defecto
private BigDecimal precioMinuto = new BigDecimal("0.12");
public BigDecimal getDesbloqueo() {
return desbloqueo;
}
public void setDesbloqueo(BigDecimal desbloqueo) {
this.desbloqueo = desbloqueo;
}
public BigDecimal getPrecioMinuto() {
return precioMinuto;
}
public void setPrecioMinuto(BigDecimal precioMinuto) {
this.precioMinuto = precioMinuto;
}
}Comparados:
| Criterio | record (constructor) |
Clase con setters |
|---|---|---|
| Inmutabilidad | Sí | No |
| Verbosidad | Mínima | Alta |
| Valores por defecto | En el constructor compacto o con @DefaultValue |
Inicializando el campo |
| Propiedades no definidas | Llegan como null |
Conservan el valor inicial |
| Modificable en caliente | No | Sí (Spring Cloud Config, ver 07-05) |
| Recomendación | Por defecto | Solo si necesitas mutabilidad |
Los valores por defecto en un record se declaran con @DefaultValue:
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public record TarifaProperties(
@DefaultValue("0.50") BigDecimal desbloqueo,
@DefaultValue("0.12") BigDecimal precioMinuto
) {
}Ahora, si el YAML no define ciclourbana.tarifa.desbloqueo, el valor será 0.50 en lugar de null.
Usarlo en CicloUrbana
package com.ciclourbana.alquileres;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
@Component
@Primary
public class TarifaEstandar implements CalculadoraTarifa {
private final TarifaProperties propiedades;
// Un solo parámetro en lugar de dos @Value
public TarifaEstandar(TarifaProperties propiedades) {
this.propiedades = propiedades;
}
@Override
public BigDecimal calcular(Duration duracion) {
BigDecimal minutos = BigDecimal.valueOf(Math.max(1, duracion.toMinutes()));
return propiedades.desbloqueo()
.add(propiedades.precioMinuto().multiply(minutos))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String nombre() {
return "estandar";
}
}Fíjate en la ganancia: el constructor pasa de dos parámetros anotados con cadenas de texto frágiles a un objeto tipado. En un test, new TarifaEstandar(new TarifaProperties(new BigDecimal("0.50"), new BigDecimal("0.12"))) es directo y no requiere Spring.
- Estructuras anidadas, listas y mapas
Aquí es donde @ConfigurationProperties se separa definitivamente de @Value.
Objetos anidados
Se declaran como record anidados:
@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
String ciudad,
Tarifa tarifa,
Red red
) {
public record Tarifa(BigDecimal desbloqueo, BigDecimal precioMinuto) { }
public record Red(int capacidadMinima, int umbralBateria) { }
}ciclourbana:
ciudad: Ribalta
tarifa:
desbloqueo: 0.50
precio-minuto: 0.12
red:
capacidad-minima: 8
umbral-bateria: 20El acceso es propiedades.tarifa().desbloqueo(). Puedes anidar tantos niveles como necesites; en la práctica más de tres se vuelve incómodo.
Listas
@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
List<String> estacionesDestacadas,
List<Mantenimiento> ventanasMantenimiento
) {
public record Mantenimiento(String dia, LocalTime desde, LocalTime hasta) { }
}ciclourbana:
estaciones-destacadas:
- Plaza Mayor
- Universidad
ventanas-mantenimiento:
- dia: MARTES
desde: "03:00"
hasta: "05:00"
- dia: JUEVES
desde: "03:00"
hasta: "04:30"En .properties la misma lista se escribe con índices, lo que ilustra por qué elegimos YAML:
ciclourbana.ventanas-mantenimiento[0].dia=MARTES
ciclourbana.ventanas-mantenimiento[0].desde=03:00
ciclourbana.ventanas-mantenimiento[0].hasta=05:00
ciclourbana.ventanas-mantenimiento[1].dia=JUEVESUn aviso: las listas no se fusionan entre fuentes de propiedades. Si application.yaml define tres estaciones destacadas y una variable de entorno define una, el resultado es una, no cuatro. La fuente de mayor prioridad reemplaza la lista completa.
Mapas
Es el caso más potente y el que resuelve el SelectorTarifa de la lección 02-02 de forma elegante:
@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
Map<String, BigDecimal> tarifasPorUsuario
) {
}Y el valor del mapa puede ser a su vez un objeto:
public record CicloUrbanaProperties(
Map<String, PerfilTarifa> tarifasPorUsuario
) {
public record PerfilTarifa(
BigDecimal desbloqueo,
BigDecimal precioMinuto,
int minutosGratis
) { }
}ciclourbana:
tarifas-por-usuario:
estandar:
desbloqueo: 0.50
precio-minuto: 0.12
minutos-gratis: 0
estudiante:
desbloqueo: 0.00
precio-minuto: 0.08
minutos-gratis: 15
jubilado:
desbloqueo: 0.00
precio-minuto: 0.05
minutos-gratis: 30Con esto, añadir un tipo de tarifa deja de requerir código: basta con añadir tres líneas al YAML. Es un salto cualitativo respecto a la solución de la lección 02-02, donde cada tarifa necesitaba su clase.
Dos advertencias sobre los mapas: las claves no se relajan (si escribes estudiante-becado, la clave es literalmente estudiante-becado, no estudianteBecado), y si una clave contiene caracteres especiales hay que encerrarla entre corchetes: ciclourbana.tarifas-por-usuario.[clave.con.puntos].precio-minuto.
- La configuración completa de CicloUrbana
Reunimos todo en dos clases de propiedades, que serán las definitivas del proyecto.
package com.ciclourbana.alquileres;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.math.BigDecimal;
import java.util.Map;
/**
* Configuración del sistema de tarifas de la red de Ribalta.
* Prefijo: ciclourbana.tarifas
*/
@ConfigurationProperties(prefix = "ciclourbana.tarifas")
public record TarifasProperties(
/** Importe fijo de desbloqueo por defecto, en euros. */
@DefaultValue("0.50") BigDecimal desbloqueo,
/** Importe por minuto por defecto, en euros. */
@DefaultValue("0.12") BigDecimal precioMinuto,
/** Perfiles de tarifa por tipo de usuario, indexados por su identificador. */
Map<String, PerfilTarifa> porTipoUsuario
) {
/** Condiciones económicas de un tipo de usuario. */
public record PerfilTarifa(
@DefaultValue("0.00") BigDecimal desbloqueo,
BigDecimal precioMinuto,
@DefaultValue("0") int minutosGratis
) { }
}package com.ciclourbana.estaciones;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.time.Duration;
import java.util.List;
/**
* Configuración operativa de la red de estaciones de Ribalta.
* Prefijo: ciclourbana.red
*/
@ConfigurationProperties(prefix = "ciclourbana.red")
public record RedProperties(
/** Nombre de la ciudad, usado en informes y en el saludo del arranque. */
@DefaultValue("Ribalta") String ciudad,
/** Anclajes mínimos exigidos para dar de alta una estación. */
@DefaultValue("8") int capacidadMinima,
/** Porcentaje de batería por debajo del cual una bicicleta se retira. */
@DefaultValue("20") int umbralBateria,
/** Tiempo máximo de un alquiler antes de aplicar recargo. */
@DefaultValue("2h") Duration duracionMaximaAlquiler,
/** Estaciones que se muestran destacadas en la aplicación móvil. */
@DefaultValue({"Plaza Mayor", "Universidad"}) List<String> estacionesDestacadas
) {
}Y el YAML correspondiente:
# src/main/resources/application.yaml
spring:
application:
name: ciclourbana
server:
port: 8080
shutdown: graceful
logging:
level:
com.ciclourbana: DEBUG
ciclourbana:
red:
ciudad: Ribalta
capacidad-minima: 8
umbral-bateria: 20
duracion-maxima-alquiler: 2h
estaciones-destacadas:
- Plaza Mayor
- Universidad
tarifas:
desbloqueo: 0.50
precio-minuto: 0.12
por-tipo-usuario:
estandar:
desbloqueo: 0.50
precio-minuto: 0.12
minutos-gratis: 0
estudiante:
precio-minuto: 0.08
minutos-gratis: 15
jubilado:
precio-minuto: 0.05
minutos-gratis: 30Ahora el selector de tarifas se apoya en la configuración en lugar de en las clases:
package com.ciclourbana.alquileres;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Set;
/**
* Calcula el importe de un alquiler a partir de los perfiles de tarifa
* definidos en la configuración. Añadir un tipo de usuario ya no requiere
* escribir una clase: basta con añadirlo al YAML.
*/
@Service
public class SelectorTarifa {
private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);
private final TarifasProperties tarifas;
public SelectorTarifa(TarifasProperties tarifas) {
this.tarifas = tarifas;
log.info("Perfiles de tarifa configurados: {}", tiposDisponibles());
}
public Set<String> tiposDisponibles() {
return tarifas.porTipoUsuario().keySet();
}
public BigDecimal calcular(String tipoUsuario, Duration duracion) {
TarifasProperties.PerfilTarifa perfil = tarifas.porTipoUsuario().get(tipoUsuario);
if (perfil == null) {
throw new IllegalArgumentException("Tipo de usuario desconocido: " + tipoUsuario
+ ". Disponibles: " + tiposDisponibles());
}
long facturables = Math.max(0, duracion.toMinutes() - perfil.minutosGratis());
return perfil.desbloqueo()
.add(perfil.precioMinuto().multiply(BigDecimal.valueOf(facturables)))
.setScale(2, RoundingMode.HALF_UP);
}
}Un apunte de diseño: la interfaz CalculadoraTarifa y sus implementaciones de la lección 02-02 siguen siendo útiles para tarifas con lógica propia (una tarifa de temporada alta que dependa de la fecha, por ejemplo, o una promocional con reglas complejas). Lo que hemos hecho es mover a configuración lo que solo eran datos. Distinguir una cosa de otra es un buen criterio de diseño: si la diferencia entre dos casos son unos números, es configuración; si es un algoritmo, es código.
- Validación con
@Validated y Bean Validation
@Validated y Bean ValidationUn precio por minuto negativo, una capacidad mínima de cero o una lista de tarifas vacía deberían impedir el arranque. @ConfigurationProperties se integra con Jakarta Bean Validation para conseguirlo de forma declarativa.
Primero, la dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Este es el starter que anunciamos en la tabla de la lección 01-04; lo usaremos también, y mucho, en la lección 03-04 para validar la entrada de la API.
Ahora las anotaciones:
package com.ciclourbana.alquileres;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Map;
@Validated // <-- activa la validación
@ConfigurationProperties(prefix = "ciclourbana.tarifas")
public record TarifasProperties(
@NotNull
@PositiveOrZero(message = "El desbloqueo no puede ser negativo")
@DefaultValue("0.50") BigDecimal desbloqueo,
@NotNull
@DecimalMin(value = "0.01", message = "El precio por minuto debe ser mayor que 0")
@DefaultValue("0.12") BigDecimal precioMinuto,
@NotEmpty(message = "Debe definirse al menos un perfil de tarifa")
Map<String, @Valid PerfilTarifa> porTipoUsuario // @Valid: valida cada valor
) {
public record PerfilTarifa(
@NotNull @PositiveOrZero
@DefaultValue("0.00") BigDecimal desbloqueo,
@NotNull
@DecimalMin(value = "0.01", message = "El precio por minuto debe ser positivo")
BigDecimal precioMinuto,
@Min(value = 0, message = "Los minutos gratis no pueden ser negativos")
@DefaultValue("0") int minutosGratis
) { }
}Y para la red:
@Validated
@ConfigurationProperties(prefix = "ciclourbana.red")
public record RedProperties(
@NotBlank(message = "Debe indicarse la ciudad de la red")
@DefaultValue("Ribalta") String ciudad,
@Min(value = 4, message = "Una estación necesita al menos 4 anclajes")
@Max(value = 100, message = "Ninguna estación de Ribalta supera los 100 anclajes")
@DefaultValue("8") int capacidadMinima,
@Min(0) @Max(100)
@DefaultValue("20") int umbralBateria,
@NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
@DefaultValue("2h") Duration duracionMaximaAlquiler,
@NotEmpty @DefaultValue({"Plaza Mayor", "Universidad"})
List<@NotBlank String> estacionesDestacadas
) {
}Las restricciones más útiles:
| Anotación | Aplica a | Comprueba |
|---|---|---|
@NotNull |
Cualquiera | No es null |
@NotBlank |
String |
No es null, no está vacía ni es solo espacios |
@NotEmpty |
String, colecciones, mapas |
No es null ni está vacía |
@Min / @Max |
Enteros | Rango |
@Positive / @PositiveOrZero |
Números | Signo |
@DecimalMin / @DecimalMax |
BigDecimal, decimales |
Rango con precisión decimal |
@Size(min, max) |
Cadenas y colecciones | Longitud o número de elementos |
@Pattern(regexp) |
String |
Expresión regular |
@Email |
String |
Formato de correo |
@Valid |
Objetos anidados y elementos de colección | Valida en cascada |
@DurationMin / @DurationMax |
Duration |
Rango temporal (de Spring Boot) |
El @Valid dentro del genérico —Map<String, @Valid PerfilTarifa>— es imprescindible: sin él, las restricciones de PerfilTarifa no se evalúan. Es el olvido más habitual al validar estructuras anidadas.
El fallo de arranque
Con esta configuración inválida:
ciclourbana:
red:
ciudad: ""
capacidad-minima: 2
umbral-bateria: 150
tarifas:
por-tipo-usuario:
estudiante:
precio-minuto: -0.05
minutos-gratis: -3El arranque falla con un informe completo, no de uno en uno:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.ciclourbana.estaciones.RedProperties failed:
Property: ciclourbana.red.ciudad
Value: ""
Reason: Debe indicarse la ciudad de la red
Property: ciclourbana.red.capacidad-minima
Value: "2"
Reason: Una estación necesita al menos 4 anclajes
Property: ciclourbana.red.umbral-bateria
Value: "150"
Reason: debe ser menor o igual que 100
Action:
Update your application's configurationEste mensaje es la razón principal para usar @ConfigurationProperties con validación: dice qué propiedad, qué valor y por qué está mal, y lo dice de todas a la vez. Compáralo con el Could not resolve placeholder de @Value y la diferencia es abismal.
Y la propiedad más valiosa de todo esto: el error ocurre en el arranque, no cuando un ciudadano de Ribalta intente pagar un alquiler con una tarifa negativa.
- Conversión de tipos
El binder de Spring Boot convierte automáticamente el texto del fichero al tipo Java declarado. Los casos que más se usan:
Duration
@DefaultValue("2h") Duration duracionMaximaAlquiler;
@DefaultValue("30s") Duration tiempoEsperaAnclaje;
@DefaultValue("500ms") Duration latenciaMaxima;ciclourbana:
red:
duracion-maxima-alquiler: 2h # 2 horas
tiempo-espera-anclaje: 30s # 30 segundos
intervalo-sincronizacion: PT15M # formato ISO-8601 también válido| Sufijo | Unidad | Ejemplo |
|---|---|---|
ns |
nanosegundos | 500ns |
us |
microsegundos | 200us |
ms |
milisegundos | 500ms |
s |
segundos | 30s |
m |
minutos | 15m |
h |
horas | 2h |
d |
días | 7d |
| (ninguno) | según @DurationUnit, por defecto milisegundos |
5000 |
Si prefieres escribir números sin sufijo, @DurationUnit fija la unidad:
@DurationUnit(ChronoUnit.MINUTES)
@DefaultValue("120") Duration duracionMaximaAlquiler; // 120 significa 120 minutosRecomendación: usa siempre el sufijo explícito (2h) en lugar de @DurationUnit. Es autodocumentado y no depende de leer el código Java para interpretar el fichero.
Existe el equivalente @PeriodUnit para java.time.Period (días, meses, años), útil para plazos de facturación.
DataSize
Para tamaños de fichero o de memoria:
@DefaultValue("5MB") DataSize tamanoMaximoFotoIncidencia;
@DefaultValue("512KB") DataSize tamanoMaximoInforme;Sufijos: B, KB, MB, GB, TB. Sin sufijo se interpretan bytes, salvo que uses @DataSizeUnit. Lo usarás de verdad en el módulo 3 al configurar la subida de fotos de incidencias.
Enumerados
package com.ciclourbana.estaciones;
/** Estado operativo de una estación de la red. */
public enum EstadoEstacion {
OPERATIVA, MANTENIMIENTO, FUERA_DE_SERVICIO
}ciclourbana:
red:
estado-por-defecto: mantenimiento # sin distinguir mayúsculas
# también valen: MANTENIMIENTO, Mantenimiento, fuera-de-servicioLa conversión de enumerados también aplica relajación: fuera-de-servicio, FUERA_DE_SERVICIO y fueraDeServicio se resuelven todos a FUERA_DE_SERVICIO. Si el valor no corresponde a ninguna constante, el arranque falla con un mensaje que lista los valores válidos, lo cual es excelente para el operador.
Colecciones y otros tipos
List<String> estacionesDestacadas; // lista YAML natural
Set<String> etiquetas; // sin duplicados
Map<String, Integer> cuposPorBarrio; // mapa
LocalTime horaCierre; // "23:30"
LocalDate inicioTemporada; // "2026-06-01"
Charset codificacionInformes; // "UTF-8"
Locale idiomaPorDefecto; // "es-ES"
Resource plantillaFactura; // "classpath:plantillas/factura.html"
Class<?> implementacion; // nombre completo de la clase
BigDecimal precio; // decimal exacto, obligatorio para dineroConversores propios
Si necesitas un tipo que Spring no sabe convertir, registra un Converter:
package com.ciclourbana.comun;
import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
/** Convierte "RB-0142" en un objeto Matricula. */
@Component
@ConfigurationPropertiesBinding // <-- imprescindible: lo hace visible al binder
public class ConversorMatricula implements Converter<String, Matricula> {
@Override
public Matricula convert(String origen) {
return Matricula.de(origen);
}
}La anotación @ConfigurationPropertiesBinding es la clave: sin ella, el conversor existe como bean pero el binder no lo usa.
- Metadatos para el IDE
Cuando escribes server.po en application.yaml, IntelliJ o VS Code te sugieren server.port y te muestran su descripción y su valor por defecto. Eso funciona porque Spring Boot publica metadatos en un fichero JSON dentro del jar. Tus propiedades pueden hacer lo mismo.
El processor de anotaciones
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency><optional>true</optional> es importante: el processor solo hace falta al compilar; no debe propagarse a quien dependa de tu artefacto.
Al compilar, genera target/classes/META-INF/spring-configuration-metadata.json a partir de tus clases @ConfigurationProperties y de sus comentarios Javadoc:
{
"groups": [
{
"name": "ciclourbana.red",
"type": "com.ciclourbana.estaciones.RedProperties",
"sourceType": "com.ciclourbana.estaciones.RedProperties"
}
],
"properties": [
{
"name": "ciclourbana.red.capacidad-minima",
"type": "java.lang.Integer",
"description": "Anclajes mínimos exigidos para dar de alta una estación.",
"sourceType": "com.ciclourbana.estaciones.RedProperties",
"defaultValue": 8
},
{
"name": "ciclourbana.red.umbral-bateria",
"type": "java.lang.Integer",
"description": "Porcentaje de batería por debajo del cual una bicicleta se retira.",
"sourceType": "com.ciclourbana.estaciones.RedProperties",
"defaultValue": 20
}
]
}Observa de dónde sale el campo description: del comentario Javadoc del componente del record. Esta es la mejor razón para documentar tus propiedades: el comentario no se queda en el código, aparece en el autocompletado de quien configure la aplicación.
Metadatos adicionales a mano
Para lo que el processor no puede deducir —valores permitidos, propiedades declaradas dinámicamente, marcas de obsolescencia— existe un fichero que escribes tú:
// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
"properties": [
{
"name": "ciclourbana.red.estado-por-defecto",
"type": "com.ciclourbana.estaciones.EstadoEstacion",
"description": "Estado con el que se dan de alta las estaciones nuevas.",
"defaultValue": "OPERATIVA"
},
{
"name": "ciclourbana.tarifas.tarifa-plana",
"type": "java.math.BigDecimal",
"description": "Tarifa plana mensual. Sustituida por ciclourbana.tarifas.suscripcion.",
"deprecation": {
"level": "error",
"reason": "Sustituida por el modelo de suscripciones.",
"replacement": "ciclourbana.tarifas.suscripcion.precio-mensual"
}
}
],
"hints": [
{
"name": "ciclourbana.red.estaciones-destacadas",
"values": [
{ "value": "Plaza Mayor", "description": "Estación de 24 anclajes en el centro." },
{ "value": "Estación Norte", "description": "Estación de 30 anclajes." },
{ "value": "Parque del Río", "description": "Estación de 18 anclajes." },
{ "value": "Universidad", "description": "Estación de 36 anclajes en el campus sur." }
]
}
]
}Los dos bloques resuelven necesidades distintas:
deprecationhace que el IDE tache la propiedad y muestre la alternativa. Con"level": "error"indica que ya no funciona en absoluto. Es la forma correcta de retirar una propiedad sin romper a los usuarios en silencio.hintsofrece valores sugeridos con descripción. Al escribirciclourbana.red.estaciones-destacadas:el IDE propone las cuatro estaciones de Ribalta.
Este fichero se fusiona con el generado automáticamente; no lo sustituye.
- Propiedades sensibles
Recuperamos el hilo de la lección anterior. Las credenciales no van al repositorio; ahora vemos cómo evitar además que se filtren por otras vías.
No las registres en el log
El fallo más común: un toString() automático que incluye la contraseña.
// MAL: el toString() de un record incluye TODOS los componentes
@ConfigurationProperties(prefix = "ciclourbana.pasarela")
public record PasarelaProperties(String url, String apiKey) { }log.info("Configuración de pasarela: {}", propiedades);
// -> PasarelaProperties[url=https://pagos.ribalta.example, apiKey=sk_live_9f3a2b1c8d7e]
// La clave acaba de quedar escrita en el fichero de log, en el agregador y en las copiasLa corrección: sobrescribir toString().
package com.ciclourbana.comun;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "ciclourbana.pasarela")
public record PasarelaProperties(
/** URL base de la pasarela de pago municipal. */
@NotBlank String url,
/** Clave de API. Nunca debe registrarse en el log ni versionarse. */
@NotBlank String apiKey
) {
/** Oculta la clave de API en cualquier salida de texto. */
@Override
public String toString() {
return "PasarelaProperties[url=" + url + ", apiKey=" + enmascarar(apiKey) + "]";
}
private static String enmascarar(String valor) {
if (valor == null || valor.length() < 8) {
return "****";
}
// Conserva los 4 primeros caracteres: suficiente para identificar la clave
return valor.substring(0, 4) + "****" + valor.substring(valor.length() - 2);
}
}Ocultación en Actuator
El endpoint /actuator/env expone toda la configuración. Spring Boot enmascara automáticamente las claves que contienen password, secret, key, token, credentials o vcap_services, y en Spring Boot 3 ese endpoint no está expuesto por defecto. Puedes ampliar la lista:
management:
endpoint:
env:
show-values: when-authorized # nunca 'always' en producción
configprops:
show-values: when-authorized
endpoints:
web:
exposure:
include: health,info # NO expongas env ni configprops sin másActuator se estudia en la lección 07-01; lo importante ahora es saber que existe y que exponerlo sin cuidado publica tu configuración entera.
Resumen de reglas
| Regla | Por qué |
|---|---|
| El valor nunca en el repositorio | Git no olvida (lección 02-04) |
Marcador sin valor por defecto (${CLAVE_API}) |
Falla en el arranque si falta |
toString() enmascarado |
Evita la filtración por logs |
Actuator sin env ni configprops expuestos |
Evita la filtración por HTTP |
| Rotar la credencial si se filtra | Borrarla del código no la invalida |
Errores Comunes y Consejos
Olvidar registrar la clase de propiedades. Sin @ConfigurationPropertiesScan, @EnableConfigurationProperties o @Component, la clase no es un bean y el arranque falla con NoSuchBeanDefinitionException. Es, de largo, el error número uno con @ConfigurationProperties.
Poner @ConfigurationProperties sobre una clase sin setters y sin constructor con parámetros. Todos los campos quedan a null sin ningún error. Con record no puede pasar; con clases mutables, sí.
Olvidar @Valid en un objeto anidado o en el genérico de una colección. Las restricciones internas no se evalúan y una configuración inválida pasa el arranque. Recuerda Map<String, @Valid PerfilTarifa>.
Poner @Validated en la clase anidada en lugar de en la raíz. @Validated va en la clase anotada con @ConfigurationProperties; la cascada hacia dentro la produce @Valid.
Esperar que las listas se fusionen entre fuentes. No lo hacen: la fuente de mayor prioridad reemplaza la lista completa.
Usar double para importes. 0.1 + 0.2 no es 0.3 en coma flotante binaria. En dinero, siempre BigDecimal.
Escribir el prefijo con mayúsculas o guiones bajos. El prefix de @ConfigurationProperties debe ir en minúsculas y kebab-case: ciclourbana.tarifas, no cicloUrbana.Tarifas. Spring Boot lo rechaza explícitamente.
No incluir el spring-boot-configuration-processor. No rompe nada, pero pierdes el autocompletado y la documentación en el IDE, que es una de las mejores ventajas del mecanismo. Y recuerda: si añades el processor con el IDE abierto, necesitarás recompilar y, en IntelliJ, a veces reimportar el proyecto Maven.
Consejo: una clase de propiedades por área funcional. TarifasProperties en .alquileres, RedProperties en .estaciones. Colocarlas junto a su funcionalidad, no en un paquete config genérico, mantiene la cohesión que decidimos en la lección 01-04.
Consejo: documenta cada componente con Javadoc. No es ceremonia: ese texto acaba en el autocompletado del IDE de quien configure la aplicación.
Consejo: valida siempre, aunque parezca excesivo. Cada restricción que añades convierte un posible error de producción en un fallo de arranque de treinta segundos.
Consejo: no dupliques configuración en el código. Si RedProperties.capacidadMinima es 8, EstacionService debe leer esa propiedad, no tener su propio if (capacidad < 8). Es exactamente lo que corregiremos en el primer ejercicio.
Ejercicios
Ejercicio 1: migrar EstacionService a la configuración
EstacionService tiene todavía la regla if (estacion.capacidad() < 8) con el 8 escrito a fuego. Inyecta RedProperties y usa capacidadMinima(). Añade además una validación que impida registrar una estación cuyo nombre no esté entre las destacadas si la red está en estado de mantenimiento (usa una propiedad booleana nueva ciclourbana.red.solo-destacadas, con valor por defecto false). Comprueba el comportamiento sobrescribiendo la propiedad por línea de comandos.
Ejercicio 2: propiedades de incidencias con validación y tipos
Crea IncidenciaProperties con prefijo ciclourbana.incidencias que incluya: tamano-maximo-foto (DataSize, por defecto 5MB, entre 100KB y 20MB), plazo-resolucion (Duration, por defecto 48h, mínimo 1 hora), prioridad-por-defecto (un enumerado Prioridad con BAJA, MEDIA, ALTA), destinatarios-aviso (lista de correos validados con @Email, no vacía). Escribe un CommandLineRunner que vuelque la configuración y comprueba el mensaje de error con valores inválidos.
Ejercicio 3: metadatos y ocultación de secretos
Añade el spring-boot-configuration-processor al pom.xml, documenta con Javadoc todos los componentes de RedProperties y verifica que aparecen en el JSON generado. Después crea PasarelaProperties con url y api-key, con la clave leída de una variable de entorno sin valor por defecto y un toString() enmascarado, y añade un additional-spring-configuration-metadata.json con sugerencias para ciclourbana.red.estaciones-destacadas.
Soluciones
Solución 1
package com.ciclourbana.estaciones;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMax;
import org.springframework.boot.convert.DurationMin;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.util.List;
@Validated
@ConfigurationProperties(prefix = "ciclourbana.red")
public record RedProperties(
/** Nombre de la ciudad donde opera la red. */
@NotBlank @DefaultValue("Ribalta") String ciudad,
/** Anclajes mínimos exigidos para dar de alta una estación. */
@Min(4) @Max(100) @DefaultValue("8") int capacidadMinima,
/** Porcentaje de batería por debajo del cual una bicicleta se retira. */
@Min(0) @Max(100) @DefaultValue("20") int umbralBateria,
/** Tiempo máximo de un alquiler antes de aplicar recargo. */
@NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
@DefaultValue("2h") Duration duracionMaximaAlquiler,
/** Estaciones destacadas en la aplicación móvil. */
@NotEmpty @DefaultValue({"Plaza Mayor", "Universidad"})
List<@NotBlank String> estacionesDestacadas,
/** Si es true, solo se admiten altas de estaciones destacadas. */
@DefaultValue("false") boolean soloDestacadas
) {
}package com.ciclourbana.estaciones;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@Service
public class EstacionService {
private static final Logger log = LoggerFactory.getLogger(EstacionService.class);
private final EstacionRepositorio estacionRepositorio;
private final RedProperties red;
public EstacionService(EstacionRepositorio estacionRepositorio, RedProperties red) {
this.estacionRepositorio = estacionRepositorio;
this.red = red;
}
public List<Estacion> listarTodas() {
return estacionRepositorio.buscarTodas().stream()
.sorted(Comparator.comparing(Estacion::nombre))
.toList();
}
public Optional<Estacion> buscarPorId(Long id) {
return estacionRepositorio.buscarPorId(id);
}
public Estacion registrar(Estacion estacion) {
// El umbral ya no está escrito a fuego: viene de la configuración
if (estacion.capacidad() < red.capacidadMinima()) {
throw new IllegalArgumentException(
"Una estación de " + red.ciudad() + " requiere al menos "
+ red.capacidadMinima() + " anclajes; recibidos: "
+ estacion.capacidad());
}
if (red.soloDestacadas() && !red.estacionesDestacadas().contains(estacion.nombre())) {
throw new IllegalStateException(
"La red está limitada a estaciones destacadas; '"
+ estacion.nombre() + "' no lo es");
}
Estacion guardada = estacionRepositorio.guardar(estacion);
log.info("Estación registrada en {}: {} ({} anclajes)",
red.ciudad(), guardada.nombre(), guardada.capacidad());
return guardada;
}
public int capacidadTotalRed() {
return estacionRepositorio.buscarTodas().stream()
.mapToInt(Estacion::capacidad)
.sum();
}
public long contar() {
return estacionRepositorio.contar();
}
}Verificación:
# Normal: se cargan las 4 estaciones de demostración
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Cargadas 4 estaciones, 108 anclajes en total
# Solo destacadas: "Estación Norte" y "Parque del Río" son rechazadas
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.red.solo-destacadas=true
# IllegalStateException: La red está limitada a estaciones destacadas;
# 'Estación Norte' no lo es
# Umbral más exigente: "Parque del Río" (18) pasa, pero no una de 16
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.red.capacidad-minima=20
# IllegalArgumentException: Una estación de Ribalta requiere al menos 20 anclajes;
# recibidos: 18Comentario: fíjate en que el mensaje de error se construye con los valores de configuración. Es un detalle pequeño con un efecto grande: quien lee el log entiende de inmediato qué regla se aplicó y con qué umbral, sin abrir el código.
Consejo: red.estacionesDestacadas().contains(...) es una búsqueda lineal sobre una lista. Con cuatro elementos es irrelevante, pero si la lista creciera, convendría convertirla a Set una sola vez —en un @PostConstruct del servicio, por ejemplo, aplicando lo aprendido en la lección 02-03.
Solución 2
package com.ciclourbana.incidencias;
/** Prioridad de atención de una incidencia reportada por un usuario. */
public enum Prioridad {
BAJA, MEDIA, ALTA
}package com.ciclourbana.incidencias;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMin;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.util.List;
/**
* Configuración del sistema de incidencias de la red de Ribalta.
* Prefijo: ciclourbana.incidencias
*/
@Validated
@ConfigurationProperties(prefix = "ciclourbana.incidencias")
public record IncidenciaProperties(
/** Tamaño máximo de la foto adjunta al reportar una incidencia. */
@NotNull @DefaultValue("5MB") DataSize tamanoMaximoFoto,
/** Plazo comprometido para resolver una incidencia. */
@NotNull @DurationMin(hours = 1) @DefaultValue("48h") Duration plazoResolucion,
/** Prioridad asignada a las incidencias que no indican una. */
@NotNull @DefaultValue("MEDIA") Prioridad prioridadPorDefecto,
/** Correos del equipo de mantenimiento que reciben el aviso. */
@NotEmpty List<@Email String> destinatariosAviso
) {
/**
* Bean Validation no cubre rangos de DataSize, así que lo validamos
* en el constructor compacto: se ejecuta durante el enlazado.
*/
public IncidenciaProperties {
if (tamanoMaximoFoto != null) {
long bytes = tamanoMaximoFoto.toBytes();
if (bytes < DataSize.ofKilobytes(100).toBytes()
|| bytes > DataSize.ofMegabytes(20).toBytes()) {
throw new IllegalArgumentException(
"ciclourbana.incidencias.tamano-maximo-foto debe estar entre "
+ "100KB y 20MB; recibido: " + tamanoMaximoFoto);
}
}
}
}ciclourbana:
incidencias:
tamano-maximo-foto: 5MB
plazo-resolucion: 48h
prioridad-por-defecto: media # relajado: se resuelve a MEDIA
destinatarios-aviso:
- [email protected]
- [email protected]package com.ciclourbana.incidencias;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AvisoConfiguracionIncidencias implements CommandLineRunner {
private static final Logger log =
LoggerFactory.getLogger(AvisoConfiguracionIncidencias.class);
private final IncidenciaProperties propiedades;
public AvisoConfiguracionIncidencias(IncidenciaProperties propiedades) {
this.propiedades = propiedades;
}
@Override
public void run(String... args) {
log.info("Incidencias | foto máx: {} ({} bytes) | plazo: {} ({} h) | "
+ "prioridad por defecto: {} | avisos a: {}",
propiedades.tamanoMaximoFoto(),
propiedades.tamanoMaximoFoto().toBytes(),
propiedades.plazoResolucion(),
propiedades.plazoResolucion().toHours(),
propiedades.prioridadPorDefecto(),
propiedades.destinatariosAviso());
}
}Incidencias | foto máx: 5242880 (5242880 bytes) | plazo: PT48H (48 h) |
prioridad por defecto: MEDIA | avisos a: [[email protected], [email protected]]Y con valores inválidos:
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
--ciclourbana.incidencias.plazo-resolucion=30m \
--ciclourbana.incidencias.destinatarios-aviso=esto-no-es-un-correoBinding to target com.ciclourbana.incidencias.IncidenciaProperties failed:
Property: ciclourbana.incidencias.plazo-resolucion
Value: "30m"
Reason: debe ser mayor o igual que 1 horas
Property: ciclourbana.incidencias.destinatarios-aviso[0]
Value: "esto-no-es-un-correo"
Reason: debe ser una dirección de correo electrónico con formato correctoComentario: el ejercicio combina las tres capacidades de conversión de tipos —DataSize, Duration y enumerado con relajación— con validación en cascada dentro de una lista (List<@Email String>). El constructor compacto del record es el sitio idiomático para validaciones que Bean Validation no cubre: se ejecuta durante el enlazado y su excepción se integra en el mismo informe de error.
Error frecuente: escribir plazo-resolucion: 48 sin sufijo. Se interpretaría como 48 milisegundos y fallaría la validación de mínimo 1 hora... afortunadamente. Sin esa validación, habrías tenido un plazo de resolución de 48 ms sin darte cuenta.
Solución 3
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>./mvnw clean compile
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json | head -40{
"groups": [
{
"name": "ciclourbana.red",
"type": "com.ciclourbana.estaciones.RedProperties",
"sourceType": "com.ciclourbana.estaciones.RedProperties"
}
],
"properties": [
{
"name": "ciclourbana.red.capacidad-minima",
"type": "java.lang.Integer",
"description": "Anclajes mínimos exigidos para dar de alta una estación.",
"sourceType": "com.ciclourbana.estaciones.RedProperties",
"defaultValue": 8
}
]
}La clase de la pasarela:
package com.ciclourbana.comun;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Credenciales de la pasarela de pago municipal de Ribalta.
* La clave de API NUNCA se versiona: llega por variable de entorno.
*/
@Validated
@ConfigurationProperties(prefix = "ciclourbana.pasarela")
public record PasarelaProperties(
/** URL base del servicio de pagos del ayuntamiento. */
@NotBlank @Pattern(regexp = "https://.*",
message = "La pasarela debe usar HTTPS") String url,
/** Clave de API. Se enmascara en toString() y no debe registrarse nunca. */
@NotBlank String apiKey
) {
@Override
public String toString() {
return "PasarelaProperties[url=" + url + ", apiKey=" + enmascarar(apiKey) + "]";
}
private static String enmascarar(String valor) {
if (valor == null || valor.length() < 8) {
return "****";
}
return valor.substring(0, 4) + "****" + valor.substring(valor.length() - 2);
}
}ciclourbana:
pasarela:
url: https://pagos.ribalta.example/api/v1
# Sin valor por defecto: si falta la variable, el arranque falla
api-key: ${CICLOURBANA_PASARELA_API_KEY}// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
"properties": [
{
"name": "ciclourbana.pasarela.api-key",
"type": "java.lang.String",
"description": "Clave de API de la pasarela. Debe llegar por la variable de entorno CICLOURBANA_PASARELA_API_KEY; nunca se versiona."
}
],
"hints": [
{
"name": "ciclourbana.red.estaciones-destacadas",
"values": [
{ "value": "Plaza Mayor", "description": "24 anclajes, centro histórico." },
{ "value": "Estación Norte", "description": "30 anclajes, intercambiador." },
{ "value": "Parque del Río", "description": "18 anclajes, zona verde." },
{ "value": "Universidad", "description": "36 anclajes, campus sur." }
]
},
{
"name": "ciclourbana.red.estado-por-defecto",
"values": [
{ "value": "OPERATIVA", "description": "La estación acepta alquileres." },
{ "value": "MANTENIMIENTO", "description": "Temporalmente cerrada." },
{ "value": "FUERA_DE_SERVICIO", "description": "Cerrada indefinidamente." }
]
}
]
}Prueba final:
# Sin la variable: falla, y es lo correcto
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Could not resolve placeholder 'CICLOURBANA_PASARELA_API_KEY'
# Con la variable, y comprobando que el log no la revela
CICLOURBANA_PASARELA_API_KEY='sk_live_9f3a2b1c8d7e' \
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Pasarela configurada: PasarelaProperties[url=https://pagos.ribalta.example/api/v1, apiKey=sk_l****7e]Comentario: el @Pattern(regexp = "https://.*") sobre la URL es un detalle que merece la pena copiar. Impide por configuración que alguien apunte la pasarela de pago a un endpoint sin cifrar, y lo hace en el arranque, no cuando ya se ha enviado la primera tarjeta de crédito en claro.
Sobre el enmascarado: conservar los cuatro primeros caracteres es un compromiso deliberado. Permite a un operador identificar qué clave está usando (sk_live_ frente a sk_test_) sin revelar la clave. Enmascarar el 100 % es más seguro pero hace imposible diagnosticar un despliegue con la credencial equivocada.
Conclusión
La configuración de CicloUrbana ha alcanzado su forma definitiva. Sabes que @ConfigurationProperties enlaza un grupo de propiedades con prefijo común a un objeto tipado, y conoces las once diferencias que lo hacen superior a @Value en todo salvo en SpEL. Sabes registrarlo con @ConfigurationPropertiesScan en una aplicación y con @EnableConfigurationProperties en un starter —lo necesitarás en la próxima lección—. Sabes enlazar a un record inmutable, que en Spring Boot 3 ya no necesita @ConstructorBinding, y dar valores por defecto con @DefaultValue. Dominas las estructuras que @Value no alcanza: objetos anidados, listas y mapas de objetos, con la consecuencia práctica de que añadir una tarifa a la red de Ribalta pasó de escribir una clase a escribir tres líneas de YAML. Sabes validar con @Validated y Bean Validation, incluida la cascada con @Valid dentro de genéricos, y has visto el informe de arranque que enumera todos los fallos con propiedad, valor y motivo. Conoces la conversión automática de Duration, DataSize, enumerados y colecciones, y cómo registrar un conversor propio con @ConfigurationPropertiesBinding. Sabes generar metadatos para que el IDE autocomplete tus propiedades a partir de tu propio Javadoc, y ampliarlos a mano con sugerencias y marcas de obsolescencia. Y sabes proteger un secreto en las tres vías por las que se filtra: el repositorio, el log y Actuator.
El proyecto cuenta ahora con TarifasProperties (con su mapa de perfiles por tipo de usuario), RedProperties (capacidad mínima, umbral de batería, duración máxima y estaciones destacadas), IncidenciaProperties y PasarelaProperties, todas validadas, y con un EstacionService que aplica reglas configurables en lugar de constantes.
Queda una sola pieza del contenedor por abrir, y es la más característica de Spring Boot. Desde la primera lección hemos dicho que la autoconfiguración "detecta lo que hay en el classpath y registra los beans apropiados", y lo hemos aceptado como una caja negra. Añadimos spring-boot-starter-web y aparecen Tomcat, Jackson y el DispatcherServlet sin escribir una línea. ¿Cómo lo decide exactamente? ¿Y por qué basta con declarar tu propio bean para que Spring Boot se aparte? La última lección del módulo, Autoconfiguración y Starters por Dentro, responde a eso leyendo el mecanismo real —el fichero AutoConfiguration.imports, el AutoConfigurationImportSelector, las anotaciones condicionales— y aprendiendo a depurarlo con el informe de autoconfiguración. Y terminaremos construyendo nuestro propio starter, ciclourbana-tarifas-spring-boot-starter, con la configuración tipada que acabamos de escribir.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
