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

  1. Qué es Spring y qué es Spring Boot
  2. Los módulos de Spring Framework
  3. El contenedor de IoC: ApplicationContext
  4. Qué es un bean y cómo se declara
  5. Las tres formas de inyección
  6. Por qué la inyección por constructor
  7. Comparativa con el ContenedorSimple casero
  8. Estereotipos: @Component, @Service, @Repository, @Controller
  9. @Configuration + @Bean frente al escaneo de componentes
  10. El ciclo de vida de un bean
  11. Ámbitos de un bean
  12. Resolución de ambigüedad: @Qualifier, @Primary y colecciones
  13. @Value y las propiedades externalizadas
  14. @ConfigurationProperties: configuración tipada y validada
  15. Perfiles: @Profile y spring.profiles.active
  16. BiblioTech: de Configuracion a la configuración de Spring
  17. Spring Boot: el pom.xml y los starters
  18. @SpringBootApplication diseccionada
  19. La autoconfiguración, explicada de verdad
  20. Depurar la autoconfiguración
  21. CommandLineRunner y ApplicationRunner
  22. El jar ejecutable y spring-boot-maven-plugin
  23. AOP en Spring: aspectos y puntos de corte
  24. @Transactional: el aspecto canónico
  25. Cómo funciona por debajo: los proxies que ya escribiste
  26. La trampa de la llamada interna
  27. BiblioTech sobre Spring: el resultado
  28. Qué NO se ve en esta lección
  29. Errores Comunes y Consejos
  30. Ejercicios

  1. 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.

  1. 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.

  1. El contenedor de IoC: ApplicationContext

El corazón de Spring es el contenedor. Su trabajo es exactamente el de tu ContenedorSimple:

  1. Averiguar qué objetos hay que crear.
  2. Averiguar qué necesita cada uno.
  3. Crearlos en el orden correcto, inyectando las dependencias.
  4. Guardarlos y entregarlos cuando se piden.
  5. 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.

  1. 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).

  1. 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:

  1. 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, es new GestorPrestamos(mock1, mock2, mock3).
  2. No se puede usar final. Pierdes la inmutabilidad y la garantía de que la dependencia no cambia.
  3. 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.
  4. Permite estados inconsistentes. Con inyección por campo, el objeto existe un instante construido pero sin dependencias. Si algo se ejecuta ahí, hay NullPointerException.
  5. Acopla la clase a Spring. @Autowired en 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.

  1. 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.

  1. Comparativa con el ContenedorSimple casero

Vale 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.

  1. Estereotipos: @Component, @Service, @Repository, @Controller

Spring 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.

  1. @Configuration + @Bean frente al escaneo de componentes

Hay dos estilos de decirle a Spring qué beans existen, y en un proyecto real conviven.

Escaneo de componentes

@ComponentScan("com.nexussoftware.bibliotech")
public class Configuracion { }

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 @Service y 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:

  1. El nombre del método es el nombre del bean (reloj, clienteHttp, clienteMetadatos).
  2. Los parámetros de un método @Bean se inyectan igual que los de un constructor.
  3. Aquí se resuelve por fin un problema real de 09-06: el HttpClient es 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

  1. 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 @Transactional deja 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.

  1. Á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
        // ...
    }
}

  1. Resolución de ambigüedad: @Qualifier, @Primary y colecciones

Spring 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
	- avisosPorSms

Tu ContenedorSimple fallaba igual, pero sin decirte los candidatos. Hay cuatro soluciones, y cada una tiene su caso.

@Primary: el candidato por defecto

@Service
@Primary
public class AvisosPorCorreo implements ServicioAvisos { ... }

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)

  1. @Value y las propiedades externalizadas

Ninguna 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=true

O 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: true

Se 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 como BigDecimal. Spring convierte a int, 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.properties dentro del repositorio. Van en variables de entorno o en un gestor de secretos. Se trata en 12-07.

  1. @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 1

Fí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.

  1. Perfiles: @Profile y spring.profiles.active

Un 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=dev

Y 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.

  1. BiblioTech: de Configuracion a la configuración de Spring

