La lección anterior terminó señalando la deuda más grande de BiblioTech: un ContenedorSimple de ciento cincuenta líneas que resuelve dependencias por reflexión y al que le falta absolutamente todo lo demás. Sin ámbitos, sin ciclo de vida, sin perfiles, sin propiedades externalizadas, sin resolución de ambigüedad y sin transacciones.
Esta lección lo sustituye por el contenedor que lleva veinte años puliéndose.
Spring es el framework dominante del back-end en Java, y su núcleo es exactamente lo que tú escribiste: un contenedor que instancia objetos, resuelve sus dependencias y gestiona su ciclo de vida. Todo lo demás —web, persistencia, seguridad, mensajería, observabilidad— está construido encima de esa base.
La ventaja con la que llegas es enorme. No vas a aprender Spring como una lista de anotaciones que hay que memorizar: vas a reconocer, una por una, las piezas que ya construiste. Cuando veas @Component, verás tu @Componente. Cuando veas cómo el contenedor resuelve el constructor, verás tu getDeclaredConstructors(). Y cuando llegues a @Transactional, verás tu ProxyAuditoria — con la misma trampa de la llamada interna incluida.
Al terminar sabrás qué es el ApplicationContext y cómo se declaran beans; por qué la inyección por constructor es la única recomendable y qué problemas concretos tiene la inyección por campo; qué añade cada estereotipo; cómo funciona el ciclo de vida de un bean y qué ámbitos existen; cómo se resuelven las ambigüedades cuando hay dos implementaciones; cómo se externaliza la configuración con propiedades tipadas y perfiles; qué es Spring Boot y qué es la autoconfiguración de verdad —incluido cómo depurarla—; y cómo funciona AOP por debajo con los proxies que ya conoces.
Y BiblioTech pasará de su contenedor casero a Spring, con perfiles dev y prod y configuración tipada.
Contenido
- Qué es Spring y qué es Spring Boot
- Los módulos de Spring Framework
- El contenedor de IoC:
ApplicationContext - Qué es un bean y cómo se declara
- Las tres formas de inyección
- Por qué la inyección por constructor
- Comparativa con el
ContenedorSimplecasero - Estereotipos:
@Component,@Service,@Repository,@Controller @Configuration+@Beanfrente al escaneo de componentes- El ciclo de vida de un bean
- Ámbitos de un bean
- Resolución de ambigüedad:
@Qualifier,@Primaryy colecciones @Valuey las propiedades externalizadas@ConfigurationProperties: configuración tipada y validada- Perfiles:
@Profileyspring.profiles.active - BiblioTech: de
Configuraciona la configuración de Spring - Spring Boot: el
pom.xmly los starters @SpringBootApplicationdiseccionada- La autoconfiguración, explicada de verdad
- Depurar la autoconfiguración
CommandLineRunneryApplicationRunner- El jar ejecutable y
spring-boot-maven-plugin - AOP en Spring: aspectos y puntos de corte
@Transactional: el aspecto canónico- Cómo funciona por debajo: los proxies que ya escribiste
- La trampa de la llamada interna
- BiblioTech sobre Spring: el resultado
- Qué NO se ve en esta lección
- Errores Comunes y Consejos
- Ejercicios
- Qué es Spring y qué es Spring Boot
Es la primera confusión de todo el mundo, y conviene despejarla antes de escribir una línea.
Spring Framework (versión 6.x en este curso) es el framework: el contenedor de IoC, la abstracción de datos, la programación orientada a aspectos, el modelo web MVC, la gestión de transacciones. Es la funcionalidad.
Spring Boot (versión 3.x) no sustituye a Spring Framework: lo envuelve. Es una capa que resuelve tres problemas que Spring "a pelo" tenía y que hacían que arrancar un proyecto costara un día entero:
| Problema | Solución de Spring Boot |
|---|---|
| Elegir versiones compatibles de 30 dependencias | Starters: una dependencia trae un conjunto coherente y ya probado |
Escribir 300 líneas de XML o de @Bean para lo de siempre |
Autoconfiguración: si detecta H2 en el classpath, configura el DataSource solo |
Desplegar un .war en un servidor de aplicaciones |
Servidor embebido y jar ejecutable: java -jar bibliotech.jar |
Una analogía útil: Spring Framework es el motor; Spring Boot es el coche montado, con el motor dentro, las ruedas puestas y la llave en el contacto. Puedes usar el motor solo —y hay quien lo hace—, pero prácticamente nadie empieza un proyecto nuevo así.
Un detalle importante para leer documentación: todo lo que aprendas de Spring Framework sirve en Spring Boot. @Component, @Autowired, @Bean, @Transactional son de Spring Framework. @SpringBootApplication, los starters y la autoconfiguración son de Spring Boot. En esta lección aprendes primero el núcleo (apartados 3-16) y después la capa de Boot (17-22), porque ese es el orden en que se entienden.
- Los módulos de Spring Framework
Spring no es un bloque monolítico. Estos son los módulos que importan:
| Módulo | Qué aporta | Dónde se ve |
|---|---|---|
| spring-core / spring-beans | El contenedor de IoC, BeanFactory, inyección |
Esta lección |
| spring-context | ApplicationContext, eventos, @Configuration, planificación |
Esta lección |
| spring-aop | Programación orientada a aspectos con proxies | Esta lección (§23-26) |
| spring-tx | Gestión de transacciones, @Transactional |
Esta lección y 11-03 |
| spring-jdbc / spring-orm | JdbcTemplate, integración con JPA/Hibernate |
11-03 |
| spring-web / spring-webmvc | Cliente y servidor HTTP, controladores REST | 12-04 |
| spring-test | @SpringBootTest, MockMvc, contexto de pruebas |
11-04, 11-06, 12-05 |
Los tres primeros son el núcleo, y son el objeto de esta lección.
- El contenedor de IoC:
ApplicationContext
ApplicationContextEl corazón de Spring es el contenedor. Su trabajo es exactamente el de tu ContenedorSimple:
- Averiguar qué objetos hay que crear.
- Averiguar qué necesita cada uno.
- Crearlos en el orden correcto, inyectando las dependencias.
- Guardarlos y entregarlos cuando se piden.
- Destruirlos ordenadamente al cerrar.
En Spring, ese contenedor es un ApplicationContext. Puedes crearlo a mano para verlo funcionar:
package com.nexussoftware.bibliotech;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import com.nexussoftware.bibliotech.servicio.GestorPrestamos;
public class ArranqueManual {
public static void main(String[] args) {
// 1. Se crea el contenedor indicándole dónde buscar los componentes.
var contexto = new AnnotationConfigApplicationContext(
"com.nexussoftware.bibliotech");
// 2. Se pide un bean por tipo. Spring ya lo ha construido, con
// sus dependencias resueltas recursivamente.
GestorPrestamos gestor = contexto.getBean(GestorPrestamos.class);
gestor.prestar("978-0000000001", "Marta Ruiz");
// 3. Al cerrar, se ejecutan los métodos de destrucción de todos los beans.
contexto.close();
}
}Tres líneas, y ya está todo el mecanismo. Compáralo con tu contenedor:
// Tu ContenedorSimple de 10-03
var contenedor = new ContenedorSimple("com.nexussoftware.bibliotech");
GestorPrestamos gestor = contenedor.obtener(GestorPrestamos.class);Es la misma API. Lo que cambia es todo lo que hay debajo.
Un matiz de vocabulario que aparece en la documentación: BeanFactory es la interfaz base del contenedor (crear beans, inyectar). ApplicationContext extiende BeanFactory y añade internacionalización, eventos, carga de recursos y integración con AOP. En la práctica siempre usas ApplicationContext.
- Qué es un bean y cómo se declara
Un bean es, sencillamente, un objeto gestionado por el contenedor de Spring. Nada más. No es una clase especial, no implementa ninguna interfaz, no hereda de nada. Es un objeto normal que, en vez de crear tú con new, crea Spring.
Hay dos formas de declararlos, y ambas se usan:
Forma 1: anotar la clase (para tus propias clases)
package com.nexussoftware.bibliotech.servicio;
import org.springframework.stereotype.Service;
@Service // "Spring, este es un bean tuyo"
public class CalculadoraMultas {
// ...
}Forma 2: un método @Bean en una clase @Configuration (para clases de terceros que no puedes anotar)
package com.nexussoftware.bibliotech.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ConfiguracionReloj {
@Bean
public Clock reloj() {
// Clock es del JDK: no puedes ponerle @Component.
return Clock.systemDefaultZone();
}
}Ese Clock es exactamente el de 10-05, el que hiciste inyectable para poder probar. Ahora lo gestiona Spring, y en 11-04 lo sustituirás por un Clock.fixed en las pruebas sin tocar una línea de la lógica.
La regla práctica: @Component (y derivados) para tus clases; @Bean para lo que no puedes anotar (clases del JDK, de una librería externa, o cuando la construcción requiere lógica).
- Las tres formas de inyección
Spring puede inyectar dependencias de tres maneras. Las tres funcionan; solo una es recomendable.
Inyección por constructor (la recomendada)
package com.nexussoftware.bibliotech.servicio;
import org.springframework.stereotype.Service;
import com.nexussoftware.bibliotech.persistencia.RepositorioPrestamos;
@Service
public class GestorPrestamos {
private final RepositorioPrestamos repositorio;
private final CalculadoraMultas calculadora;
private final ServicioAvisos avisos;
// Desde Spring 4.3: si solo hay UN constructor, @Autowired es opcional.
public GestorPrestamos(RepositorioPrestamos repositorio,
CalculadoraMultas calculadora,
ServicioAvisos avisos) {
this.repositorio = repositorio;
this.calculadora = calculadora;
this.avisos = avisos;
}
}Fíjate en que no hay ni una sola anotación de Spring en el constructor. Esa clase es Java normal: se puede instanciar con new en una prueba sin arrancar nada.
Inyección por setter
@Service
public class GestorPrestamos {
private RepositorioPrestamos repositorio; // no puede ser final
@Autowired
public void setRepositorio(RepositorioPrestamos repositorio) {
this.repositorio = repositorio;
}
}Su único uso legítimo son las dependencias verdaderamente opcionales, que son raras.
Inyección por campo (la que hay que evitar)
@Service
public class GestorPrestamos {
@Autowired
private RepositorioPrestamos repositorio; // ¡tentadora, y mala idea!
}Es la más corta de escribir, y por eso está por todas partes en tutoriales antiguos. Sus problemas son concretos, no estéticos:
- No se puede probar sin Spring ni sin reflexión. El campo es privado y no hay constructor ni setter. Para una prueba unitaria tienes que arrancar el contexto entero o usar
ReflectionTestUtils. Con constructor, esnew GestorPrestamos(mock1, mock2, mock3). - No se puede usar
final. Pierdes la inmutabilidad y la garantía de que la dependencia no cambia. - Oculta el exceso de dependencias. Un constructor con nueve parámetros grita "esta clase hace demasiado". Nueve campos anotados no llaman la atención de nadie. El constructor feo es información útil.
- Permite estados inconsistentes. Con inyección por campo, el objeto existe un instante construido pero sin dependencias. Si algo se ejecuta ahí, hay
NullPointerException. - Acopla la clase a Spring.
@Autowireden el campo hace que la clase solo funcione dentro de un contenedor.
Comparadas:
| Aspecto | Constructor | Setter | Campo |
|---|---|---|---|
Permite final |
Sí | No | No |
| Objeto siempre válido tras construirse | Sí | No | No |
Se puede instanciar en una prueba con new |
Sí | Sí | No |
| Detecta dependencias circulares | En arranque, con error claro | Las oculta | Las oculta |
| Hace visible el exceso de dependencias | Sí | No | No |
| Clase independiente de Spring | Sí | No | No |
| Recomendación oficial de Spring | Sí | Opcionales | Desaconsejada |
Dependencias circulares
Un detalle relevante: con inyección por constructor, si A necesita B y B necesita A, el arranque falla con un mensaje explícito. Eso es bueno: un ciclo es un problema de diseño y quieres saberlo. Con inyección por campo el ciclo se resuelve silenciosamente y el problema queda enterrado.
Tu ContenedorSimple también detectaba ciclos y lanzaba una excepción — por la misma razón y con la misma lógica.
- Por qué la inyección por constructor
Resumido en una frase: porque produce clases que son Java normal.
// Prueba unitaria (lo verás en 11-04 y 11-06). Sin Spring, sin contexto, en 3 ms.
var gestor = new GestorPrestamos(
repositorioSimulado,
new CalculadoraMultas(Clock.fixed(...)),
avisosSimulados);Si la clase se puede construir así, se puede probar. Si no, no. Ese es todo el argumento, y es suficiente.
- Comparativa con el
ContenedorSimple casero
ContenedorSimple caseroVale la pena ver el salto explícito, con tu código al lado.
| Aspecto | Tu ContenedorSimple |
Spring |
|---|---|---|
| Marcar un componente | @Componente |
@Component, @Service, @Repository, @Controller |
| Escaneo | Recorrías el paquete con reflexión | @ComponentScan (implícito en @SpringBootApplication) |
| Inyección | Constructor único obligatorio | Constructor, setter o campo |
| Resolución | Recursiva por tipo | Por tipo, con desempate por nombre, @Qualifier y @Primary |
| Ciclos | Excepción propia | BeanCurrentlyInCreationException con la cadena completa |
| Caché | Un HashMap<Class<?>, Object> |
Registro de singletons con tres niveles de caché |
| Ámbitos | Solo singleton | 6 ámbitos |
| Ciclo de vida | Ninguno | @PostConstruct, @PreDestroy, InitializingBean, DisposableBean, BeanPostProcessor |
| Configuración externa | Ninguna | @Value, @ConfigurationProperties, perfiles, jerarquía de fuentes |
| Aspectos | Tres proxies aplicados a mano | AOP integrado y transparente |
| Errores | NullPointerException o excepción genérica |
Mensajes que dicen qué bean falta, dónde se pedía y qué candidatos hay |
Ese último punto se subestima siempre y es el que más tiempo ahorra en la práctica. Un error típico de Spring:
Parameter 0 of constructor in com.nexussoftware.bibliotech.servicio.GestorPrestamos
required a bean of type 'com.nexussoftware.bibliotech.persistencia.RepositorioPrestamos'
that could not be found.
Action:
Consider defining a bean of type 'RepositorioPrestamos' in your configuration.Te dice el bean, el constructor, el parámetro y qué hacer.
- Estereotipos:
@Component, @Service, @Repository, @Controller
@Component, @Service, @Repository, @ControllerSpring ofrece cuatro anotaciones para marcar componentes. La primera pregunta de todo el mundo es: ¿en qué se diferencian?
Técnicamente, en muy poco. @Service, @Repository y @Controller están anotadas con @Component. Son meta-anotaciones, exactamente el mecanismo que estudiaste en 10-02:
// Código real de Spring, simplificado
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // <-- @Service ES un @Component
public @interface Service {
@AliasFor(annotation = Component.class)
String value() default "";
}Diferencias reales:
| Anotación | Capa | Qué añade además de ser un bean |
|---|---|---|
@Component |
Genérica | Nada. Es la base |
@Service |
Lógica de negocio | Nada técnico. Semántica: marca la capa de servicio |
@Repository |
Acceso a datos | Traducción de excepciones: convierte las excepciones propias de la tecnología (SQLException, excepciones de Hibernate) en la jerarquía DataAccessException de Spring |
@Controller |
Presentación web | Lo hace elegible para el mapeo de peticiones de Spring MVC |
La traducción de excepciones de @Repository sí es funcionalidad real y muy relevante: gracias a ella, tu capa de servicio no depende de si debajo hay JDBC, Hibernate o JPA. Es un caso de libro de aislar la tecnología, y conecta con las estrategias por capas del módulo 6.
Aplicado a BiblioTech:
@Service // capa de negocio
public class GestorPrestamos { ... }
@Service
public class CalculadoraMultas { ... }
@Repository // capa de datos
public class RepositorioPrestamosJpa implements RepositorioPrestamos { ... }
@Component // infraestructura genérica: no encaja en ninguna capa
public class RegistroOperaciones { ... }Consejo: usa el estereotipo correcto aunque técnicamente den igual. Es documentación gratuita, y algunas herramientas y aspectos se apoyan en ellos.
@Configuration + @Bean frente al escaneo de componentes
@Configuration + @Bean frente al escaneo de componentesHay dos estilos de decirle a Spring qué beans existen, y en un proyecto real conviven.
Escaneo de componentes
Spring recorre el classpath bajo ese paquete, busca clases con @Component o derivados y las registra. Es lo que hace @SpringBootApplication implícitamente sobre su propio paquete.
- A favor: cero configuración. Añades una clase con
@Servicey ya es un bean. - En contra: las dependencias son implícitas. Para saber qué beans hay, hay que buscar anotaciones por todo el código.
Configuración explícita con @Bean
package com.nexussoftware.bibliotech.config;
import java.time.Clock;
import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.context.annotation.*;
@Configuration
public class ConfiguracionBiblioTech {
@Bean
public Clock reloj() {
return Clock.systemDefaultZone();
}
@Bean
public HttpClient clienteHttp() {
// El HttpClient de 09-06: caro de crear, hay que reutilizarlo.
// Como singleton de Spring, se crea UNA vez.
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
@Bean
public ClienteMetadatos clienteMetadatos(HttpClient http,
@Value("${bibliotech.metadatos.url}") String url) {
// Los parámetros del método @Bean son OTROS beans: Spring los inyecta.
return new ClienteMetadatos(http, url);
}
}Tres cosas que aprender de este bloque:
- El nombre del método es el nombre del bean (
reloj,clienteHttp,clienteMetadatos). - Los parámetros de un método
@Beanse inyectan igual que los de un constructor. - Aquí se resuelve por fin un problema real de 09-06: el
HttpClientes caro de crear y había que reutilizarlo. Como singleton del contenedor, se crea una sola vez y se inyecta donde haga falta.
Un detalle que sorprende: el modo full de @Configuration
@Configuration
public class Config {
@Bean
public Clock reloj() { return Clock.systemDefaultZone(); }
@Bean
public CalculadoraMultas calculadora() {
return new CalculadoraMultas(reloj()); // llamada directa a otro @Bean
}
@Bean
public ServicioAvisos avisos() {
return new ServicioAvisos(reloj()); // ¿otro Clock distinto?
}
}Intuitivamente, reloj() se llama dos veces y hay dos relojes. No es así. Spring genera una subclase CGLIB de la clase @Configuration que intercepta las llamadas a métodos @Bean y devuelve el singleton ya creado. Hay un solo Clock.
Y ahí tienes, otra vez, tu proxy dinámico de 10-03 — esta vez por generación de subclase en vez de por interfaz. Esta es también la razón de que una clase @Configuration no pueda ser final.
| Estilo | Cuándo usarlo |
|---|---|
@Component + escaneo |
Tus clases de dominio, servicios y repositorios |
@Configuration + @Bean |
Clases de terceros, del JDK, o construcción con lógica condicional |
- El ciclo de vida de un bean
Un bean no se limita a "existir". Pasa por una secuencia de fases, y en varias puedes engancharte.
graph TD
A["Arranque del contenedor"] --> B["Lectura de definiciones de bean<br/>(escaneo + @Bean)"]
B --> C["Instanciación<br/>(se llama al constructor)"]
C --> D["Inyección de dependencias<br/>(setters y campos)"]
D --> E["Aware: BeanNameAware,<br/>ApplicationContextAware"]
E --> F["BeanPostProcessor<br/>antes de la inicialización"]
F --> G["@PostConstruct"]
G --> H["afterPropertiesSet<br/>(InitializingBean)"]
H --> I["BeanPostProcessor<br/>después de la inicialización<br/>AQUÍ SE CREAN LOS PROXIES AOP"]
I --> J["BEAN LISTO Y EN USO"]
J --> K["context.close()"]
K --> L["@PreDestroy"]
L --> M["destroy (DisposableBean)"]
M --> N["Bean destruido"]
Dos observaciones que valen oro:
- La inyección por constructor ocurre en la instanciación; la de setter y campo, después. Por eso con inyección por campo hay un instante en que el objeto existe incompleto.
- Los proxies AOP se crean en el último
BeanPostProcessor. Ese es el punto exacto en que@Transactionaldeja de ser una etiqueta y se convierte en un envoltorio. Recuérdalo para el apartado 26.
En la práctica solo usarás dos ganchos, y ambos son de Jakarta, no de Spring:
package com.nexussoftware.bibliotech.persistencia;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Repository;
@Repository
public class CatalogoEnMemoria {
private final Map<String, Material> porIsbn = new ConcurrentHashMap<>();
@PostConstruct
public void cargarCatalogoInicial() {
// Se ejecuta DESPUÉS de la inyección: aquí todas las dependencias
// están disponibles. En el constructor podrían no estarlo.
porIsbn.putAll(importador.importarDesdeRecurso("catalogo-inicial.csv"));
log.info("Catálogo cargado con {} materiales", porIsbn.size());
}
@PreDestroy
public void guardarEstado() {
// Se ejecuta al cerrar el contexto de forma ordenada.
// Sustituye al ApagadoOrdenado con shutdown hooks de 08-05.
escritor.volcar(porIsbn.values());
log.info("Catálogo persistido antes del apagado");
}
}Y ahí queda saldada otra pieza: el ApagadoOrdenado que escribiste con shutdown hooks en el módulo 8 ya no hace falta. El contenedor cierra los beans en orden inverso al de creación, que es justo lo que quieres: un servicio se cierra antes que el repositorio del que depende.
Una regla importante para el constructor: no hagas trabajo pesado en él. Nada de conectar a bases de datos, leer ficheros o llamar a la red. Para eso está @PostConstruct.
- Ámbitos de un bean
Un ámbito (scope) define cuántas instancias crea el contenedor y cuánto viven.
| Ámbito | Instancias | Vida | Cuándo usarlo |
|---|---|---|---|
singleton (por defecto) |
Una por contenedor | Todo el ciclo de la aplicación | Casi siempre: servicios, repositorios, configuración |
prototype |
Una nueva en cada petición del bean | La gestiona quien lo usa | Objetos con estado mutable no compartible |
request |
Una por petición HTTP | La petición | Datos de la petición actual (solo web) |
session |
Una por sesión HTTP | La sesión del usuario | Carrito, preferencias (solo web) |
application |
Una por ServletContext |
La aplicación web | Raro |
websocket |
Una por sesión WebSocket | La sesión | Raro |
@Service
@Scope("prototype")
public class InformeMensual {
// Cada vez que se pide, se obtiene un objeto nuevo.
}Y aquí, la consecuencia más importante de todas, que enlaza directamente con el módulo 8:
Los beans singleton son compartidos por todos los hilos. Deben ser sin estado o seguros ante la concurrencia.
// MAL: estado mutable en un singleton
@Service
public class ContadorPrestamos {
private int total = 0; // compartido por TODOS los hilos
public void registrar() { total++; } // condición de carrera (08-04)
}
// BIEN: sin estado
@Service
public class CalculadoraMultas {
private final Clock reloj; // final, inmutable
public BigDecimal calcular(Prestamo p) { ... } // solo variables locales
}
// BIEN: estado, pero seguro ante la concurrencia (08-06)
@Service
public class ContadorPrestamos {
private final AtomicLong total = new AtomicLong();
public void registrar() { total.incrementAndGet(); }
}Este es uno de los errores más frecuentes y más difíciles de diagnosticar en aplicaciones Spring: funciona perfectamente en desarrollo con un usuario y da resultados incoherentes en producción con cincuenta. Todo lo que aprendiste en el módulo 8 se aplica directamente aquí.
Segunda trampa clásica: inyectar un prototype dentro de un singleton. Como el singleton se construye una sola vez, recibe una sola instancia del prototipo y la usa siempre. La solución habitual es inyectar un ObjectProvider<T> y pedir una instancia nueva cuando haga falta:
@Service
public class GeneradorInformes {
private final ObjectProvider<InformeMensual> proveedor;
public GeneradorInformes(ObjectProvider<InformeMensual> proveedor) {
this.proveedor = proveedor;
}
public void generar() {
InformeMensual informe = proveedor.getObject(); // instancia NUEVA
// ...
}
}
- Resolución de ambigüedad:
@Qualifier, @Primary y colecciones
@Qualifier, @Primary y coleccionesSpring inyecta por tipo. ¿Qué pasa si hay dos implementaciones?
public interface ServicioAvisos {
void avisar(Empleado destinatario, String mensaje);
}
@Service
public class AvisosPorCorreo implements ServicioAvisos { ... }
@Service
public class AvisosPorSms implements ServicioAvisos { ... }Y alguien pide un ServicioAvisos. El arranque falla:
Parameter 2 of constructor in ...GestorPrestamos required a single bean,
but 2 were found:
- avisosPorCorreo
- avisosPorSmsTu ContenedorSimple fallaba igual, pero sin decirte los candidatos. Hay cuatro soluciones, y cada una tiene su caso.
@Primary: el candidato por defecto
Quien pida un ServicioAvisos sin más recibirá el de correo. Útil cuando hay una implementación claramente principal.
@Qualifier: elegir explícitamente
@Service("avisosSms")
public class AvisosPorSms implements ServicioAvisos { ... }
@Service
public class GestorPrestamos {
public GestorPrestamos(@Qualifier("avisosSms") ServicioAvisos avisos) { ... }
}Un qualifier propio (más limpio y verificable)
Retoma directamente las meta-anotaciones de 10-02:
@Qualifier("sms")
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD})
public @interface PorSms { }
@Service @PorSms
public class AvisosPorSms implements ServicioAvisos { ... }
// Uso: sin cadenas mágicas, con verificación del compilador
public GestorPrestamos(@PorSms ServicioAvisos avisos) { ... }Mejor que @Qualifier("sms") porque una cadena mal escrita falla en ejecución; un nombre de anotación mal escrito no compila.
Inyectar todas las implementaciones
A veces no quieres elegir: las quieres todas. Spring inyecta una List con todos los beans del tipo.
@Service
public class NotificadorMultiCanal {
private final List<ServicioAvisos> canales;
public NotificadorMultiCanal(List<ServicioAvisos> canales) {
this.canales = canales; // correo, SMS y los que se añadan mañana
}
public void avisarPorTodosLosCanales(Empleado destinatario, String mensaje) {
canales.forEach(canal -> canal.avisar(destinatario, mensaje));
}
}Esto es potente de verdad: añadir un canal nuevo es crear una clase con @Service. Ni una línea del notificador cambia. Es el principio abierto/cerrado hecho realidad, y es un patrón que verás mucho (estrategias, validadores, manejadores de eventos).
También funciona con Map<String, ServicioAvisos>, donde la clave es el nombre del bean, y se puede ordenar con @Order o implementando Ordered.
| Mecanismo | Cuándo |
|---|---|
@Primary |
Hay una implementación principal evidente |
@Qualifier("nombre") |
Se elige explícitamente, caso puntual |
| Qualifier propio | Se elige explícitamente y quieres verificación del compilador |
List<T> / Map<String,T> |
Quieres todas las implementaciones |
@Profile |
La elección depende del entorno (§15) |
@Value y las propiedades externalizadas
@Value y las propiedades externalizadasNinguna aplicación debe llevar la configuración escrita a fuego. En 07-07 resolviste esto con Properties y tus clases Configuracion y ReglasNegocio. Spring lo lleva mucho más lejos.
Un application.properties en src/main/resources:
bibliotech.nombre=BiblioTech - Nexus Software
bibliotech.prestamo.dias-por-defecto=15
bibliotech.prestamo.maximo-por-empleado=3
bibliotech.multa.euros-por-dia=0.50
bibliotech.multa.maxima=20.00
bibliotech.metadatos.url=https://api.ejemplo.com/libros
bibliotech.avisos.activados=trueO el mismo contenido en application.yml, más legible cuando hay jerarquía:
bibliotech:
nombre: BiblioTech - Nexus Software
prestamo:
dias-por-defecto: 15
maximo-por-empleado: 3
multa:
euros-por-dia: 0.50
maxima: 20.00
metadatos:
url: https://api.ejemplo.com/libros
avisos:
activados: trueSe leen con @Value:
@Service
public class CalculadoraMultas {
private final BigDecimal eurosPorDia;
private final BigDecimal multaMaxima;
private final Clock reloj;
public CalculadoraMultas(
@Value("${bibliotech.multa.euros-por-dia}") BigDecimal eurosPorDia,
@Value("${bibliotech.multa.maxima:20.00}") BigDecimal multaMaxima,
Clock reloj) {
this.eurosPorDia = eurosPorDia;
this.multaMaxima = multaMaxima;
this.reloj = reloj;
}
}Detalles importantes:
- Conversión automática de tipos: la cadena
"0.50"llega comoBigDecimal. Spring convierte aint,boolean,Duration,List<String>, enums y muchos más. - Valor por defecto con
:—${propiedad:valorPorDefecto}. Sin él, si la propiedad falta, el arranque falla. Eso suele ser lo correcto: mejor no arrancar que arrancar mal configurado.
De dónde salen las propiedades
Spring Boot busca en varias fuentes con una precedencia definida. De mayor a menor prioridad, simplificada:
| Prioridad | Fuente | Ejemplo |
|---|---|---|
| 1 | Argumentos de línea de comandos | --bibliotech.multa.euros-por-dia=0.75 |
| 2 | Variables de entorno | BIBLIOTECH_MULTA_EUROSPORDIA=0.75 |
| 3 | Propiedades del sistema | -Dbibliotech.multa.euros-por-dia=0.75 |
| 4 | application-{perfil}.properties |
application-prod.properties |
| 5 | application.properties |
El general |
| 6 | Valores por defecto en el código | ${prop:0.50} |
Esta jerarquía es la que hace posible el despliegue moderno: la misma imagen de contenedor en desarrollo, preproducción y producción, cambiando solo variables de entorno. Se desarrolla en 12-06.
Nota de seguridad: las contraseñas y claves de API nunca van en
application.propertiesdentro del repositorio. Van en variables de entorno o en un gestor de secretos. Se trata en 12-07.
@ConfigurationProperties: configuración tipada y validada
@ConfigurationProperties: configuración tipada y validada@Value funciona, pero con quince propiedades se vuelve ruidoso y no valida nada. La alternativa moderna agrupa la configuración en un objeto.
package com.nexussoftware.bibliotech.config;
import java.math.BigDecimal;
import jakarta.validation.constraints.*;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "bibliotech")
public record PropiedadesBiblioTech(
@NotBlank String nombre,
@NotNull Prestamo prestamo,
@NotNull Multa multa) {
public record Prestamo(
@Min(1) @Max(90) int diasPorDefecto,
@Min(1) @Max(10) int maximoPorEmpleado) { }
public record Multa(
@DecimalMin("0.0") BigDecimal eurosPorDia,
@DecimalMin("0.0") BigDecimal maxima) { }
}Se activa en la clase principal:
@SpringBootApplication
@ConfigurationPropertiesScan // detecta los @ConfigurationProperties
public class BiblioTechApplication { ... }Y se inyecta como cualquier bean:
@Service
public class CalculadoraMultas {
private final PropiedadesBiblioTech propiedades;
private final Clock reloj;
public CalculadoraMultas(PropiedadesBiblioTech propiedades, Clock reloj) {
this.propiedades = propiedades;
this.reloj = reloj;
}
public BigDecimal calcular(Prestamo prestamo) {
LocalDate hoy = LocalDate.now(reloj);
long diasRetraso = ChronoUnit.DAYS.between(prestamo.fechaVencimiento(), hoy);
if (diasRetraso <= 0) return BigDecimal.ZERO;
BigDecimal multa = propiedades.multa().eurosPorDia()
.multiply(BigDecimal.valueOf(diasRetraso));
return multa.min(propiedades.multa().maxima());
}
}Ventajas frente a @Value:
| Aspecto | @Value |
@ConfigurationProperties |
|---|---|---|
| Agrupación | Propiedad a propiedad | Objeto completo con jerarquía |
| Tipado | Sí | Sí, y estructurado |
| Validación | No | Sí, Jakarta Bean Validation |
| Nombres relajados | No | Sí (dias-por-defecto = diasPorDefecto = DIAS_POR_DEFECTO) |
| Autocompletado en el IDE | No | Sí (con el procesador de metadatos) |
| Fallo si la configuración es inválida | En el punto de uso | En el arranque |
Esa última fila es la decisiva. Si alguien despliega con maximo-por-empleado=0, quieres que la aplicación no arranque con un mensaje claro, no que los empleados no puedan pedir libros y nadie sepa por qué:
Binding to target ...PropiedadesBiblioTech failed:
Property: bibliotech.prestamo.maximo-por-empleado
Value: "0"
Reason: debe ser mayor que o igual a 1Fíjate además en que el bloque de validación es exactamente lo que hacía tu ValidadorAnotado de 10-03: anotaciones leídas por reflexión que comprueban el valor de un campo. La diferencia es que esta versión está estandarizada, cubre treinta casos y produce mensajes traducidos.
- Perfiles:
@Profile y spring.profiles.active
@Profile y spring.profiles.activeUn perfil es un entorno con nombre. Permite que existan beans y propiedades distintos según dónde se ejecute la aplicación.
Ficheros por perfil, todos en src/main/resources:
# application.properties (común a todos)
bibliotech.nombre=BiblioTech - Nexus Software
bibliotech.prestamo.dias-por-defecto=15# application-dev.properties
bibliotech.multa.euros-por-dia=0.10
bibliotech.avisos.activados=false
logging.level.com.nexussoftware=DEBUG
spring.datasource.url=jdbc:h2:mem:bibliotech# application-prod.properties
bibliotech.multa.euros-por-dia=0.50
bibliotech.avisos.activados=true
logging.level.com.nexussoftware=INFO
spring.datasource.url=${BD_URL}
spring.datasource.username=${BD_USUARIO}
spring.datasource.password=${BD_PASSWORD}Fíjate en la última parte: en producción, las credenciales no están en el fichero, se leen de variables de entorno.
Se activa un perfil de tres formas:
# 1. Argumento de línea de comandos
java -jar bibliotech.jar --spring.profiles.active=prod
# 2. Variable de entorno (lo habitual en contenedores)
export SPRING_PROFILES_ACTIVE=prod
# 3. En application.properties (solo para el valor por defecto de desarrollo)
# spring.profiles.active=devY beans distintos según el perfil:
@Service
@Profile("dev")
public class AvisosDeConsola implements ServicioAvisos {
// En desarrollo no se envían correos: se imprimen.
public void avisar(Empleado destinatario, String mensaje) {
System.out.printf("[AVISO SIMULADO] %s -> %s%n", destinatario.correo(), mensaje);
}
}
@Service
@Profile("prod")
public class AvisosPorCorreo implements ServicioAvisos {
// En producción se envían de verdad.
}Con esto se resuelve un problema muy real: durante el desarrollo, nadie envía correos de multa a compañeros por accidente. Y no hay ningún if (esDesarrollo) en el código.
Un aviso: no abuses de los perfiles. Si tienes ocho perfiles cruzados (dev, prod, test, sinCorreo, conCache...) la combinatoria se vuelve inmanejable y nadie sabe qué beans hay activos. Dos o tres, y el resto por propiedades.
- BiblioTech: de
Configuracion a la configuración de Spring
Configuracion a la configuración de SpringEl antes y el después completo. Esto es lo que tenías en 07-07:
// ANTES (módulo 7): Properties a mano
public final class ReglasNegocio {
private static final Properties PROPIEDADES = new Properties();
static {
try (var entrada = ReglasNegocio.class.getResourceAsStream("/reglas.properties")) {
PROPIEDADES.load(entrada);
} catch (IOException e) {
throw new ExcepcionInicializacion("No se pudieron cargar las reglas", e);
}
}
public static int diasPrestamo() {
return Integer.parseInt(PROPIEDADES.getProperty("prestamo.dias", "15"));
}
public static BigDecimal multaPorDia() {
return new BigDecimal(PROPIEDADES.getProperty("multa.euros.dia", "0.50"));
}
}Problemas, todos reales:
static: no se puede sustituir en una prueba. Es exactamente el "código que no se puede probar" que 11-04 va a analizar.- Sin validación:
prestamo.dias=abclanzaNumberFormatExceptionel día que alguien pida un préstamo, no al arrancar. - Sin entornos: un solo fichero para desarrollo y producción.
- Conversión manual en cada acceso:
Integer.parseIntynew BigDecimalcada vez que se llama. - Un bloque
staticque puede fallar: si el fichero no está, el error aparece en unExceptionInInitializerErrorincomprensible.
Y esto es lo que tienes ahora:
// DESPUÉS: un record inmutable, validado, inyectable y por entorno
@Validated
@ConfigurationProperties(prefix = "bibliotech")
public record PropiedadesBiblioTech(
@NotBlank String nombre,
@NotNull Prestamo prestamo,
@NotNull Multa multa) { ... }| Aspecto | ReglasNegocio (módulo 7) |
PropiedadesBiblioTech (Spring) |
|---|---|---|
| Acceso | static, global |
Inyectado, sustituible |
| Tipos | Conversión manual cada vez | Convertidos una vez al arrancar |
| Validación | Ninguna | Jakarta Validation, en el arranque |
| Entornos | Uno | Los perfiles que hagan falta |
| Sobrescribir en despliegue | Imposible sin recompilar | Variables de entorno |
| Probable en pruebas | No | Sí, se construye con new |
| Fallo con configuración inválida | En ejecución, tarde | Al arrancar, con mensaje claro |
- Spring Boot: el
pom.xml y los starters
pom.xml y los startersAhora sí, la capa de Boot. Este es el pom.xml mínimo de BiblioTech (Maven se explica a fondo en 11-05; aquí solo lo necesario para ejecutar):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- El "padre" aporta el BOM: fija versiones compatibles de TODO -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/>
</parent>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>BiblioTech</name>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Núcleo: contenedor IoC, autoconfiguración, logging -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Validación: @NotNull, @Min... para @ConfigurationProperties -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- AOP: necesario para @Transactional y aspectos propios -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Pruebas: JUnit 5, AssertJ, Mockito. Se usa en 11-04 y 11-06 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Fíjate en que ninguna dependencia lleva <version>. Las gestiona el spring-boot-starter-parent, que garantiza que las versiones de Spring, Jackson, Logback, Tomcat y todo lo demás son compatibles entre sí. Poner una versión a mano ahí es quitarle ese trabajo y arriesgarse a un conflicto (se explica en 11-05).
Qué es un starter
Un starter es una dependencia que no contiene código: solo declara un conjunto coherente de dependencias. spring-boot-starter trae:
| Artefacto que arrastra | Para qué |
|---|---|
spring-boot |
El arranque |
spring-boot-autoconfigure |
Toda la autoconfiguración |
spring-core, spring-context, spring-beans |
El contenedor de IoC |
spring-boot-starter-logging |
SLF4J + Logback (¡ya lo tienes! — 11-07) |
snakeyaml |
Lectura de application.yml |
Los starters más habituales:
| Starter | Qué trae |
|---|---|
spring-boot-starter |
Núcleo, autoconfiguración, logging |
spring-boot-starter-web |
MVC, REST, Tomcat embebido, Jackson (12-04) |
spring-boot-starter-data-jpa |
Hibernate, JPA, Spring Data, transacciones (11-03) |
spring-boot-starter-test |
JUnit 5, Mockito, AssertJ, spring-test (11-04, 11-06) |
spring-boot-starter-validation |
Jakarta Bean Validation |
spring-boot-starter-aop |
AspectJ y proxies |
spring-boot-starter-actuator |
Métricas y sondas de salud (12-07) |
spring-boot-starter-security |
Autenticación y autorización (12-07) |
Este es un momento excelente para ejecutar lo del apartado 15 de la lección anterior:
Verás que ese pom.xml de treinta líneas ha traído unos cuarenta jars. Y verás con tus ojos que Jackson, SLF4J y Logback ya están ahí, antes de que 11-07 los explique.
@SpringBootApplication diseccionada
@SpringBootApplication diseccionadaLa clase principal de BiblioTech:
package com.nexussoftware.bibliotech;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class BiblioTechApplication {
public static void main(String[] args) {
SpringApplication.run(BiblioTechApplication.class, args);
}
}Esas dos líneas de main arrancan todo. @SpringBootApplication es una meta-anotación que equivale exactamente a tres:
@Configuration // esta clase puede declarar @Bean
@EnableAutoConfiguration // activa la autoconfiguración
@ComponentScan // escanea ESTE paquete y sus subpaquetes
public class BiblioTechApplication { }| Anotación | Qué hace |
|---|---|
@SpringBootConfiguration |
Marca la clase como fuente de definiciones de beans (es un @Configuration especializado) |
@EnableAutoConfiguration |
Activa el mecanismo del apartado 19 |
@ComponentScan |
Escanea el paquete de la clase y todos sus subpaquetes |
De esa última fila sale una regla práctica que ahorra horas de desconcierto:
La clase principal debe estar en el paquete raíz.
com.nexussoftware.bibliotech.BiblioTechApplicationescanea.dominio,.servicio,.persistencia,.red,.presentacion. Si la pusieras en.presentacion, no encontraría ningún servicio y el arranque fallaría con "no se encuentra el bean".
Qué hace SpringApplication.run internamente, en orden:
- Deduce el tipo de aplicación (consola, web servlet, web reactiva) según lo que hay en el classpath.
- Crea el
ApplicationContextadecuado. - Carga las fuentes de propiedades (
application.properties, perfiles, entorno, argumentos). - Registra las definiciones de bean: escaneo +
@Bean+ autoconfiguración. - Instancia todos los singletons, inyectando dependencias.
- Ejecuta
@PostConstructy crea los proxies AOP. - Arranca el servidor embebido si lo hay.
- Ejecuta los
CommandLineRunneryApplicationRunner. - Registra un gancho de apagado para cerrar el contexto de forma ordenada.
- La autoconfiguración, explicada de verdad
Este es el punto donde Spring Boot parece magia, y donde deja de parecerlo.
El problema que resuelve. Configurar un DataSource a mano en Spring "clásico" son unas cuarenta líneas: crear el pool de conexiones, configurar el EntityManagerFactory, el gestor de transacciones, el dialecto... Y en el 95 % de los proyectos, esas cuarenta líneas son idénticas.
La idea. Si Spring Boot puede detectar qué tienes en el classpath y qué has configurado, puede aportar la configuración obvia por ti — y apartarse en cuanto tú definas la tuya.
El mecanismo, en tres piezas.
Pieza 1: un catálogo de configuraciones candidatas. El artefacto spring-boot-autoconfigure contiene un fichero de texto:
con unos 150 nombres de clase, uno por línea:
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
...@EnableAutoConfiguration lee ese fichero. Es un catálogo de "cosas que podrían configurarse".
Pieza 2: condiciones. Cada candidata está llena de anotaciones @Conditional, que deciden si se aplica o no. Ejemplo real simplificado:
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // <-- LA CLAVE
public DataSource dataSource(DataSourceProperties propiedades) {
return propiedades.initializeDataSourceBuilder().build();
}
}Las condiciones más importantes:
| Condición | Se cumple cuando |
|---|---|
@ConditionalOnClass |
La clase está en el classpath (o sea: tienes la dependencia) |
@ConditionalOnMissingClass |
La clase no está |
@ConditionalOnBean |
Ya existe un bean de ese tipo |
@ConditionalOnMissingBean |
NO existe un bean de ese tipo: "solo si tú no lo has hecho" |
@ConditionalOnProperty |
Una propiedad tiene cierto valor |
@ConditionalOnWebApplication |
Es una aplicación web |
@ConditionalOnMissingFilterBean |
No hay un filtro de ese tipo registrado |
Pieza 3: el orden. Las autoconfiguraciones se evalúan después de tus beans. Por eso @ConditionalOnMissingBean funciona: cuando se evalúa, tus definiciones ya están registradas.
Y de ahí sale la regla de oro de Spring Boot:
La autoconfiguración nunca te pisa. Si tú defines un bean, el suyo no se crea.
Ejemplo concreto con H2, el que usarás en 11-03:
graph TD
A["Arranca Spring Boot"] --> B{"¿Está la clase<br/>DataSource en el classpath?"}
B -->|No| Z["No hace nada"]
B -->|Sí| C{"¿Has definido tú<br/>un bean DataSource?"}
C -->|Sí| Y["Se usa el TUYO.<br/>La autoconfiguración se aparta"]
C -->|No| D{"¿Hay una BD embebida<br/>(H2, HSQL, Derby)?"}
D -->|Sí| E["Crea un DataSource H2<br/>en memoria, sin configuración"]
D -->|No| F["Usa spring.datasource.url<br/>y el pool HikariCP"]
Ese diagrama explica por qué, en 11-03, bastará con añadir la dependencia de H2 para tener una base de datos funcionando sin escribir una línea de configuración. No es magia: son dos condiciones.
- Depurar la autoconfiguración
Cuando algo se configura solo y no era lo que querías, o cuando no se configura y no sabes por qué, Spring Boot te da el informe completo:
o en application.properties:
Salida (recortada):
============================
CONDITIONS EVALUATION REPORT
============================
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required classes 'javax.sql.DataSource',
'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType'
DataSourceAutoConfiguration#dataSource matched:
- @ConditionalOnMissingBean (types: javax.sql.DataSource;
SearchStrategy: all) did not find any beans
Negative matches:
-----------------
MongoAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'com.mongodb.client.MongoClient'
Exclusions:
-----------
None
Unconditional classes:
----------------------
...ConfigurationPropertiesAutoConfigurationCómo leerlo:
- Positive matches: qué se ha autoconfigurado y por qué.
- Negative matches: qué no, y qué condición falló. Aquí está la respuesta a la mayoría de los «¿por qué no funciona?».
Y para ver el resultado final, el propio contenedor:
@Bean
CommandLineRunner listarBeans(ApplicationContext contexto) {
return args -> Arrays.stream(contexto.getBeanDefinitionNames())
.sorted()
.filter(nombre -> nombre.startsWith("bibliotech") || nombre.contains("nexus"))
.forEach(System.out::println);
}Si necesitas desactivar una autoconfiguración concreta:
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class BiblioTechApplication { }
CommandLineRunner y ApplicationRunner
CommandLineRunner y ApplicationRunner¿Y dónde va el código que quieres ejecutar al arrancar? No en el main: cuando SpringApplication.run devuelve, el contexto ya está montado, pero el sitio idiomático es un runner.
package com.nexussoftware.bibliotech.presentacion;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class ArranqueBiblioTech implements CommandLineRunner {
private final GestorPrestamos gestor;
private final PropiedadesBiblioTech propiedades;
public ArranqueBiblioTech(GestorPrestamos gestor, PropiedadesBiblioTech propiedades) {
this.gestor = gestor;
this.propiedades = propiedades;
}
@Override
public void run(String... args) {
System.out.println("=== " + propiedades.nombre() + " ===");
System.out.println("Días de préstamo: " + propiedades.prestamo().diasPorDefecto());
gestor.prestar("978-0000000001", "Marta Ruiz");
gestor.prestar("978-0000000002", "Diego Alonso");
gestor.prestamosActivos()
.forEach(p -> System.out.printf(" %s -> %s (vence %s)%n",
p.isbn(), p.empleado(), p.fechaVencimiento()));
}
}Se ejecuta después de que el contexto esté completamente listo. Diferencia con ApplicationRunner:
| Interfaz | Firma | Cuándo |
|---|---|---|
CommandLineRunner |
run(String... args) |
Argumentos en crudo |
ApplicationRunner |
run(ApplicationArguments args) |
Argumentos ya parseados (--clave=valor, opciones) |
Si hay varios, se ordenan con @Order. Y una nota: si un runner lanza una excepción, la aplicación no arranca. Suele ser lo deseable.
- El jar ejecutable y
spring-boot-maven-plugin
spring-boot-maven-pluginEl plugin declarado en el pom.xml hace dos cosas.
Ejecutar en desarrollo sin empaquetar:
mvn spring-boot:run
mvn spring-boot:run -Dspring-boot.run.profiles=dev
mvn spring-boot:run -Dspring-boot.run.arguments=--bibliotech.multa.euros-por-dia=0.75Empaquetar un jar ejecutable:
mvn clean package
java -jar target/bibliotech-1.0.0-SNAPSHOT.jar
java -jar target/bibliotech-1.0.0-SNAPSHOT.jar --spring.profiles.active=prodEse jar es un fat jar con una estructura especial:
bibliotech-1.0.0-SNAPSHOT.jar
├── META-INF/MANIFEST.MF <- Main-Class: JarLauncher
├── org/springframework/boot/loader/ <- el cargador de Spring Boot
└── BOOT-INF/
├── classes/ <- TUS clases y recursos
└── lib/ <- TODAS las dependencias, como jarsEl truco es que Java no sabe ejecutar jars anidados, así que Spring Boot incluye su propio cargador de clases. El resultado es un artefacto único y autocontenido: se copia a cualquier máquina con Java 17 y funciona. Es la base del despliegue en contenedores de 12-06.
- AOP en Spring: aspectos y puntos de corte
Última pieza del núcleo, y la que más directamente conecta con lo que escribiste.
La programación orientada a aspectos (AOP) resuelve un problema concreto: hay preocupaciones que atraviesan toda la aplicación —transacciones, seguridad, trazas, métricas, reintentos, caché— y que, si se escriben a mano, ensucian todos los métodos con código que no es lógica de negocio.
Es exactamente lo que motivó tus tres proxies de 10-03. El vocabulario:
| Término | Qué es | Tu equivalente |
|---|---|---|
| Aspecto (aspect) | La clase que agrupa la funcionalidad transversal | Tu ProxyAuditoria |
| Punto de unión (join point) | Un punto donde se puede intervenir. En Spring, siempre una llamada a método | El invoke del InvocationHandler |
| Punto de corte (pointcut) | La expresión que selecciona qué puntos de unión | Tu if (metodo.isAnnotationPresent(...)) |
| Consejo (advice) | El código que se ejecuta | El cuerpo del invoke |
| Tejido (weaving) | Cómo se aplica. En Spring, proxies en ejecución | Proxy.newProxyInstance |
Un aspecto de BiblioTech, que sustituye tu ProxyCronometro de 10-03:
package com.nexussoftware.bibliotech.infraestructura;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class AspectoCronometro {
private static final Logger log = LoggerFactory.getLogger(AspectoCronometro.class);
// Punto de corte: TODOS los métodos públicos del paquete .servicio
@Around("execution(public * com.nexussoftware.bibliotech.servicio..*(..))")
public Object medir(ProceedingJoinPoint punto) throws Throwable {
long inicio = System.nanoTime();
try {
return punto.proceed(); // <- la llamada real al método
} finally {
long ms = (System.nanoTime() - inicio) / 1_000_000;
if (ms > 100) {
log.warn("Método lento: {} tardó {} ms",
punto.getSignature().toShortString(), ms);
}
}
}
}Compáralo con lo que escribiste:
// Tu ProxyCronometro de 10-03
(proxy, metodo, args) -> {
long inicio = System.nanoTime();
try {
return metodo.invoke(objetivo, args);
} finally {
long ms = (System.nanoTime() - inicio) / 1_000_000;
// ...
}
};Es el mismo código. Lo que aporta Spring es el @Around("execution(...)"): en vez de aplicar el proxy a mano objeto por objeto, declaras una expresión y el contenedor lo aplica a todo lo que encaje.
Los tipos de consejo:
| Anotación | Cuándo se ejecuta | Uso típico |
|---|---|---|
@Before |
Antes del método | Comprobaciones de seguridad, trazas |
@AfterReturning |
Tras terminar bien | Auditoría de éxito |
@AfterThrowing |
Tras lanzar excepción | Registro de errores, métricas |
@After |
Siempre (como finally) |
Liberación |
@Around |
Envuelve la llamada | El más potente: transacciones, caché, reintentos, cronometraje |
Y puntos de corte por anotación, que es lo que más se usa y lo que conecta con tu @Auditable:
@Around("@annotation(com.nexussoftware.bibliotech.anotaciones.Auditable)")
public Object auditar(ProceedingJoinPoint punto) throws Throwable {
// Se aplica SOLO a los métodos anotados con @Auditable.
// Tu anotación de 10-02, ahora consumida por Spring en vez de por tu proxy.
registro.anotarInicio(punto.getSignature().getName());
Object resultado = punto.proceed();
registro.anotarFin(punto.getSignature().getName());
return resultado;
}Tu @Auditable sigue viva. Lo que ha cambiado es quién la lee.
@Transactional: el aspecto canónico
@Transactional: el aspecto canónicoEl aspecto más usado del mundo Java no lo escribes tú: viene con Spring.
@Service
public class GestorPrestamos {
@Transactional
public Prestamo prestar(String isbn, String empleado) {
Material material = repositorioMateriales.buscarPorIsbn(isbn)
.orElseThrow(() -> new MaterialNoEncontradoException(isbn));
material.decrementarDisponibles(); // UPDATE 1
Prestamo prestamo = new Prestamo(material, empleado, LocalDate.now(reloj));
repositorioPrestamos.guardar(prestamo); // INSERT
registroAuditoria.anotar(prestamo); // INSERT
return prestamo;
}
}Sin @Transactional, si el tercer INSERT falla, los dos primeros ya están confirmados: hay un ejemplar menos disponible y un préstamo sin auditar. Datos incoherentes.
Con @Transactional, las tres operaciones son atómicas: o las tres o ninguna.
Lo que hace el proxy, exactamente:
// Pseudocódigo del aspecto transaccional de Spring
public Object invoke(Method metodo, Object[] args) throws Throwable {
Transaccion tx = gestorTransacciones.iniciar(); // BEGIN
try {
Object resultado = metodo.invoke(objetivo, args);
gestorTransacciones.confirmar(tx); // COMMIT
return resultado;
} catch (RuntimeException | Error e) {
gestorTransacciones.revertir(tx); // ROLLBACK
throw e;
} catch (Exception excepcionComprobada) {
gestorTransacciones.confirmar(tx); // ¡CONFIRMA! (por defecto)
throw excepcionComprobada;
}
}Fíjate en el último catch, porque es el comportamiento por defecto que más sorprende:
Por defecto, Spring revierte ante excepciones no comprobadas (
RuntimeExceptionyError) y confirma ante excepciones comprobadas.
Si tu BiblioTechException del módulo 6 es comprobada (extiende Exception), lanzarla no revierte la transacción. Se corrige de dos formas: hacerla no comprobada (recomendable y coherente con lo que se decidió en 06-04), o declararlo:
@Transactional(rollbackFor = BiblioTechException.class)
public Prestamo prestar(String isbn, String empleado) { ... }Los atributos que se usan de verdad:
| Atributo | Para qué | Valor habitual |
|---|---|---|
readOnly |
Optimiza consultas; Hibernate desactiva la comprobación de cambios | true en métodos de solo lectura |
rollbackFor |
Fuerza rollback ante excepciones comprobadas | Tu excepción base |
propagation |
Qué hacer si ya hay una transacción | REQUIRED (por defecto) |
isolation |
Nivel de aislamiento de la base de datos | El del gestor (por defecto) |
timeout |
Segundos máximos | Según el caso |
La propagación y el aislamiento se desarrollan en 11-03, donde tienen sentido con una base de datos real delante. Aquí basta con saber que @Transactional es un aspecto y que el aspecto es un proxy.
- Cómo funciona por debajo: los proxies que ya escribiste
Recupera el diagrama del ciclo de vida (apartado 10) y fíjate en el penúltimo paso: "BeanPostProcessor después de la inicialización: AQUÍ SE CREAN LOS PROXIES AOP".
Lo que ocurre exactamente:
- Spring instancia tu
GestorPrestamosy le inyecta sus dependencias. - Un
BeanPostProcessormira si alguno de sus métodos encaja en algún punto de corte (por ejemplo, tiene@Transactional). - Si encaja, crea un proxy que envuelve tu objeto y registra el proxy en el contenedor, no tu objeto.
- Todo el que inyecte un
GestorPrestamosrecibe el proxy.
graph LR
A["Llamador<br/>(otro bean)"] -->|prestar| B["Proxy<br/>(generado por Spring)"]
B -->|1. BEGIN| D[("Base de datos")]
B -->|2. proceed| C["GestorPrestamos<br/>(TU objeto real)"]
C -->|3. SQL| D
B -->|4. COMMIT o ROLLBACK| D
Dos formas de crear ese proxy, y esto tiene consecuencias prácticas:
| Mecanismo | Cuándo lo usa Spring | Limitación |
|---|---|---|
| Proxy dinámico del JDK | La clase implementa alguna interfaz | Solo intercepta métodos de la interfaz |
| CGLIB (subclase generada) | La clase no implementa interfaces | La clase y los métodos no pueden ser final |
Spring Boot usa CGLIB por defecto (spring.aop.proxy-target-class=true), lo que evita la primera limitación pero introduce la segunda. De ahí un fallo silencioso y muy real:
@Service
public class GestorPrestamos {
@Transactional
public final Prestamo prestar(...) { // <- final: CGLIB no puede sobrescribirlo
// La transacción NO se aplica. Y no hay ningún error.
}
}El comportamiento es idéntico al del Proxy.newProxyInstance que escribiste, con la diferencia de que aquel solo funcionaba sobre interfaces.
Una consecuencia curiosa que verás en las trazas de pila: aparecerán clases como GestorPrestamos$$SpringCGLIB$$0. No te asustes: es tu clase, envuelta.
- La trampa de la llamada interna
Este apartado explica probablemente el problema número uno del mundo Spring. Y ya lo has visto: es exactamente el agujero de tu proxy de 10-03.
@Service
public class GestorPrestamos {
public void prestarVarios(List<String> isbns, String empleado) {
for (String isbn : isbns) {
prestar(isbn, empleado); // <- LLAMADA INTERNA: this.prestar(...)
}
}
@Transactional
public Prestamo prestar(String isbn, String empleado) {
// Llamado desde prestarVarios: NO HAY TRANSACCIÓN.
}
}Por qué falla, paso a paso:
- El llamador externo invoca
prestarVariosen el proxy. - El proxy mira:
prestarVariosno tiene@Transactional. No hace nada especial. - El proxy invoca
prestarVariosen tu objeto real. - Dentro,
prestar(isbn, empleado)es en realidadthis.prestar(...), ythises tu objeto real, no el proxy. - La llamada no pasa por el proxy. No hay transacción. Y no hay ningún error.
graph TD
A["Llamador externo"] -->|prestarVarios| P["PROXY"]
P -->|sin transacción, correcto| G["GestorPrestamos real"]
G -->|"this.prestar(...)<br/>NO PASA POR EL PROXY"| G
G -.->|"@Transactional IGNORADA"| X["Sin transacción"]
Escribiste este mismo error, con este mismo diagnóstico, cuando probaste tu ProxyAuditoria: los métodos internos no se auditaban. Es el mismo mecanismo, y es inherente a los proxies. No es un fallo de Spring: es una consecuencia de que un proxy solo puede interceptar lo que pasa por él.
Soluciones, de mejor a peor:
1. Poner la anotación en el método externo (lo correcto casi siempre):
@Transactional // toda la operación en una transacción
public void prestarVarios(List<String> isbns, String empleado) {
for (String isbn : isbns) { prestar(isbn, empleado); }
}2. Separar en dos beans (correcto cuando la semántica transaccional debe ser distinta):
@Service
public class GestorPrestamosMasivo {
private final GestorPrestamos gestor; // OTRO bean: la llamada SÍ pasa por su proxy
public GestorPrestamosMasivo(GestorPrestamos gestor) { this.gestor = gestor; }
public void prestarVarios(List<String> isbns, String empleado) {
isbns.forEach(isbn -> gestor.prestar(isbn, empleado)); // una transacción por préstamo
}
}3. Autoinyección (funciona, pero es un síntoma de diseño mejorable):
@Service
public class GestorPrestamos {
@Autowired @Lazy
private GestorPrestamos self; // el PROXY de sí mismo
public void prestarVarios(List<String> isbns, String empleado) {
isbns.forEach(isbn -> self.prestar(isbn, empleado));
}
}La misma trampa afecta a @Cacheable, @Async, @Retryable, @PreAuthorize y a cualquier aspecto propio. Ahora que sabes por qué, la reconocerás en todos ellos.
- BiblioTech sobre Spring: el resultado
La estructura del proyecto después de la migración:
bibliotech/
├── pom.xml
└── src/main/
├── java/com/nexussoftware/bibliotech/
│ ├── BiblioTechApplication.java <- @SpringBootApplication
│ ├── config/
│ │ ├── PropiedadesBiblioTech.java <- @ConfigurationProperties + @Validated
│ │ └── ConfiguracionBiblioTech.java <- @Bean Clock, @Bean HttpClient
│ ├── dominio/ <- SIN anotaciones de Spring
│ │ ├── Material.java (sealed), Libro, Revista, Dvd
│ │ ├── Empleado.java, Prestamo.java, Reserva.java
│ │ └── Gravedad, TipoMaterial, EstadoPrestamo, Ficha, ResumenSesion
│ ├── servicio/
│ │ ├── GestorPrestamos.java <- @Service, @Transactional
│ │ ├── CalculadoraMultas.java <- @Service, Clock inyectado
│ │ ├── ServicioAvisos.java <- interfaz
│ │ ├── AvisosDeConsola.java <- @Service @Profile("dev")
│ │ ├── AvisosPorCorreo.java <- @Service @Profile("prod")
│ │ └── EstadisticasBiblioTech.java <- @Service
│ ├── persistencia/ <- @Repository (JPA en 11-03)
│ ├── red/ <- ClienteMetadatos, ServidorCatalogo
│ ├── infraestructura/
│ │ └── AspectoCronometro.java <- @Aspect @Component
│ └── presentacion/
│ └── ArranqueBiblioTech.java <- CommandLineRunner
└── resources/
├── application.yml
├── application-dev.yml
└── application-prod.ymlFíjate en un detalle deliberado: el paquete dominio no tiene ni una anotación de Spring. Material, Prestamo y Empleado son Java puro. Se pueden instanciar con new, probar sin contexto y reutilizar en otro framework. Esa separación es un principio de diseño que se profundiza en 12-02, y es una de las decisiones que más agradecerás dentro de dos años.
Y lo que ha desaparecido:
| Se elimina | Sustituido por |
|---|---|
ContenedorSimple (150 líneas) |
ApplicationContext |
@Componente e @Inyectar propias |
@Service, @Repository, @Component |
ProxyAuditoria, ProxyCronometro (aplicados a mano) |
@Aspect con puntos de corte declarativos |
Configuracion / ReglasNegocio estáticas |
@ConfigurationProperties validado |
ApagadoOrdenado con shutdown hooks |
Ciclo de vida del contenedor y @PreDestroy |
Un main de 60 líneas montando objetos a mano |
SpringApplication.run y un CommandLineRunner |
Para ejecutarlo:
- Qué NO se ve en esta lección
Para que el mapa quede claro:
| Tema | Dónde |
|---|---|
Controladores REST, @RestController, @GetMapping |
12-04 |
| Persistencia con JPA y Spring Data | 11-03 (introducción completa) |
@SpringBootTest, MockMvc, pruebas de integración |
11-04, 11-06 y 12-05 |
Actuator: sondas de salud, métricas, /actuator |
12-07 |
| Spring Security | 12-07 |
| Los patrones de diseño detrás (Inyección de Dependencias, Fábrica, Proxy, Singleton) | 12-02 |
| Empaquetar en contenedor y desplegar | 12-06 |
Spring Actuator merece una mención aquí porque se activa con un solo starter: expone /actuator/health, /actuator/metrics y /actuator/env sin escribir código. Es la respuesta directa a "cómo sé si mi aplicación está viva en producción". Se desarrolla en 12-07.
- Errores Comunes y Consejos
Error: inyección por campo con @Autowired. Es la forma más corta y la peor. Impide final, impide instanciar la clase en una prueba, oculta el exceso de dependencias y acopla la clase a Spring. Usa siempre el constructor.
Error: la clase principal fuera del paquete raíz. @ComponentScan solo mira el paquete de la clase anotada y sus subpaquetes. Si BiblioTechApplication está en com.nexussoftware.bibliotech.presentacion, no verá com.nexussoftware.bibliotech.servicio y el arranque fallará con "no se encuentra el bean".
Error: estado mutable en un bean singleton. El singleton lo comparten todos los hilos. Un private int contador es una condición de carrera de manual (08-04). Sin estado, o Atomic* y colecciones concurrentes (08-06).
Error: llamada interna a un método @Transactional. No pasa por el proxy y la anotación se ignora sin ningún error. Apartado 26.
Error: método o clase final con anotaciones AOP. CGLIB no puede sobrescribirlo y el aspecto no se aplica, también en silencio.
Error: esperar rollback ante una excepción comprobada. Por defecto Spring confirma. O haces la excepción no comprobada, o usas rollbackFor.
Error: trabajo pesado en el constructor. Conectar a la base de datos, leer ficheros o llamar a la red en el constructor rompe el orden del contenedor y hace la clase imposible de instanciar en una prueba. Usa @PostConstruct.
Error: poner <version> a dependencias que gestiona el starter parent. Le quitas su trabajo y provocas conflictos de versiones difíciles de diagnosticar.
Error: javax.* en vez de jakarta.*. Spring Boot 3 migró todos los paquetes. Un import javax.persistence.Entity copiado de un tutorial de 2020 no compila.
Consejo: mantén el dominio libre de anotaciones del framework. Material y Prestamo deben poder existir sin Spring. Anota los servicios y los repositorios, no las entidades de negocio.
Consejo: prefiere @ConfigurationProperties a @Value. Agrupa, valida, autocompleta y falla en el arranque en vez de en ejecución.
Consejo: usa --debug cuando algo no se configure como esperas. El informe de condiciones responde a casi todos los "por qué".
Consejo: @Profile("dev") en vez de if (esDesarrollo). La lógica condicional por entorno dentro del código de negocio envejece muy mal.
Consejo: cuando dudes de qué está pasando, imprime los beans. Un CommandLineRunner que recorra getBeanDefinitionNames() es una herramienta de diagnóstico excelente.
- Ejercicios
Ejercicio 1: migrar un servicio al contenedor
Partes de esta clase de BiblioTech, escrita al estilo del módulo 7:
public class ProcesadorReservas {
private final RepositorioReservas repositorio = new RepositorioReservasCsv(
Path.of("datos/reservas.csv"));
private final GestorPrestamos gestor = new GestorPrestamos();
private final int diasCaducidadReserva =
Integer.parseInt(ReglasNegocio.get("reserva.dias.caducidad", "3"));
public void procesarPendientes() {
for (Reserva reserva : repositorio.pendientes()) {
if (reserva.fecha().plusDays(diasCaducidadReserva).isBefore(LocalDate.now())) {
repositorio.caducar(reserva);
} else if (gestor.hayDisponible(reserva.isbn())) {
gestor.prestar(reserva.isbn(), reserva.empleado());
repositorio.completar(reserva);
}
}
}
}Conviértela a Spring. Debe cumplir:
- Inyección por constructor con campos
final. - El estereotipo correcto.
- El número de días desde configuración externa y validado (
@Min(1) @Max(30)). LocalDate.now()sustituido por unClockinyectado (10-05), para poder probarlo.procesarPendientesdebe ser atómico.- Añade el fragmento de
application.ymlcorrespondiente.
Ejercicio 2: dos implementaciones y un entorno
BiblioTech necesita enviar avisos de vencimiento. Hay dos implementaciones de ServicioAvisos: AvisosPorCorreo (SMTP real) y AvisosDeConsola (imprime por pantalla).
Requisitos:
- En el perfil
devdebe usarse la de consola; enprod, la de correo. Ninguna clase debe consultar en qué entorno está. GestorPrestamosrecibe unServicioAvisossin saber cuál.- Añade una tercera implementación,
AvisosPorSms, y unNotificadorMultiCanalque use todas las implementaciones activas, sin que haya que modificarlo al añadir una cuarta. - Explica qué ocurre si en el perfil
prodhay dos implementaciones activas yGestorPrestamospide una sola, y da dos soluciones distintas.
Ejercicio 3: diagnosticar tres fallos de proxy
Estos tres fragmentos compilan, arrancan sin errores y no hacen lo que parece. Para cada uno: explica qué ocurre, por qué, y corrígelo.
// CASO A
@Service
public class ServicioCatalogo {
public void importarLote(List<Material> materiales) {
materiales.forEach(this::importarUno);
}
@Transactional
public void importarUno(Material material) {
repositorio.guardar(material);
indice.actualizar(material);
}
}// CASO B
@Service
public class GestorMultas {
@Transactional
public final void condonar(Long prestamoId) {
repositorio.borrarMulta(prestamoId);
auditoria.registrar("Multa condonada: " + prestamoId);
}
}// CASO C
@Service
public class ImportadorCatalogo {
@Transactional
public void importar(Path fichero) throws MaterialCorruptoException {
for (String linea : Files.readAllLines(fichero)) {
repositorio.guardar(parsear(linea)); // lanza MaterialCorruptoException
}
}
}
// donde: public class MaterialCorruptoException extends Exception { ... }Soluciones
Solución 1
package com.nexussoftware.bibliotech.servicio;
import java.time.Clock;
import java.time.LocalDate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service // (2) capa de negocio: @Service, no @Component ni @Repository
public class ProcesadorReservas {
// (1) todo final, inyectado por constructor
private final RepositorioReservas repositorio;
private final GestorPrestamos gestor;
private final PropiedadesBiblioTech propiedades;
private final Clock reloj; // (4) reloj inyectado
public ProcesadorReservas(RepositorioReservas repositorio,
GestorPrestamos gestor,
PropiedadesBiblioTech propiedades,
Clock reloj) {
this.repositorio = repositorio;
this.gestor = gestor;
this.propiedades = propiedades;
this.reloj = reloj;
}
// (5) atómico: o se procesan todas las reservas o ninguna
@Transactional
public void procesarPendientes() {
LocalDate hoy = LocalDate.now(reloj); // (4) determinista en pruebas
int dias = propiedades.reserva().diasCaducidad(); // (3) configurado y validado
for (Reserva reserva : repositorio.pendientes()) {
if (reserva.fecha().plusDays(dias).isBefore(hoy)) {
repositorio.caducar(reserva);
} else if (gestor.hayDisponible(reserva.isbn())) {
gestor.prestar(reserva.isbn(), reserva.empleado());
repositorio.completar(reserva);
}
}
}
}Ampliación de las propiedades (punto 3):
@Validated
@ConfigurationProperties(prefix = "bibliotech")
public record PropiedadesBiblioTech(
@NotBlank String nombre,
@NotNull Prestamo prestamo,
@NotNull Multa multa,
@NotNull Reserva reserva) {
public record Reserva(@Min(1) @Max(30) int diasCaducidad) { }
// ... Prestamo y Multa como antes
}# (6) application.yml
bibliotech:
nombre: BiblioTech - Nexus Software
prestamo:
dias-por-defecto: 15
maximo-por-empleado: 3
multa:
euros-por-dia: 0.50
maxima: 20.00
reserva:
dias-caducidad: 3Qué se ha ganado, punto por punto:
| Antes | Después | Beneficio |
|---|---|---|
new RepositorioReservasCsv(...) |
Inyectado | Sustituible por JPA en 11-03 sin tocar esta clase |
new GestorPrestamos() |
Inyectado | Sustituible por un simulado en 11-06 |
ReglasNegocio.get(...) estático |
PropiedadesBiblioTech |
Validado en arranque, distinto por entorno |
LocalDate.now() |
LocalDate.now(reloj) |
Probable: Clock.fixed en la prueba |
| Sin transacción | @Transactional |
Sin estados a medias si algo falla |
Nota: procesarPendientes es un método público del propio bean llamado desde fuera, así que sí pasa por el proxy y la transacción se aplica. Si lo llamara otro método de esta misma clase, no (apartado 26).
Solución 2
Puntos 1 y 2: perfiles.
public interface ServicioAvisos {
void avisar(Empleado destinatario, String mensaje);
}
@Service
@Profile("dev")
public class AvisosDeConsola implements ServicioAvisos {
private static final Logger log = LoggerFactory.getLogger(AvisosDeConsola.class);
@Override public void avisar(Empleado destinatario, String mensaje) {
log.info("[AVISO SIMULADO] {} -> {}", destinatario.correo(), mensaje);
}
}
@Service
@Profile("prod")
public class AvisosPorCorreo implements ServicioAvisos {
@Override public void avisar(Empleado destinatario, String mensaje) { /* SMTP */ }
}GestorPrestamos no cambia en absoluto:
@Service
public class GestorPrestamos {
private final ServicioAvisos avisos; // no sabe cuál recibe
public GestorPrestamos(ServicioAvisos avisos, ...) { this.avisos = avisos; }
}Ninguna clase pregunta por el entorno: la decisión está en las anotaciones y en spring.profiles.active.
Punto 3: todas las implementaciones.
@Service
@Profile("prod")
public class AvisosPorSms implements ServicioAvisos { ... }
@Service
public class NotificadorMultiCanal {
private static final Logger log = LoggerFactory.getLogger(NotificadorMultiCanal.class);
private final List<ServicioAvisos> canales;
public NotificadorMultiCanal(List<ServicioAvisos> canales) {
this.canales = canales; // Spring inyecta TODOS los beans ACTIVOS del tipo
log.info("Notificador con {} canales activos", canales.size());
}
public void avisarPorTodos(Empleado destinatario, String mensaje) {
canales.forEach(canal -> {
try {
canal.avisar(destinatario, mensaje);
} catch (Exception e) {
// Un canal caído no debe impedir los demás (estrategia por capas, 06-07)
log.warn("Falló el canal {}", canal.getClass().getSimpleName(), e);
}
});
}
}Añadir un cuarto canal es crear una clase con @Service. NotificadorMultiCanal no se toca: es el principio abierto/cerrado, y Spring lo hace gratis.
Punto 4: la ambigüedad. En prod hay dos beans de tipo ServicioAvisos (AvisosPorCorreo y AvisosPorSms). Cuando GestorPrestamos pide uno, el arranque falla:
Parameter 0 of constructor in ...GestorPrestamos required a single bean,
but 2 were found: avisosPorCorreo, avisosPorSmsDos soluciones:
// Solución A: marcar el principal
@Service @Profile("prod") @Primary
public class AvisosPorCorreo implements ServicioAvisos { ... }// Solución B: elegir explícitamente con un qualifier propio (10-02)
@Qualifier("correo")
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.PARAMETER})
public @interface PorCorreo { }
@Service @Profile("prod") @PorCorreo
public class AvisosPorCorreo implements ServicioAvisos { ... }
@Service
public class GestorPrestamos {
public GestorPrestamos(@PorCorreo ServicioAvisos avisos, ...) { ... }
}La A es más cómoda; la B es explícita y la verifica el compilador. Y NotificadorMultiCanal, que pide una List<ServicioAvisos>, no se ve afectado por ninguna ambigüedad: quiere todas.
Solución 3
CASO A — llamada interna.
- Qué ocurre:
importarUnono se ejecuta en transacción. Cadaguardarse confirma por su cuenta; si el material número 40 falla, quedan 39 importados a medias, con el índice posiblemente descuadrado. - Por qué:
materiales.forEach(this::importarUno)es una referencia a método sobrethis, que es el objeto real, no el proxy. La llamada no atraviesa el proxy y@Transactionalse ignora. - Corrección (depende de la semántica deseada):
// Opción 1: todo el lote en UNA transacción (lo habitual en una importación)
@Service
public class ServicioCatalogo {
@Transactional
public void importarLote(List<Material> materiales) {
materiales.forEach(this::importarUno); // ahora ya hay transacción abierta
}
// sin @Transactional: hereda la del método externo
void importarUno(Material material) {
repositorio.guardar(material);
indice.actualizar(material);
}
}// Opción 2: una transacción POR material (si un fallo no debe tumbar el lote)
@Service
public class ImportadorLote {
private final ServicioCatalogo catalogo; // otro bean -> pasa por SU proxy
public ImportadorLote(ServicioCatalogo catalogo) { this.catalogo = catalogo; }
public void importarLote(List<Material> materiales) {
materiales.forEach(catalogo::importarUno);
}
}CASO B — método final.
- Qué ocurre: no hay transacción. Si
auditoria.registrarfalla, la multa ya está borrada y no hay registro. - Por qué: Spring Boot usa CGLIB, que genera una subclase. Un método
finalno se puede sobrescribir, así que el proxy no lo intercepta. No se emite ningún error; el fallo es completamente silencioso. - Corrección: quitar
final.
@Service
public class GestorMultas {
@Transactional
public void condonar(Long prestamoId) { // sin final
repositorio.borrarMulta(prestamoId);
auditoria.registrar("Multa condonada: " + prestamoId);
}
}Regla general: una clase o un método destinados a llevar aspectos no pueden ser final. Lo mismo vale para @Cacheable, @Async y @PreAuthorize.
CASO C — excepción comprobada.
- Qué ocurre: si la línea 300 está corrupta,
MaterialCorruptoExceptionsube... y Spring confirma la transacción. Quedan 299 materiales importados y el fichero se da por fallido. El siguiente reintento duplica esos 299. - Por qué: por defecto solo se revierte ante
RuntimeExceptionyError.MaterialCorruptoExceptionextiendeException: es comprobada, y ante ella Spring confirma. - Corrección, dos opciones:
// Opción 1 (recomendada): declararlo explícitamente
@Transactional(rollbackFor = MaterialCorruptoException.class)
public void importar(Path fichero) throws MaterialCorruptoException { ... }// Opción 2 (mejor a largo plazo): que la jerarquía del módulo 6 sea NO comprobada
public class BiblioTechException extends RuntimeException { ... }
public class MaterialCorruptoException extends BiblioTechException { ... }
@Transactional // ahora revierte por defecto
public void importar(Path fichero) { ... }La opción 2 es coherente con lo que se decidió en 06-04: las excepciones de las que el llamador no puede recuperarse de forma razonable deben ser no comprobadas, y así además no contaminan todas las firmas del código con throws.
Los tres casos comparten una raíz: AOP funciona por proxies, y un proxy solo intercepta lo que pasa a través de él. Con eso claro, los tres se diagnostican en segundos.
Conclusión
El ContenedorSimple ha desaparecido, y con él la mitad de la deuda técnica de BiblioTech.
Sabes distinguir Spring Framework de Spring Boot: el primero es la funcionalidad —contenedor, AOP, transacciones, web—; el segundo es la capa que resuelve las versiones con starters, la configuración repetitiva con autoconfiguración y el despliegue con jar ejecutable y servidor embebido. Y sabes que todo lo que aprendes del núcleo sirve tal cual dentro de Boot.
Dominas el contenedor de IoC: qué es un ApplicationContext, qué es un bean —simplemente un objeto que gestiona Spring— y las dos formas de declararlos, @Component para tus clases y @Bean para las que no puedes anotar, como ese Clock de 10-05 y ese HttpClient de 09-06 que por fin se crea una sola vez. Conoces las tres formas de inyección y por qué solo una vale: la del constructor, porque permite final, deja el objeto siempre válido, hace visible el exceso de dependencias, detecta ciclos en el arranque y —lo decisivo— produce clases que se instancian con new en una prueba. La inyección por campo es corta de escribir y cara de mantener.
Sabes qué añade cada estereotipo —y que @Repository sí aporta algo real, la traducción de excepciones a DataAccessException—, cuándo usar @Configuration con @Bean y por qué una clase @Configuration no puede ser final: porque Spring genera una subclase CGLIB para que llamar dos veces a reloj() devuelva el mismo reloj.
Conoces el ciclo de vida de un bean completo, con sus dos puntos que importan: que la inyección por constructor ocurre en la instanciación y la de campo después, y que los proxies AOP se crean en el último BeanPostProcessor — el dato que explica todo el apartado de AOP. Usas @PostConstruct para el trabajo pesado que no debe ir en el constructor y @PreDestroy para el apagado ordenado que en el módulo 8 hacías con shutdown hooks. Y conoces los ámbitos, con la consecuencia que más incidentes de producción provoca: un singleton lo comparten todos los hilos, así que o no tiene estado o usa lo que aprendiste en 08-04 y 08-06.
Sabes resolver ambigüedades con @Primary, @Qualifier y qualifiers propios que el compilador verifica; y conoces el patrón de inyectar una List<T> con todas las implementaciones, que convierte "añadir un canal de aviso" en "crear una clase".
Has externalizado la configuración de verdad. @Value con conversión de tipos y valores por defecto, la jerarquía de fuentes que permite desplegar la misma imagen en tres entornos cambiando variables de entorno, y sobre todo @ConfigurationProperties con validación, que convierte aquel ReglasNegocio estático, sin validar y sin entornos de 07-07 en un record inmutable, tipado, inyectable y que impide arrancar si la configuración es inválida — con un mensaje que dice qué propiedad, qué valor y por qué. Y tienes perfiles: dev que imprime los avisos por consola y prod que los envía, sin un solo if (esDesarrollo) en el código.
Del lado de Boot, tienes el pom.xml con spring-boot-starter-parent que fija versiones compatibles —y por eso no pones <version>—, sabes qué es un starter y qué arrastra cada uno, y has diseccionado @SpringBootApplication en sus tres anotaciones, con la regla práctica que se deriva: la clase principal va en el paquete raíz. Y sobre todo, entiendes la autoconfiguración como lo que es: un catálogo de candidatas en un fichero .imports, un conjunto de condiciones —@ConditionalOnClass, @ConditionalOnProperty y, la clave, @ConditionalOnMissingBean— y un orden de evaluación que garantiza la regla de oro: si tú defines un bean, el suyo no se crea. Con --debug puedes leer el informe de condiciones y ver, para cada pieza, si se aplicó y por qué.
Y has cerrado el círculo con AOP. Sabes qué es un aspecto, un punto de corte y un consejo, y has visto que tu ProxyCronometro de 10-03 y un @Around("execution(...)") de Spring son el mismo código, con la diferencia de que Spring lo aplica por expresión en vez de objeto a objeto. Conoces @Transactional como el aspecto canónico, con su comportamiento por defecto que sorprende a todo el mundo —revierte ante excepciones no comprobadas y confirma ante las comprobadas—, y sabes que por debajo hay un proxy JDK sobre interfaces o una subclase CGLIB, con sus dos consecuencias silenciosas: lo final no se puede proxiar y la llamada interna no pasa por el proxy. Esa segunda trampa, que provoca más incidentes en Spring que ninguna otra, la reconociste al instante porque tenía exactamente el mismo agujero el proxy que escribiste tú.
BiblioTech es ahora una aplicación Spring Boot: arranca con mvn spring-boot:run, tiene su dominio limpio de anotaciones del framework, sus servicios inyectados por constructor, su configuración tipada y validada, sus perfiles dev y prod, sus aspectos declarativos y su ciclo de vida gestionado.
Y su persistencia sigue siendo ficheros CSV.
Ese @Transactional que acabas de poner sobre prestar no está haciendo nada, porque no hay ninguna base de datos debajo que pueda confirmar o revertir. RepositorioPrestamosJpa es todavía un nombre sin contenido. El spring.datasource.url=jdbc:h2:mem:bibliotech del perfil dev apunta a una base de datos que no existe. Y ese DataSourceAutoConfiguration que aparecía en el informe de condiciones está esperando a que alguien añada una dependencia.
En la próxima lección se añade. Verás qué es el desajuste objeto-relacional y qué automatiza exactamente un ORM; verás JDBC en veinte líneas como línea base y comprenderás cuánto trabajo desaparece; mapearás Material, Empleado y Prestamo a tablas con @Entity, @Id y @Column —anotaciones leídas por reflexión, como el @CampoCsv que escribiste—; modelarás las relaciones entre ellas y te encontrarás con la LazyInitializationException y con el problema N+1, los dos clásicos que todo el mundo sufre antes de entenderlos; descubrirás la comprobación de cambios automática, que guarda tus modificaciones sin que llames a save; y @Transactional cobrará por fin sentido, con su propagación, su aislamiento y su bloqueo optimista para el caso que ya conoces del módulo 8: dos bibliotecarios prestando el mismo ejemplar a la vez.
Los CSV de BiblioTech tienen los días contados.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