El 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:

  1. static: no se puede sustituir en una prueba. Es exactamente el "código que no se puede probar" que 11-04 va a analizar.
  2. Sin validación: prestamo.dias=abc lanza NumberFormatException el día que alguien pida un préstamo, no al arrancar.
  3. Sin entornos: un solo fichero para desarrollo y producción.
  4. Conversión manual en cada acceso: Integer.parseInt y new BigDecimal cada vez que se llama.
  5. Un bloque static que puede fallar: si el fichero no está, el error aparece en un ExceptionInInitializerError incomprensible.

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

  1. Spring Boot: el pom.xml y los starters

Ahora 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:

mvn dependency:tree

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.

  1. @SpringBootApplication diseccionada

La 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.BiblioTechApplication escanea .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:

  1. Deduce el tipo de aplicación (consola, web servlet, web reactiva) según lo que hay en el classpath.
  2. Crea el ApplicationContext adecuado.
  3. Carga las fuentes de propiedades (application.properties, perfiles, entorno, argumentos).
  4. Registra las definiciones de bean: escaneo + @Bean + autoconfiguración.
  5. Instancia todos los singletons, inyectando dependencias.
  6. Ejecuta @PostConstruct y crea los proxies AOP.
  7. Arranca el servidor embebido si lo hay.
  8. Ejecuta los CommandLineRunner y ApplicationRunner.
  9. Registra un gancho de apagado para cerrar el contexto de forma ordenada.

  1. 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:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

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.

  1. 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:

java -jar target/bibliotech-1.0.0-SNAPSHOT.jar --debug

o en application.properties:

debug=true

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:
----------------------
   ...ConfigurationPropertiesAutoConfiguration

Có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 { }

  1. 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.

  1. El jar ejecutable y spring-boot-maven-plugin

El 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.75

Empaquetar 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=prod

Ese 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 jars

El 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.

  1. 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.

  1. @Transactional: el aspecto canónico

El 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 (RuntimeException y Error) 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.

  1. 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:

  1. Spring instancia tu GestorPrestamos y le inyecta sus dependencias.
  2. Un BeanPostProcessor mira si alguno de sus métodos encaja en algún punto de corte (por ejemplo, tiene @Transactional).
  3. Si encaja, crea un proxy que envuelve tu objeto y registra el proxy en el contenedor, no tu objeto.
  4. Todo el que inyecte un GestorPrestamos recibe 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.

  1. 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:

  1. El llamador externo invoca prestarVarios en el proxy.
  2. El proxy mira: prestarVarios no tiene @Transactional. No hace nada especial.
  3. El proxy invoca prestarVarios en tu objeto real.
  4. Dentro, prestar(isbn, empleado) es en realidad this.prestar(...), y this es tu objeto real, no el proxy.
  5. 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.

  1. 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.yml

Fí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:

mvn spring-boot:run -Dspring-boot.run.profiles=dev

  1. 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.

  1. 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.

  1. 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:

  1. Inyección por constructor con campos final.
  2. El estereotipo correcto.
  3. El número de días desde configuración externa y validado (@Min(1) @Max(30)).
  4. LocalDate.now() sustituido por un Clock inyectado (10-05), para poder probarlo.
  5. procesarPendientes debe ser atómico.
  6. Añade el fragmento de application.yml correspondiente.

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:

  1. En el perfil dev debe usarse la de consola; en prod, la de correo. Ninguna clase debe consultar en qué entorno está.
  2. GestorPrestamos recibe un ServicioAvisos sin saber cuál.
  3. Añade una tercera implementación, AvisosPorSms, y un NotificadorMultiCanal que use todas las implementaciones activas, sin que haya que modificarlo al añadir una cuarta.
  4. Explica qué ocurre si en el perfil prod hay dos implementaciones activas y GestorPrestamos pide 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: 3

Qué 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, avisosPorSms

Dos 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: importarUno no se ejecuta en transacción. Cada guardar se 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 sobre this, que es el objeto real, no el proxy. La llamada no atraviesa el proxy y @Transactional se 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.registrar falla, la multa ya está borrada y no hay registro.
  • Por qué: Spring Boot usa CGLIB, que genera una subclase. Un método final no 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, MaterialCorruptoException sube... 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 RuntimeException y Error. MaterialCorruptoException extiende Exception: 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

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados