Módulo 17: Spring Framework: núcleo

Objetivos del módulo

  • Conocer qué es Spring, su historia y su ecosistema, y la diferencia entre Spring Framework y Spring Boot
  • Entender el problema del acoplamiento y cómo lo resuelven la inversión de control (IoC) y la inyección de dependencias (DI)
  • Crear un proyecto Maven con Spring Framework 7 sin Spring Boot y arrancar un ApplicationContext
  • Definir beans con @Configuration/@Bean y con escaneo de componentes (@Component, @Service, @Repository, @Controller)
  • Usar inyección por constructor, por setter y por campo, y saber por qué se prefiere la de constructor
  • Resolver ambigüedades con @Primary y @Qualifier
  • Distinguir los ámbitos (singleton, prototype, request, session) y el ciclo de vida de un bean (@PostConstruct, @PreDestroy)
  • Leer configuración externa con @Value, @PropertySource y Environment, y usar perfiles con @Profile
  • Publicar y escuchar eventos de aplicación
  • Comprender la AOP (aspecto, advice, pointcut, join point) y escribir un aspecto de log y medición de tiempos
  • Acceder a EMPRESA con JdbcTemplate y JdbcClient y gestionar transacciones declarativas con @Transactional

1. ¿Qué es Spring?

Spring es el framework más utilizado para desarrollar aplicaciones Java de servidor. En su núcleo es un contenedor que crea los objetos de tu aplicación, los conecta entre sí y les añade servicios transversales (transacciones, seguridad, log…) sin que tu código dependa de él más de lo imprescindible.

Línea temporal de Spring desde 2002 hasta Spring Framework 7 y Spring Boot 4 en 2025

Figura 1. Evolución de Spring Framework y Spring Boot.

Spring nació en 2002-2004 como reacción a la complejidad de J2EE: en lugar de componentes pesados que dependían del servidor de aplicaciones (los EJB 2), proponía usar objetos Java normales (POJO, Plain Old Java Objects) y configurarlos desde fuera. Hoy “Spring” es una familia de proyectos:

Proyecto Para qué sirve Módulo del curso
Spring Framework El núcleo: contenedor IoC, AOP, transacciones, JDBC, MVC web 17
Spring Boot Arranque rápido: autoconfiguración, starters, servidor embebido 18
Spring Data JPA Repositorios JPA sin implementar (findByNombre…) 19
Spring Web MVC Controladores web y APIs REST 20 y 21
Spring Security Autenticación y autorización 22
Spring Batch, Spring Cloud, Spring AI… Procesos por lotes, microservicios, IA —

📘 Concepto: En este curso usamos Spring Framework 7.0 (publicado en noviembre de 2025; requiere Java 17 o superior y se basa en Jakarta EE 11) y Spring Boot 4.1, que está construido sobre él. En este módulo trabajaremos sin Spring Boot a propósito: así verás qué piezas hay que configurar a mano y entenderás qué automatiza Boot en el módulo 18.

💡 Consejo: Spring Framework 7 trae novedades como las anotaciones de nulabilidad de JSpecify en toda su API, el registro programático de beans con BeanRegistrar o anotaciones de resiliencia en el propio núcleo (@Retryable y @ConcurrencyLimit, que se activan con @EnableResilientMethods). No las necesitamos para entender el núcleo, pero conviene saber que existen.


2. El problema del acoplamiento

Imagina el servicio de alta de empleados de EMPRESA. Al dar de alta a alguien hay que enviarle un correo de bienvenida:

package es.curso.empresa.sinspring;

import java.util.ArrayList;
import java.util.List;

public class Ejemplo01SinSpring {

    interface Notificador { void enviar(String destino, String mensaje); }

    static class EmailNotificador implements Notificador {
        public void enviar(String destino, String mensaje) {
            System.out.println("[EMAIL a " + destino + "] " + mensaje);
        }
    }

    /** Doble de pruebas: guarda los mensajes en lugar de enviarlos. */
    static class NotificadorFalso implements Notificador {
        final List<String> enviados = new ArrayList<>();
        public void enviar(String destino, String mensaje) { enviados.add(destino + ": " + mensaje); }
    }

    // ---- Versión ACOPLADA: la clase crea su propia dependencia ----
    static class AltaEmpleadoAcoplada {
        private final Notificador notificador = new EmailNotificador();   // ¡atada a Email!
        void alta(String email) { notificador.enviar(email, "Bienvenido a EMPRESA"); }
    }

    // ---- Versión DESACOPLADA: la dependencia se recibe desde fuera ----
    static class AltaEmpleado {
        private final Notificador notificador;
        AltaEmpleado(Notificador notificador) { this.notificador = notificador; }   // inyección
        void alta(String email) { notificador.enviar(email, "Bienvenido a EMPRESA"); }
    }

    public static void main(String[] args) {
        new AltaEmpleadoAcoplada().alta("olga.prieto@empresa.es");

        // Nosotros hacemos de "contenedor": creamos y conectamos los objetos
        AltaEmpleado servicio = new AltaEmpleado(new EmailNotificador());
        servicio.alta("raul.vega@empresa.es");

        // En una prueba podemos inyectar otra implementación sin tocar AltaEmpleado
        NotificadorFalso falso = new NotificadorFalso();
        new AltaEmpleado(falso).alta("test@empresa.es");
        System.out.println("Mensajes capturados en la prueba: " + falso.enviados);
    }
}

Salida real:

[EMAIL a olga.prieto@empresa.es] Bienvenido a EMPRESA
[EMAIL a raul.vega@empresa.es] Bienvenido a EMPRESA
Mensajes capturados en la prueba: [test@empresa.es: Bienvenido a EMPRESA]

AltaEmpleadoAcoplada crea su dependencia con new: está atada para siempre a EmailNotificador. Para enviar SMS hay que modificarla, y en una prueba unitaria enviaría correos de verdad. AltaEmpleado, en cambio, recibe la dependencia: es exactamente lo que viste en el módulo 10 (inyección de dependencias como patrón) y lo que usabas con Mockito en el módulo 11.

El problema es que, en una aplicación real con cientos de clases, alguien tiene que crear todos esos objetos en el orden correcto y conectarlos: el DataSource antes que el JdbcTemplate, este antes que los repositorios, estos antes que los servicios… Ese “alguien” es el contenedor de Spring.

Comparación: sin IoC la clase crea su dependencia; con IoC el contenedor crea e inyecta las implementaciones

Figura 2. Del acoplamiento a la inversión de control.


3. Inversión de control e inyección de dependencias

📘 Concepto: Inversión de control (IoC) significa que el control de crear y conectar los objetos deja de estar en tus clases y pasa a un componente externo, el contenedor. Es el “principio de Hollywood”: no nos llames, ya te llamaremos.

📘 Concepto: Inyección de dependencias (DI) es la forma concreta de lograr la IoC: el contenedor entrega a cada objeto las dependencias que necesita (por el constructor, por un setter o directamente en un campo) en lugar de que el objeto las busque o las cree.

📘 Concepto: Un bean es simplemente un objeto creado, configurado y gestionado por el contenedor de Spring. No tiene que implementar ninguna interfaz especial: un NominaService normal se convierte en bean en cuanto Spring lo gestiona.

El contenedor recibe clases, metadatos y propiedades y produce un grafo de beans conectados

Figura 3. El contenedor IoC de Spring: entradas, procesos internos y resultado.

El contenedor necesita dos cosas: tus clases y unos metadatos de configuración que le digan qué objetos crear. Históricamente esos metadatos se escribían en XML; hoy se usan anotaciones y clases Java de configuración. En Spring, el contenedor es la interfaz ApplicationContext (que amplía a la más básica BeanFactory).


4. Un proyecto Maven con Spring Framework 7 (sin Boot)

4.1 El pom.xml

<?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>

    <groupId>es.curso</groupId>
    <artifactId>empresa-spring</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <spring.version>7.0.9</spring.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- BOM: fija la misma versión para todos los módulos de Spring Framework -->
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-framework-bom</artifactId>
                <version>${spring.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <!-- Núcleo: contenedor IoC, ApplicationContext, eventos, AOP basada en proxies -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
        <!-- JdbcTemplate, JdbcClient y gestor de transacciones JDBC (incluye spring-tx) -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
        </dependency>
        <!-- Necesario para las anotaciones @Aspect, @Around... -->
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.25.1</version>
        </dependency>
        <!-- @PostConstruct y @PreDestroy -->
        <dependency>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>7.0.2</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>9.7.0</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.5.38</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.16.0</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.4</version>
            </plugin>
        </plugins>
    </build>
</project>
Artefacto Contenido
spring-framework-bom Bill of materials: importado en dependencyManagement, evita repetir la versión en cada módulo de Spring
spring-context ApplicationContext, anotaciones @Component, @Configuration, eventos… Arrastra spring-core, spring-beans, spring-aop y spring-expression
spring-jdbc JdbcTemplate, JdbcClient, JdbcTransactionManager. Arrastra spring-tx (@Transactional)
aspectjweaver Spring AOP usa la sintaxis y el analizador de expresiones de AspectJ
jakarta.annotation-api Las anotaciones estándar @PostConstruct y @PreDestroy (no están en el JDK desde Java 11)
HikariCP, MySQL, Logback El pool, el driver y el log que ya conoces

💡 Consejo: Las versiones que usamos (Spring Framework 7.0.9, AspectJ 1.9.25.1, HikariCP 7.0.2, MySQL Connector/J 9.7.0, Logback 1.5.38) son las mismas que gestiona Spring Boot 4.1. Con Boot no tendrás que escribirlas: el starter las fija por ti.

4.2 Estructura y configuración

empresa-spring/
├── pom.xml
└── src/main/
    ├── java/es/curso/empresa/
    │   ├── Ejemplo02Contenedor.java ...      ← programas con main()
    │   └── app/
    │       ├── config/       AppConfig, PerfilesConfig
    │       ├── notificacion/ Notificador, EmailNotificador, SmsNotificador, Avisos
    │       ├── repositorio/  Empleado (record), EmpleadoRepository
    │       ├── servicio/     NominaService, InformeService, SubidaSalarialEvent
    │       ├── eventos/      AuditoriaListener
    │       ├── aop/          Medir, RendimientoAspect
    │       └── ciclo/        CatalogoOficios, CarritoPedido, TiendaService
    └── resources/
        ├── app.properties
        └── logback.xml
# app.properties
# Datos de conexión
db.url=jdbc:mysql://localhost:3306/empresa
db.usuario=curso
db.clave=curso
db.pool.maximo=5

# Datos de la aplicación
app.nombre=Gestión EMPRESA
app.version=1.0
app.iva=21
app.oficios-sin-comision=DIRECTOR GENERAL,DIRECTOR,BECARIO

El logback.xml activa dos loggers de Spring muy didácticos: el que muestra el SQL de JdbcTemplate y el que muestra el inicio, commit y rollback de cada transacción:

<configuration>
    <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger{0} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- SQL que ejecuta JdbcTemplate -->
    <logger name="org.springframework.jdbc.core.JdbcTemplate" level="DEBUG"/>
    <!-- Inicio, commit y rollback de las transacciones -->
    <logger name="org.springframework.jdbc.support.JdbcTransactionManager" level="DEBUG"/>
    <!-- Nuestros propios mensajes -->
    <logger name="es.curso.empresa" level="INFO"/>

    <root level="WARN">
        <appender-ref ref="CONSOLA"/>
    </root>
</configuration>

5. El ApplicationContext y la configuración Java

5.1 @Configuration y @Bean

La forma más explícita de definir beans es una clase de configuración: cada método anotado con @Bean crea un objeto y Spring lo registra con el nombre del método. Es ideal para objetos de bibliotecas ajenas (como HikariDataSource o JdbcTemplate), que no puedes anotar.

package es.curso.empresa.app.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration                                              // clase de configuración
@ComponentScan("es.curso.empresa.app")                       // busca @Component, @Service...
@PropertySource(value = "classpath:app.properties", encoding = "UTF-8")
@EnableAspectJAutoProxy                                      // activa los @Aspect
@EnableTransactionManagement                                 // activa @Transactional
public class AppConfig {

    // @Bean: el valor devuelto se registra como bean (nombre = nombre del método)
    @Bean(destroyMethod = "close")
    public DataSource dataSource(@Value("${db.url}") String url,
                                 @Value("${db.usuario}") String usuario,
                                 @Value("${db.clave}") String clave,
                                 @Value("${db.pool.maximo:10}") int maximo) {
        HikariConfig cfg = new HikariConfig();
        cfg.setJdbcUrl(url);
        cfg.setUsername(usuario);
        cfg.setPassword(clave);
        cfg.setMaximumPoolSize(maximo);
        return new HikariDataSource(cfg);
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {     // Spring inyecta el DataSource
        return new JdbcTemplate(dataSource);
    }

    @Bean
    public JdbcClient jdbcClient(DataSource dataSource) {
        return JdbcClient.create(dataSource);
    }

    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new JdbcTransactionManager(dataSource);
    }
}
Anotación Qué hace
@Configuration Marca la clase como fuente de definiciones de beans
@Bean El objeto que devuelve el método es un bean. Los parámetros del método se inyectan
@Bean(destroyMethod = "close") Método que Spring llamará al cerrar el contexto (aquí, cerrar el pool)
@ComponentScan("paquete") Busca en ese paquete (y subpaquetes) clases con estereotipos (sección 6)
@PropertySource Carga un fichero de propiedades en el Environment (sección 11)
@EnableAspectJAutoProxy Activa los aspectos @Aspect (sección 14)
@EnableTransactionManagement Activa @Transactional (sección 16)

⚠️ Importante: Indica encoding = "UTF-8" en @PropertySource. Por compatibilidad con java.util.Properties, Spring lee los .properties por defecto en ISO-8859-1 y “Gestión” aparecería como “Gestión”.

5.2 Arrancar el contenedor y pedirle beans

public class Ejemplo02Contenedor {

    public static void main(String[] args) {
        // 1. Crear el contenedor a partir de la clase de configuración
        try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {

            // 2. Los beans que hemos definido nosotros (los de Spring empiezan por "org.")
            System.out.println("--- Beans de la aplicación ---");
            Arrays.stream(ctx.getBeanDefinitionNames())
                  .filter(n -> !n.startsWith("org.springframework"))
                  .sorted()
                  .forEach(n -> System.out.println("  " + n + " -> "
                          + ctx.getType(n).getSimpleName()));

            // 3. Pedir beans al contenedor: por tipo, y por nombre y tipo
            Notificador porDefecto = ctx.getBean(Notificador.class);          // el @Primary
            Notificador sms = ctx.getBean("sms", Notificador.class);
            System.out.println("Por tipo: " + porDefecto.getClass().getSimpleName()
                    + " | por nombre 'sms': " + sms.getClass().getSimpleName());

            // 4. Singleton: siempre la misma instancia
            System.out.println("¿Mismo objeto? " + (ctx.getBean(Avisos.class) == ctx.getBean(Avisos.class)));

            // 5. El bean ya viene con sus dependencias inyectadas
            Avisos avisos = ctx.getBean(Avisos.class);
            System.out.println(avisos.describir());
            avisos.informar("ana.garcia@empresa.es", "Reunión de dirección a las 10:00");
            avisos.alertar("600100100", "Servidor caído");
        }
    }
}

Salida real:

[CICLO] 1. Constructor: dependencias recibidas
DEBUG JdbcTemplate - Executing SQL query [SELECT DISTINCT oficio FROM empleados ORDER BY oficio]
[CICLO] 2. @PostConstruct: 11 oficios cargados
[AUDITORÍA] Contexto listo con 29 beans
--- Beans de la aplicación ---
  appConfig -> AppConfig$$SpringCGLIB$$0
  auditoriaListener -> AuditoriaListener
  avisos -> Avisos
  carritoPedido -> CarritoPedido
  catalogoOficios -> CatalogoOficios
  dataSource -> HikariDataSource
  emailNotificador -> EmailNotificador
  empleadoRepository -> EmpleadoRepository
  entornoPorDefecto -> InfoEntorno
  informeService -> InformeService$$SpringCGLIB$$0
  jdbcClient -> DefaultJdbcClient
  jdbcTemplate -> JdbcTemplate
  nominaService -> NominaService$$SpringCGLIB$$0
  perfilesConfig -> PerfilesConfig$$SpringCGLIB$$0
  rendimientoAspect -> RendimientoAspect
  sms -> SmsNotificador
  tiendaService -> TiendaService
  transactionAttributeSource -> AnnotationTransactionAttributeSource
  transactionInterceptor -> TransactionInterceptor
  transactionManager -> JdbcTransactionManager
Por tipo: EmailNotificador | por nombre 'sms': SmsNotificador
¿Mismo objeto? true
principal=EmailNotificador, urgente=SmsNotificador, porCampo=SmsNotificador
[EMAIL a ana.garcia@empresa.es] Reunión de dirección a las 10:00
[SMS a 600100100] ¡URGENTE! Servidor caído
[CICLO] 4. @PreDestroy: liberando el catálogo

Hay mucho que leer en esta salida:

  • Las cuatro primeras líneas aparecen al crear el contexto, antes de que pidamos nada: Spring crea todos los singletons al arrancar (verás el porqué en la sección 10). Aparecerán en todos los ejemplos del módulo; a partir de ahora las omitiremos.
  • Los nombres de bean se generan automáticamente: el nombre de la clase con la inicial en minúscula (avisos, empleadoRepository), el nombre del método @Bean (dataSource) o el que indicamos ("sms").
  • Algunas clases aparecen como ...$$SpringCGLIB$$0: no son tus clases, sino subclases generadas (proxies). Las clases @Configuration se envuelven para que llamar dos veces a un método @Bean devuelva el mismo objeto, y NominaService e InformeService tienen proxies por la AOP y las transacciones (secciones 14 y 16).
  • La última línea aparece al cerrar el contexto (fin del try-with-resources).

6. Escaneo de componentes y estereotipos

Definir con @Bean cada clase propia sería tedioso. Con @ComponentScan, Spring recorre los paquetes indicados y registra automáticamente cualquier clase anotada con un estereotipo:

Anotación Capa Qué añade
@Component Genérica Solo la registra como bean
@Service Lógica de negocio Lo mismo que @Component; documenta la intención
@Repository Acceso a datos Además, traduce las excepciones de la base de datos a DataAccessException de Spring
@Controller / @RestController Presentación web Los usan Spring MVC y REST (módulos 20 y 21)
@Configuration Configuración Es también un @Component: por eso se escanean PerfilesConfig y similares
public interface Notificador {
    void enviar(String destino, String mensaje);
}
@Component
@Primary                         // la opción por defecto cuando hay varios Notificador
public class EmailNotificador implements Notificador {
    @Override
    public void enviar(String destino, String mensaje) {
        System.out.println("[EMAIL a " + destino + "] " + mensaje);
    }
}
@Component("sms")                // nombre explícito del bean
public class SmsNotificador implements Notificador {
    @Override
    public void enviar(String destino, String mensaje) {
        System.out.println("[SMS a " + destino + "] " + mensaje);
    }
}

💡 Consejo: @Bean y @Component no compiten: usa @Component (y sus variantes) para tus clases y @Bean para objetos de terceros o cuando la creación necesite lógica (por ejemplo, elegir una implementación según una propiedad).


7. Formas de inyectar dependencias

Spring puede inyectar una dependencia de tres maneras. Esta clase (didáctica) usa las tres:

package es.curso.empresa.app.notificacion;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

/** Muestra las tres formas de inyección (en código real, usa solo la de constructor). */
@Component
public class Avisos {

    private final Notificador principal;           // 1. por CONSTRUCTOR (recomendada)
    private Notificador urgente;                   // 2. por SETTER
    @Autowired
    @Qualifier("sms")
    private Notificador porCampo;                  // 3. por CAMPO (desaconsejada)

    // Un único constructor: @Autowired es implícito. Sin @Qualifier se usa el @Primary
    public Avisos(Notificador principal) {
        this.principal = principal;
    }

    @Autowired
    public void setUrgente(@Qualifier("sms") Notificador urgente) {
        this.urgente = urgente;
    }

    public void informar(String destino, String texto) { principal.enviar(destino, texto); }
    public void alertar(String destino, String texto) { urgente.enviar(destino, "¡URGENTE! " + texto); }

    public String describir() {
        return "principal=" + principal.getClass().getSimpleName()
                + ", urgente=" + urgente.getClass().getSimpleName()
                + ", porCampo=" + porCampo.getClass().getSimpleName();
    }
}
Tipo Cómo Ventajas Inconvenientes
Constructor Parámetros del constructor Dependencias final e inmutables; el objeto nunca está a medio construir; se prueba con un simple new; las dependencias son visibles Muchos parámetros delatan una clase con demasiadas responsabilidades (¡eso es bueno saberlo!)
Setter Método setXxx con @Autowired Dependencias opcionales o que cambian El objeto puede usarse sin la dependencia; no puede ser final
Campo @Autowired sobre el atributo Muy corto No puede ser final, oculta las dependencias y en las pruebas obliga a usar reflexión o el contenedor

📘 Concepto: Desde Spring 4.3, si una clase tiene un único constructor, Spring lo usa para inyectar sin necesidad de @Autowired. Por eso EmpleadoRepository, NominaService y casi todas las clases del módulo no llevan ninguna anotación en el constructor.

💡 Consejo: Usa siempre la inyección por constructor con atributos private final. Es la recomendación oficial del equipo de Spring y la que encaja con lo que hacías en el módulo 11: new NominaService(repositorioFalso, publicadorFalso) en una prueba con Mockito, sin arrancar Spring.


8. Varios candidatos: @Primary y @Qualifier

Hay dos beans de tipo Notificador. Cuando una clase pide “un Notificador”, ¿cuál le da Spring?

  • Si uno está marcado con @Primary, ese (por eso principal recibe EmailNotificador).
  • Si el punto de inyección lleva @Qualifier("nombre"), el bean con ese nombre (por eso urgente y porCampo reciben SmsNotificador).
  • Si no hay ni lo uno ni lo otro, Spring intenta casar el nombre del parámetro con el nombre del bean y, si no puede, falla al arrancar con NoUniqueBeanDefinitionException: expected single matching bean but found 2: emailNotificador,sms.

💡 Consejo: También puedes pedir todos los candidatos: un parámetro List<Notificador> recibe una lista con todos los beans de ese tipo, y un Map<String, Notificador> los recibe indexados por nombre. Lo usarás en el ejercicio 1 para elegir una estrategia según una propiedad (patrón Strategy del módulo 10).


9. Ámbitos de un bean

📘 Concepto: El ámbito (scope) de un bean determina cuántas instancias crea Spring y cuánto viven.

Ámbito Instancias Uso típico
singleton (por defecto) Una por contenedor, creada al arrancar Servicios, repositorios, configuración: objetos sin estado
prototype Una nueva cada vez que se pide Objetos con estado propio (un carrito, un generador de informes)
request Una por petición HTTP Aplicaciones web (módulo 21)
session Una por sesión HTTP de usuario Datos de la sesión (aplicaciones web)
application Una por ServletContext Aplicaciones web
/** Bean con estado: cada petición al contenedor debe recibir uno NUEVO. */
@Component
@Scope("prototype")
public class CarritoPedido {

    private static int contador = 0;
    private final int numero = ++contador;
    private final List<String> productos = new ArrayList<>();

    public void anadir(String producto) { productos.add(producto); }

    @Override
    public String toString() { return "Carrito#" + numero + productos; }
}
/** Singleton que necesita un prototype NUEVO cada vez: usa ObjectProvider. */
@Component
public class TiendaService {

    private final CarritoPedido carritoInyectado;              // se inyecta UNA vez
    private final ObjectProvider<CarritoPedido> carritos;      // fábrica de carritos

    public TiendaService(CarritoPedido carritoInyectado, ObjectProvider<CarritoPedido> carritos) {
        this.carritoInyectado = carritoInyectado;
        this.carritos = carritos;
    }

    public CarritoPedido carritoInyectado() { return carritoInyectado; }
    public CarritoPedido nuevoCarrito() { return carritos.getObject(); }
}
public class Ejemplo03Ambitos {

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
            // prototype: un objeto nuevo en cada getBean
            CarritoPedido c1 = ctx.getBean(CarritoPedido.class);
            CarritoPedido c2 = ctx.getBean(CarritoPedido.class);
            c1.anadir("Ratón inalámbrico");
            System.out.println("c1 = " + c1 + ", c2 = " + c2 + ", ¿iguales? " + (c1 == c2));

            // prototype dentro de un singleton: el inyectado es SIEMPRE el mismo
            TiendaService tienda = ctx.getBean(TiendaService.class);
            System.out.println("Inyectado: " + tienda.carritoInyectado() + " / " + tienda.carritoInyectado());
            // con ObjectProvider obtenemos uno nuevo cada vez
            System.out.println("Provider:  " + tienda.nuevoCarrito() + " / " + tienda.nuevoCarrito());
        }
    }
}

Salida real (sin las líneas de arranque):

c1 = Carrito#2[Ratón inalámbrico], c2 = Carrito#3[], ¿iguales? false
Inyectado: Carrito#1[] / Carrito#1[]
Provider:  Carrito#4[] / Carrito#5[]

El carrito número 1 se creó al arrancar, para inyectarlo en el singleton TiendaService; por eso los que pedimos después son el 2 y el 3. Y ahí está la trampa: un prototype inyectado en un singleton se crea una sola vez y queda “congelado”. Si el singleton necesita uno nuevo cada vez, debe pedirlo a un ObjectProvider.

⚠️ Importante: Los singleton se comparten entre todos los hilos (en una aplicación web, entre todas las peticiones simultáneas). Nunca guardes en un atributo de un singleton datos de una operación concreta (el usuario actual, un total parcial…): tendrías una condición de carrera como las del módulo 06. Los beans singleton deben ser sin estado o con estado inmutable.


10. Ciclo de vida de un bean

Pasos del ciclo de vida: definición, instanciación, inyección, Aware, inicialización, post-procesado, uso y destrucción

Figura 4. Ciclo de vida de un bean singleton.

Para ejecutar código en esos momentos se usan las anotaciones estándar de Jakarta @PostConstruct (cuando el bean ya tiene todas sus dependencias) y @PreDestroy (al cerrar el contexto):

package es.curso.empresa.app.ciclo;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

/** Bean que carga datos al arrancar y libera recursos al cerrar. */
@Component
public class CatalogoOficios {

    private final JdbcTemplate jdbc;
    private final Set<String> sinComision;
    private List<String> oficios;

    public CatalogoOficios(JdbcTemplate jdbc,
                           @Value("${app.oficios-sin-comision}") String[] sinComision) {
        System.out.println("[CICLO] 1. Constructor: dependencias recibidas");
        this.jdbc = jdbc;
        this.sinComision = Set.of(sinComision);         // "A,B,C" se convierte en un array
    }

    @PostConstruct                      // tras inyectar TODAS las dependencias
    void cargar() {
        oficios = jdbc.queryForList("SELECT DISTINCT oficio FROM empleados ORDER BY oficio", String.class);
        System.out.println("[CICLO] 2. @PostConstruct: " + oficios.size() + " oficios cargados");
    }

    @PreDestroy                         // al cerrar el contexto
    void liberar() {
        System.out.println("[CICLO] 4. @PreDestroy: liberando el catálogo");
        oficios = null;
    }

    public List<String> getOficios() { return oficios; }
    public boolean tieneComision(String oficio) { return !sinComision.contains(oficio); }
}
public class Ejemplo04CicloVida {

    public static void main(String[] args) {
        System.out.println("--- Creando el contexto ---");
        var ctx = new AnnotationConfigApplicationContext(AppConfig.class);

        CatalogoOficios catalogo = ctx.getBean(CatalogoOficios.class);
        System.out.println("[CICLO] 3. En uso: " + catalogo.getOficios());
        System.out.println("¿VENDEDOR cobra comisión? " + catalogo.tieneComision("VENDEDOR"));
        System.out.println("¿BECARIO cobra comisión? " + catalogo.tieneComision("BECARIO"));

        System.out.println("--- Cerrando el contexto ---");
        ctx.close();                    // destruye los singletons: llama a @PreDestroy
    }
}

Salida real:

--- Creando el contexto ---
[CICLO] 1. Constructor: dependencias recibidas
DEBUG JdbcTemplate - Executing SQL query [SELECT DISTINCT oficio FROM empleados ORDER BY oficio]
[CICLO] 2. @PostConstruct: 11 oficios cargados
[AUDITORÍA] Contexto listo con 29 beans
[CICLO] 3. En uso: [ADMINISTRATIVO, ANALISTA, BECARIO, CONTABLE, DIRECTOR, DIRECTOR GENERAL, DISEÑADOR, OPERARIO, PROGRAMADOR, TÉCNICO SISTEMAS, VENDEDOR]
¿VENDEDOR cobra comisión? true
¿BECARIO cobra comisión? false
--- Cerrando el contexto ---
[CICLO] 4. @PreDestroy: liberando el catálogo

📘 Concepto: Los singleton se crean de forma ansiosa, al arrancar el contexto, en el orden que imponen sus dependencias. Ventaja: cualquier error de configuración (una propiedad que falta, una tabla que no existe, dos candidatos ambiguos) aparece al arrancar, no a mitad de la noche cuando alguien usa por primera vez esa función. Si un bean concreto es muy costoso y rara vez se usa, puedes marcarlo con @Lazy para que se cree la primera vez que se pida.

⚠️ Importante: No uses las dependencias en el constructor para hacer trabajo pesado o que dependa de inyecciones por setter: en ese momento puede que el bean aún no esté completo. Ese trabajo va en @PostConstruct. Alternativas equivalentes: implementar InitializingBean.afterPropertiesSet() y DisposableBean.destroy(), o indicar @Bean(initMethod = "...", destroyMethod = "...").


11. Propiedades externas: @Value y Environment

Los datos que cambian entre entornos (URL de la base de datos, claves, tipos de IVA…) no deben estar en el código. Spring los reúne en el Environment, que combina varias fuentes: propiedades del sistema (-Dclave=valor), variables de entorno y ficheros cargados con @PropertySource.

Forma Ejemplo Notas
@Value("${clave}") @Value("${db.url}") String url Falla al arrancar si la clave no existe
Valor por defecto @Value("${db.pool.maximo:10}") int maximo Tras : va el valor si no existe la clave
Conversión de tipos int, boolean, BigDecimal, String[] "A,B,C" se convierte en un array de tres elementos
Expresión SpEL @Value("#{2 * 60}") int segundos Spring Expression Language para cálculos
Environment env.getProperty("app.iva", Integer.class, 0) Acceso programático; útil para claves opcionales
public class Ejemplo05Propiedades {

    public static void main(String[] args) {
        // Perfil recibido como argumento (por defecto, ninguno)
        var ctx = new AnnotationConfigApplicationContext();
        if (args.length > 0) {
            ctx.getEnvironment().setActiveProfiles(args[0]);   // antes de register/refresh
        }
        ctx.register(AppConfig.class);
        ctx.refresh();

        try (ctx) {
            Environment env = ctx.getEnvironment();
            System.out.println("Perfiles activos: " + Arrays.toString(env.getActiveProfiles()));
            System.out.println("Aplicación: " + env.getProperty("app.nombre") + " v" + env.getProperty("app.version"));
            int iva = env.getProperty("app.iva", Integer.class, 0);
            System.out.println("IVA: " + iva + " %");
            System.out.println("Moneda (con valor por defecto): " + env.getProperty("app.moneda", "EUR"));
            System.out.println("Variable de entorno HOME: " + env.getProperty("HOME"));
            System.out.println("Entorno: " + ctx.getBean(InfoEntorno.class));
        }
    }
}

Salida real sin argumentos (sin las líneas de arranque; el programa se ejecutó en un contenedor Linux, de ahí el valor de HOME):

Perfiles activos: []
Aplicación: Gestión EMPRESA v1.0
IVA: 21 %
Moneda (con valor por defecto): EUR
Variable de entorno HOME: /root
Entorno: InfoEntorno[nombre=POR DEFECTO, enviarCorreosReales=false]

⚠️ Importante: No guardes contraseñas reales en ficheros del repositorio. Lo habitual es dejar en el fichero un valor de desarrollo y sobrescribirlo en producción con una propiedad del sistema (-Ddb.clave=...) o una variable de entorno, que tienen más prioridad que @PropertySource. Para las variables de entorno, Spring prueba también el nombre en mayúsculas y con guiones bajos: una variable DB_CLAVE sirve para la propiedad db.clave.


12. Perfiles: @Profile

Un perfil es un nombre (dev, prod, test…) que activa o desactiva beans. Permite tener, por ejemplo, un notificador que solo escribe en consola en desarrollo y uno que envía correos de verdad en producción.

@Configuration
public class PerfilesConfig {

    public record InfoEntorno(String nombre, boolean enviarCorreosReales) { }

    @Bean
    @Profile("dev")                         // solo existe si el perfil dev está activo
    public InfoEntorno entornoDesarrollo() {
        return new InfoEntorno("DESARROLLO", false);
    }

    @Bean
    @Profile("prod")
    public InfoEntorno entornoProduccion() {
        return new InfoEntorno("PRODUCCIÓN", true);
    }

    @Bean
    @Profile("default")                     // si no se activa ningún perfil
    public InfoEntorno entornoPorDefecto() {
        return new InfoEntorno("POR DEFECTO", false);
    }
}

Ejecutando Ejemplo05Propiedades con el argumento dev y después con prod, cambian solo dos líneas:

Perfiles activos: [dev]
...
Entorno: InfoEntorno[nombre=DESARROLLO, enviarCorreosReales=false]
Perfiles activos: [prod]
...
Entorno: InfoEntorno[nombre=PRODUCCIÓN, enviarCorreosReales=true]
Forma de activar perfiles Ejemplo
Código, antes de refresh() ctx.getEnvironment().setActiveProfiles("dev")
Propiedad del sistema java -Dspring.profiles.active=prod ...
Variable de entorno SPRING_PROFILES_ACTIVE=prod
En las pruebas @ActiveProfiles("test") (módulo 23)

💡 Consejo: @Profile admite expresiones: @Profile("!prod") (cualquier perfil menos prod), @Profile("dev | test"). El perfil especial default está activo solo cuando no se ha activado ninguno.


13. Eventos de aplicación

Spring incluye un sistema de eventos que permite que unos beans avisen a otros sin conocerse (patrón Observer del módulo 10). Cualquier objeto puede ser un evento; un record es ideal:

/** Evento de aplicación: cualquier objeto (aquí, un record) puede publicarse como evento. */
public record SubidaSalarialEvent(int deptId, BigDecimal porcentaje, int empleados) { }

El servicio lo publica con ApplicationEventPublisher (que se inyecta como cualquier otra dependencia):

eventos.publishEvent(new SubidaSalarialEvent(deptId, porcentaje, lista.size()));

Y cualquier bean puede escucharlo con @EventListener; el tipo del parámetro indica qué eventos recibe:

package es.curso.empresa.app.eventos;

import es.curso.empresa.app.servicio.SubidaSalarialEvent;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AuditoriaListener {

    @EventListener                                   // se ejecuta al publicarse el evento
    public void alSubirSalarios(SubidaSalarialEvent e) {
        System.out.println("[AUDITORÍA] Subida del " + e.porcentaje() + " % a "
                + e.empleados() + " empleados del departamento " + e.deptId());
    }

    @EventListener                                   // evento propio de Spring
    public void alArrancar(ContextRefreshedEvent e) {
        System.out.println("[AUDITORÍA] Contexto listo con "
                + e.getApplicationContext().getBeanDefinitionCount() + " beans");
    }
}

La línea [AUDITORÍA] Contexto listo con 29 beans que ves en todos los ejemplos sale de aquí: ContextRefreshedEvent es uno de los eventos que publica el propio Spring (también hay ContextClosedEvent, y Spring Boot añade ApplicationReadyEvent). El evento de subida salarial lo verás en la sección 16.

Detalle Comportamiento
Hilo Por defecto, el listener se ejecuta en el mismo hilo y antes de que publishEvent vuelva
Orden entre listeners @Order(1), @Order(2)…
Asíncrono @Async en el listener + @EnableAsync
Ligado a la transacción @TransactionalEventListener: se ejecuta tras el commit (por ejemplo, para enviar el correo solo si los datos se guardaron)

14. Programación orientada a aspectos (AOP)

Hay tareas que aparecen en muchas clases sin pertenecer a ninguna: escribir en el log cada llamada, medir tiempos, comprobar permisos, abrir y cerrar transacciones… Son preocupaciones transversales (cross-cutting concerns). Si las escribes a mano, el mismo código se repite en cada método y oculta la lógica de negocio.

📘 Concepto: La AOP (Aspect-Oriented Programming) permite escribir esas tareas una vez, en un aspecto, y decir de forma declarativa a qué métodos se aplican. Spring AOP lo hace creando un proxy alrededor de los beans afectados.

El proxy creado por Spring intercepta la llamada, ejecuta los advices del aspecto y llama al objeto real

Figura 5. Conceptos de AOP en Spring.

Concepto Definición En nuestro ejemplo
Aspecto Módulo con una preocupación transversal RendimientoAspect
Join point Punto de la ejecución donde se puede intervenir; en Spring AOP, siempre la ejecución de un método de un bean La llamada a plantillaPorDepartamento()
Pointcut Expresión que selecciona join points execution(public * es.curso.empresa.app.servicio..*(..))
Advice Código que se ejecuta y cuándo @Before, @Around, @AfterThrowing
Target El objeto real InformeService
Proxy El objeto que Spring inyecta en su lugar InformeService$$SpringCGLIB$$0
Weaving Aplicar los aspectos (en Spring, al crear los beans) Paso ⑥ del ciclo de vida

14.1 Un aspecto de log y de medición de tiempos

Primero, una anotación propia para marcar los métodos que queremos medir:

package es.curso.empresa.app.aop;

import java.lang.annotation.*;

/** Marca los métodos cuyo tiempo de ejecución queremos medir. */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Medir { }

El aspecto:

package es.curso.empresa.app.aop;

import java.util.Arrays;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Aspect                   // esta clase contiene advices
@Component                // y es un bean (si no, Spring no la ve)
public class RendimientoAspect {

    // Pointcut: cualquier método público de cualquier clase del paquete servicio
    @Pointcut("execution(public * es.curso.empresa.app.servicio..*(..))")
    public void metodosDeServicio() { }

    @Before("metodosDeServicio()")
    public void registrarLlamada(JoinPoint jp) {
        System.out.println("[LOG] -> " + jp.getSignature().toShortString()
                + " args=" + Arrays.toString(jp.getArgs()));
    }

    @AfterThrowing(pointcut = "metodosDeServicio()", throwing = "ex")
    public void registrarError(JoinPoint jp, Exception ex) {
        System.out.println("[LOG] !! " + jp.getSignature().getName() + " lanzó "
                + ex.getClass().getSimpleName() + ": " + ex.getMessage());
    }

    // Around: envuelve la ejecución; decide cuándo (y si) se llama al método real
    @Around("@annotation(es.curso.empresa.app.aop.Medir)")
    public Object medirTiempo(ProceedingJoinPoint pjp) throws Throwable {
        long inicio = System.nanoTime();
        try {
            return pjp.proceed();                  // ejecuta el método original
        } finally {
            long ms = (System.nanoTime() - inicio) / 1_000_000;
            System.out.println("[TIEMPO] " + pjp.getSignature().toShortString() + " tardó " + ms + " ms");
        }
    }
}
Advice Cuándo se ejecuta Puede…
@Before Antes del método Ver los argumentos; lanzar una excepción para impedir la llamada
@AfterReturning(returning = "r") Si el método termina bien Ver el valor devuelto
@AfterThrowing(throwing = "ex") Si el método lanza una excepción Ver la excepción (no puede “tragársela”)
@After Siempre, al terminar (como un finally) Liberar recursos
@Around Envuelve la llamada Todo: cambiar argumentos, no llamar, cambiar el resultado, reintentar, medir
Expresión de pointcut Selecciona
execution(* es.curso.empresa.app.servicio.*.*(..)) Cualquier método de las clases del paquete servicio
execution(public * *..NominaService.subir*(..)) Métodos públicos de NominaService que empiezan por subir
within(es.curso.empresa.app..*) Cualquier método de cualquier clase bajo app
@annotation(es.curso.empresa.app.aop.Medir) Métodos anotados con @Medir
bean(nominaService) Los métodos del bean con ese nombre
metodosDeServicio() && !execution(* get*(..)) Combinaciones con &&, \|\| y !

14.2 El aspecto en acción (junto con JdbcClient)

InformeService usa JdbcClient (sección 15) y tiene dos métodos marcados con @Medir:

@Service
public class InformeService {

    public record FilaInforme(String departamento, long empleados, double salarioMedio) { }

    private final JdbcClient jdbc;

    public InformeService(JdbcClient jdbc) { this.jdbc = jdbc; }

    @Medir
    public List<FilaInforme> plantillaPorDepartamento() {
        return jdbc.sql("""
                SELECT d.nombre AS departamento, COUNT(e.emp_id) AS empleados,
                       COALESCE(AVG(e.salario), 0) AS salario_medio
                FROM departamentos d LEFT JOIN empleados e ON e.dept_id = d.dept_id
                GROUP BY d.nombre
                ORDER BY empleados DESC, d.nombre""")
                .query(FilaInforme.class)          // mapea columnas -> componentes del record
                .list();
    }

    @Medir
    public Map<String, Object> resumenCliente(int clienteId) {
        return jdbc.sql("SELECT nombre, ciudad, limite_credito FROM clientes WHERE cliente_id = :id")
                .param("id", clienteId)
                .query()
                .singleRow();
    }
}
public class Ejemplo06AopJdbc {

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
            InformeService informes = ctx.getBean(InformeService.class);
            System.out.println("Clase real del bean: " + informes.getClass().getSimpleName());
            System.out.println("¿Es un proxy AOP? " + AopUtils.isAopProxy(informes));

            System.out.println("--- Plantilla por departamento (JdbcClient + record) ---");
            informes.plantillaPorDepartamento()
                    .forEach(f -> System.out.printf("  %-14s %2d  %8.2f%n",
                            f.departamento(), f.empleados(), f.salarioMedio()));

            System.out.println("--- Un cliente como Map ---");
            System.out.println("  " + informes.resumenCliente(1007));

            System.out.println("--- JdbcTemplate en el repositorio ---");
            EmpleadoRepository repo = ctx.getBean(EmpleadoRepository.class);
            repo.porDepartamento(40).forEach(e -> System.out.println("  " + e));
            System.out.println("  ¿Existe el 999? " + repo.buscar(999).isPresent());
        }
    }
}

Salida real (sin las líneas de arranque):

Clase real del bean: InformeService$$SpringCGLIB$$0
¿Es un proxy AOP? true
--- Plantilla por departamento (JdbcClient + record) ---
[LOG] -> InformeService.plantillaPorDepartamento() args=[]
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT d.nombre AS departamento, COUNT(e.emp_id) AS empleados,
       COALESCE(AVG(e.salario), 0) AS salario_medio
FROM departamentos d LEFT JOIN empleados e ON e.dept_id = d.dept_id
GROUP BY d.nombre
ORDER BY empleados DESC, d.nombre]
[TIEMPO] InformeService.plantillaPorDepartamento() tardó 1104 ms
  INFORMÁTICA     6   3133.33
  VENTAS          6   2500.00
  LOGÍSTICA       4   2237.50
  CONTABILIDAD    3   2816.67
  MARKETING       3   3100.00
  DIRECCIÓN       1   6500.00
  INVESTIGACIÓN   0      0.00
--- Un cliente como Map ---
[LOG] -> InformeService.resumenCliente(..) args=[1007]
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT nombre, ciudad, limite_credito FROM clientes WHERE cliente_id = ?]
[TIEMPO] InformeService.resumenCliente(..) tardó 438 ms
  {nombre=Hotel Mar Azul S.L., ciudad=Palma, limite_credito=15000.00}
--- JdbcTemplate en el repositorio ---
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT emp_id, nombre, apellidos, oficio, salario, dept_id
FROM empleados WHERE dept_id = ? ORDER BY salario DESC]
  Empleado[id=103, nombre=Pedro, apellidos=Gómez Díaz, oficio=DIRECTOR, salario=4200.00, deptId=40]
  Empleado[id=130, nombre=Isabel, apellidos=Domínguez Vega, oficio=CONTABLE, salario=2600.00, deptId=40]
  Empleado[id=131, nombre=Jorge, apellidos=Vázquez Ramos, oficio=ADMINISTRATIVO, salario=1650.00, deptId=40]
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT emp_id, nombre, apellidos, oficio, salario, dept_id FROM empleados WHERE emp_id = ?]
  ¿Existe el 999? false

Los mensajes [LOG] y [TIEMPO] salen del aspecto, sin una sola línea de log en InformeService. El repositorio está en otro paquete, así que el pointcut no lo selecciona: no aparece [LOG] en sus llamadas. (Los tiempos son altos porque el ejemplo se ejecutó en una máquina muy cargada; en tu equipo serán de pocos milisegundos.)

⚠️ Importante: Spring AOP tiene limitaciones que conviene recordar: solo intercepta métodos de beans (no objetos creados con new), solo métodos que se puedan sobrescribir (no private, final ni static) y solo llamadas que pasan por el proxy: si un método del servicio llama a otro método de la misma clase (this.otro()), el aspecto no se aplica a esa segunda llamada. Lo comprobarás en el ejercicio 3. Para casos que no cubre Spring AOP existe AspectJ completo, con tejido en compilación.


15. Acceso a datos con JdbcTemplate y JdbcClient

En los módulos 12 y 13 escribiste mucho código JDBC repetitivo: obtener la conexión, preparar la sentencia, recorrer el ResultSet, cerrar todo en finally, traducir SQLException… JdbcTemplate (patrón Template Method del módulo 10) hace todo eso y te deja solo lo específico: el SQL, los parámetros y cómo convertir una fila en un objeto (RowMapper).

/** Con JdbcTemplate no hay entidades JPA: un record basta. */
public record Empleado(int id, String nombre, String apellidos, String oficio,
                       BigDecimal salario, Integer deptId) {
}
package es.curso.empresa.app.repositorio;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

@Repository                      // capa de acceso a datos (traduce excepciones SQL)
public class EmpleadoRepository {

    private static final RowMapper<Empleado> MAPPER = (rs, n) -> new Empleado(
            rs.getInt("emp_id"), rs.getString("nombre"), rs.getString("apellidos"),
            rs.getString("oficio"), rs.getBigDecimal("salario"),
            rs.getObject("dept_id", Integer.class));

    private final JdbcTemplate jdbc;

    public EmpleadoRepository(JdbcTemplate jdbc) {   // inyección por constructor
        this.jdbc = jdbc;
    }

    public List<Empleado> porDepartamento(int deptId) {
        return jdbc.query("""
                SELECT emp_id, nombre, apellidos, oficio, salario, dept_id
                FROM empleados WHERE dept_id = ? ORDER BY salario DESC""", MAPPER, deptId);
    }

    public Optional<Empleado> buscar(int id) {
        return jdbc.query("SELECT emp_id, nombre, apellidos, oficio, salario, dept_id FROM empleados WHERE emp_id = ?",
                MAPPER, id).stream().findFirst();
    }

    public BigDecimal masaSalarial(int deptId) {
        return jdbc.queryForObject("SELECT COALESCE(SUM(salario), 0) FROM empleados WHERE dept_id = ?",
                BigDecimal.class, deptId);
    }

    public int actualizarSalario(int id, BigDecimal salario) {
        return jdbc.update("UPDATE empleados SET salario = ? WHERE emp_id = ?", salario, id);
    }
}
Método de JdbcTemplate Para qué
query(sql, rowMapper, args...) Lista de objetos
queryForObject(sql, Clase.class, args...) Un único valor (lanza excepción si no hay exactamente una fila)
queryForList(sql, Clase.class, args...) Lista de valores de una columna
update(sql, args...) INSERT, UPDATE, DELETE; devuelve las filas afectadas
update(PreparedStatementCreator, KeyHolder) INSERT que devuelve la clave generada (ejercicio 4)
batchUpdate(...) Lotes (módulo 13)
query(sql, RowCallbackHandler, args...) Procesar fila a fila sin construir una lista

JdbcClient (desde Spring 6.1) es una API fluida más moderna sobre el mismo motor: admite parámetros por nombre (:id) y mapea automáticamente las columnas a los componentes de un record (salario_medio → salarioMedio), como viste en InformeService.

📘 Concepto: Spring traduce las SQLException (comprobadas y con códigos distintos en cada base de datos) a una jerarquía de excepciones no comprobadas e independiente del proveedor: DataAccessException y sus subclases (DuplicateKeyException, DataIntegrityViolationException, EmptyResultDataAccessException…). Tu código de negocio no necesita try/catch de SQLException.


16. Transacciones declarativas con @Transactional

En el módulo 13 gestionabas las transacciones a mano: setAutoCommit(false), commit(), rollback() en el catch, y pasar la misma Connection a todos los DAO implicados. Con Spring basta con anotar el método:

package es.curso.empresa.app.servicio;

import es.curso.empresa.app.repositorio.Empleado;
import es.curso.empresa.app.repositorio.EmpleadoRepository;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service                          // capa de negocio
public class NominaService {

    private final EmpleadoRepository repositorio;
    private final ApplicationEventPublisher eventos;

    public NominaService(EmpleadoRepository repositorio, ApplicationEventPublisher eventos) {
        this.repositorio = repositorio;
        this.eventos = eventos;
    }

    @Transactional(readOnly = true)
    public BigDecimal masaSalarial(int deptId) {
        return repositorio.masaSalarial(deptId);
    }

    /**
     * Sube el salario de todo un departamento. Si algún salario supera el tope,
     * se lanza una excepción y Spring deshace TODOS los UPDATE ya ejecutados.
     */
    @Transactional
    public int subirSalarios(int deptId, BigDecimal porcentaje, BigDecimal tope) {
        List<Empleado> lista = repositorio.porDepartamento(deptId).reversed();   // de menor a mayor salario
        BigDecimal factor = BigDecimal.ONE.add(porcentaje.movePointLeft(2));
        for (Empleado e : lista) {
            BigDecimal nuevo = e.salario().multiply(factor).setScale(2, RoundingMode.HALF_UP);
            if (nuevo.compareTo(tope) > 0) {
                throw new IllegalStateException("El salario de " + e.nombre() + " superaría " + tope);
            }
            repositorio.actualizarSalario(e.id(), nuevo);
        }
        eventos.publishEvent(new SubidaSalarialEvent(deptId, porcentaje, lista.size()));
        return lista.size();
    }

    /** Deja los salarios como estaban (para que el ejemplo no altere EMPRESA). */
    @Transactional
    public void restaurar(List<Empleado> originales) {
        originales.forEach(e -> repositorio.actualizarSalario(e.id(), e.salario()));
    }
}

📘 Concepto: @Transactional funciona gracias a un proxy: el bean NominaService que Spring inyecta es en realidad una subclase generada que, antes de llamar a tu método, obtiene una conexión del pool, desactiva el autocommit y la asocia al hilo actual; JdbcTemplate (y Hibernate, en Spring Data) usan automáticamente esa misma conexión. Al terminar, el proxy hace commit o, si el método lanzó una excepción no comprobada, rollback.

El proxy de NominaService abre la transacción, llama al método real y hace commit o rollback

Figura 6. Cómo funciona @Transactional.

public class Ejemplo07Transacciones {

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
            NominaService nomina = ctx.getBean(NominaService.class);
            EmpleadoRepository repo = ctx.getBean(EmpleadoRepository.class);
            List<Empleado> originales = repo.porDepartamento(60);       // LOGÍSTICA

            System.out.println("Masa salarial antes: " + nomina.masaSalarial(60));

            System.out.println("=== 1. Subida del 5 % con tope 5000: COMMIT ===");
            nomina.subirSalarios(60, new BigDecimal("5"), new BigDecimal("5000"));
            System.out.println("Masa salarial: " + nomina.masaSalarial(60));

            System.out.println("=== 2. Subida del 10 % con tope 4300: ROLLBACK ===");
            try {
                nomina.subirSalarios(60, new BigDecimal("10"), new BigDecimal("4300"));
            } catch (IllegalStateException e) {
                System.out.println("Excepción recibida: " + e.getMessage());
            }
            System.out.println("Masa salarial (sin cambios): " + nomina.masaSalarial(60));

            nomina.restaurar(originales);
            System.out.println("Masa salarial restaurada: " + nomina.masaSalarial(60));
        }
    }
}

Salida real (el ejemplo genera 102 líneas; se muestran las de la transacción que se confirma y la que se deshace, y el resumen final. Se han acortado los nombres de las conexiones de Hikari):

Masa salarial antes: 8950.00
=== 1. Subida del 5 % con tope 5000: COMMIT ===
DEBUG JdbcTransactionManager - Creating new transaction with name [es.curso.empresa.app.servicio.NominaService.subirSalarios]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG JdbcTransactionManager - Acquired Connection [HikariProxyConnection@233343686 ...] for JDBC transaction
DEBUG JdbcTransactionManager - Switching JDBC Connection [HikariProxyConnection@233343686 ...] to manual commit
[LOG] -> NominaService.subirSalarios(..) args=[60, 5, 5000]
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT emp_id, nombre, apellidos, oficio, salario, dept_id
FROM empleados WHERE dept_id = ? ORDER BY salario DESC]
DEBUG JdbcTemplate - Executing prepared SQL update
DEBUG JdbcTemplate - Executing prepared SQL statement [UPDATE empleados SET salario = ? WHERE emp_id = ?]
   (... 4 UPDATE en total ...)
[AUDITORÍA] Subida del 5 % a 4 empleados del departamento 60
DEBUG JdbcTransactionManager - Initiating transaction commit
DEBUG JdbcTransactionManager - Committing JDBC transaction on Connection [HikariProxyConnection@233343686 ...]
DEBUG JdbcTransactionManager - Releasing JDBC Connection [HikariProxyConnection@233343686 ...] after transaction
   (... transacción readOnly de masaSalarial ...)
Masa salarial: 9397.50
=== 2. Subida del 10 % con tope 4300: ROLLBACK ===
DEBUG JdbcTransactionManager - Creating new transaction with name [es.curso.empresa.app.servicio.NominaService.subirSalarios]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG JdbcTransactionManager - Acquired Connection [HikariProxyConnection@366828226 ...] for JDBC transaction
DEBUG JdbcTransactionManager - Switching JDBC Connection [HikariProxyConnection@366828226 ...] to manual commit
[LOG] -> NominaService.subirSalarios(..) args=[60, 10, 4300]
DEBUG JdbcTemplate - Executing prepared SQL query
   (... SELECT ...)
DEBUG JdbcTemplate - Executing prepared SQL update
DEBUG JdbcTemplate - Executing prepared SQL statement [UPDATE empleados SET salario = ? WHERE emp_id = ?]
   (... 3 UPDATE en total ...)
[LOG] !! subirSalarios lanzó IllegalStateException: El salario de Javier superaría 4300
DEBUG JdbcTransactionManager - Initiating transaction rollback
DEBUG JdbcTransactionManager - Rolling back JDBC transaction on Connection [HikariProxyConnection@366828226 ...]
DEBUG JdbcTransactionManager - Releasing JDBC Connection [HikariProxyConnection@366828226 ...] after transaction
Excepción recibida: El salario de Javier superaría 4300
Masa salarial (sin cambios): 9397.50
Masa salarial restaurada: 8950.00

Sigue la historia en el log:

  1. Primera llamada: el gestor crea la transacción, pide una conexión y la pasa a manual commit. Los cuatro UPDATE usan esa misma conexión. Se publica el evento (el listener escribe [AUDITORÍA]) y se hace commit: la masa salarial pasa de 8950 a 9397,50 €.
  2. Segunda llamada: se actualizan tres empleados (Rosa, Manuel y Cristina, los de menor salario) y, al llegar a Javier, se supera el tope. El aspecto registra la excepción, el proxy de transacciones hace rollback y la excepción llega al main. Los tres UPDATE ya ejecutados se deshacen: la masa salarial sigue en 9397,50 €.
  3. Todo esto sin escribir commit, rollback ni setAutoCommit en el servicio.
Atributo de @Transactional Valor por defecto Significado
propagation REQUIRED Usa la transacción existente o crea una. REQUIRES_NEW abre siempre una nueva (suspende la actual)
readOnly false Pista de optimización para consultas (el driver y Hibernate pueden aprovecharla)
rollbackFor RuntimeException y Error Excepciones que provocan rollback; añade las comprobadas que quieras: rollbackFor = Exception.class
noRollbackFor — Excepciones que no deben deshacer la transacción
isolation La de la base de datos Nivel de aislamiento (módulo 13)
timeout Sin límite Segundos máximos de la transacción

⚠️ Importante: Las tres trampas clásicas de @Transactional: (1) autoinvocación: llamar a un método @Transactional desde otro método de la misma clase no pasa por el proxy y no abre transacción; (2) por defecto, una excepción comprobada (IOException, SQLException propia…) no provoca rollback; (3) si capturas la excepción dentro del método transaccional y no la relanzas, el proxy no se entera y hace commit.

💡 Consejo: Pon @Transactional en la capa de servicio, que es donde se define qué operaciones forman una unidad de negocio, no en los repositorios ni en los controladores. Y marca con readOnly = true los métodos que solo consultan.


17. Lo que hará Spring Boot por ti

Recapitula todo lo que has tenido que escribir a mano en este módulo:

En este módulo (Spring Framework) En Spring Boot (módulo 18)
Elegir y versionar cada dependencia (BOM, AspectJ, Hikari…) Un starter: spring-boot-starter-jdbc
Clase AppConfig con @ComponentScan, @EnableTransactionManagement, @EnableAspectJAutoProxy @SpringBootApplication
@Bean DataSource con HikariCP Autoconfigurado a partir de spring.datasource.url
@Bean JdbcTemplate, JdbcClient, TransactionManager Autoconfigurados
@PropertySource application.properties/.yml cargado automáticamente
new AnnotationConfigApplicationContext(...) SpringApplication.run(...)
Activar perfiles en código spring.profiles.active y ficheros application-dev.properties

Boot no sustituye a nada de lo que has aprendido: lo configura por ti siguiendo convenciones. Cuando algo no funcione como esperas, saber qué beans hay debajo (un DataSource, un JdbcTransactionManager, proxies…) es lo que te permitirá entenderlo.


18. Buenas prácticas y errores comunes

Error o síntoma Causa Solución
NoSuchBeanDefinitionException: No qualifying bean of type ... La clase no tiene estereotipo, o está fuera del paquete de @ComponentScan Anótala y revisa el paquete base
NoUniqueBeanDefinitionException: expected single matching bean but found 2 Varios candidatos del mismo tipo @Primary en uno o @Qualifier en el punto de inyección
BeanCurrentlyInCreationException (ciclo) A depende de B y B de A por constructor Rediseña (suele indicar responsabilidades mal repartidas)
Could not resolve placeholder 'db.url' Falta la propiedad o el @PropertySource Añádela o usa un valor por defecto ${db.url:...}
Texto con caracteres raros (“Gestión”) .properties leído en ISO-8859-1 @PropertySource(..., encoding = "UTF-8")
@PostConstruct no se ejecuta Falta jakarta.annotation-api en el classpath Añade la dependencia
El aspecto o @Transactional “no hacen nada” Autoinvocación, método private/final, objeto creado con new o falta @Enable... Llama desde otro bean; métodos públicos; deja que Spring cree el objeto
Datos mezclados entre usuarios Estado mutable en un singleton Beans sin estado; el estado, en variables locales o en prototype/request
No se hace rollback Excepción comprobada, o capturada dentro del método rollbackFor, o relanzar la excepción

💡 Consejo: Reglas de oro: inyección por constructor con final; beans sin estado; @Configuration + @Bean para lo ajeno y estereotipos para lo propio; configuración fuera del código (@Value, perfiles); transacciones en la capa de servicio; y deja que Spring cree los objetos (si haces new de un servicio, pierdes inyección, aspectos y transacciones).


19. Ejercicios

Cada ejercicio es una clase con su propia configuración (una clase anidada Config que escanea solo el paquete del ejercicio). Las soluciones omiten las líneas import (IntelliJ las añade con Alt+Intro). Para no repetir la infraestructura, todos importan esta configuración común con @Import:

package es.curso.empresa.ejercicios;

import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

/** Infraestructura común de los ejercicios (se importa con @Import). */
@Configuration
@PropertySource(value = "classpath:app.properties", encoding = "UTF-8")
public class BaseDatosConfig {

    @Bean(destroyMethod = "close")
    DataSource dataSource(@Value("${db.url}") String url, @Value("${db.usuario}") String usuario,
                          @Value("${db.clave}") String clave) {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        ds.setUsername(usuario);
        ds.setPassword(clave);
        ds.setMaximumPoolSize(3);
        return ds;
    }

    @Bean
    JdbcTemplate jdbcTemplate(DataSource ds) { return new JdbcTemplate(ds); }

    @Bean
    JdbcClient jdbcClient(DataSource ds) { return JdbcClient.create(ds); }

    @Bean
    PlatformTransactionManager transactionManager(DataSource ds) { return new JdbcTransactionManager(ds); }
}

Ejercicio 1: Estrategias de retención con un Map de beans

Define una interfaz Retencion con dos implementaciones: "fija" (15 %) y "tramos" (12 % por debajo de 2000 €, 18 % hasta 4000 € y 25 % a partir de ahí). Un servicio CalculadoraNomina debe recibir todas las estrategias en un Map<String, Retencion> y usar la indicada en la propiedad nomina.retencion (por defecto, tramos). Muestra el bruto, la retención y el neto de los empleados de VENTAS. Ejecútalo con y sin la propiedad.

Ver solución
public class Ejercicio1 {

    /** Estrategia de cálculo de la retención. */
    public interface Retencion {
        BigDecimal porcentaje(BigDecimal salarioMensual);
    }

    @Component("fija")
    public static class RetencionFija implements Retencion {
        public BigDecimal porcentaje(BigDecimal s) { return new BigDecimal("15"); }
    }

    @Component("tramos")
    public static class RetencionPorTramos implements Retencion {
        public BigDecimal porcentaje(BigDecimal s) {
            if (s.compareTo(new BigDecimal("2000")) < 0) return new BigDecimal("12");
            if (s.compareTo(new BigDecimal("4000")) < 0) return new BigDecimal("18");
            return new BigDecimal("25");
        }
    }

    @Service
    public static class CalculadoraNomina {
        private final Retencion retencion;
        private final JdbcClient jdbc;

        // Spring inyecta TODOS los beans Retencion en un Map: nombre del bean -> bean
        public CalculadoraNomina(Map<String, Retencion> estrategias,
                                 @Value("${nomina.retencion:tramos}") String elegida,
                                 JdbcClient jdbc) {
            this.retencion = estrategias.get(elegida);
            this.jdbc = jdbc;
            System.out.println("Estrategias disponibles: " + estrategias.keySet() + " -> uso '" + elegida + "'");
        }

        public void imprimirNominas(int deptId) {
            record Fila(String nombre, BigDecimal salario) { }
            jdbc.sql("SELECT nombre, salario FROM empleados WHERE dept_id = ? ORDER BY salario DESC")
                .param(deptId)
                .query(Fila.class)
                .list()
                .forEach(f -> {
                    BigDecimal pct = retencion.porcentaje(f.salario());
                    BigDecimal neto = f.salario().multiply(BigDecimal.ONE.subtract(pct.movePointLeft(2)))
                                       .setScale(2, RoundingMode.HALF_UP);
                    System.out.printf("  %-8s bruto %8s  IRPF %2s %%  neto %8s%n", f.nombre(), f.salario(), pct, neto);
                });
        }
    }

    @Configuration
    @ComponentScan                       // escanea el paquete de esta clase (ej1)
    @Import(BaseDatosConfig.class)
    static class Config { }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext()) {
            if (args.length > 0) {       // permite elegir la estrategia: -> nomina.retencion=fija
                ctx.getEnvironment().getSystemProperties().put("nomina.retencion", args[0]);
            }
            ctx.register(Config.class);
            ctx.refresh();
            ctx.getBean(CalculadoraNomina.class).imprimirNominas(20);
        }
    }
}

Salida real sin argumentos:

Estrategias disponibles: [fija, tramos] -> uso 'tramos'
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT nombre, salario FROM empleados WHERE dept_id = ? ORDER BY salario DESC]
  Luis     bruto  4800.00  IRPF 25 %  neto  3600.00
  Elena    bruto  2300.00  IRPF 18 %  neto  1886.00
  Carmen   bruto  2200.00  IRPF 18 %  neto  1804.00
  David    bruto  2100.00  IRPF 18 %  neto  1722.00
  Sergio   bruto  1900.00  IRPF 12 %  neto  1672.00
  Lucía    bruto  1700.00  IRPF 12 %  neto  1496.00

Y con el argumento fija:

Estrategias disponibles: [fija, tramos] -> uso 'fija'
...
  Luis     bruto  4800.00  IRPF 15 %  neto  4080.00
  Elena    bruto  2300.00  IRPF 15 %  neto  1955.00
  ...

Añadir una tercera estrategia es crear otra clase @Component("..."): ni CalculadoraNomina ni la configuración cambian (principio abierto/cerrado del módulo 10). El record local Fila sirve de destino para JdbcClient.query(Fila.class). (Los porcentajes son un ejemplo didáctico, no los del IRPF real.)

Ejercicio 2: @Bean para clases de terceros

NumberFormat, Locale y DateTimeFormatter son clases del JDK: no puedes anotarlas. Crea una configuración con tres @Bean encadenados: un Locale (propiedad app.locale, por defecto es-ES), un NumberFormat de moneda que dependa del Locale y un record Formateador que use ambos y el IVA de app.iva. Muestra con él el precio con IVA de dos productos y una fecha larga, y comprueba que el NumberFormat del formateador es el mismo bean que devuelve el contexto.

Ver solución
public class Ejercicio2 {

    /** Clase de una biblioteca ajena: no podemos anotarla con @Component. */
    public record Formateador(NumberFormat moneda, DateTimeFormatter fecha, BigDecimal iva) {
        String precioConIva(BigDecimal neto) {
            return moneda.format(neto.multiply(BigDecimal.ONE.add(iva.movePointLeft(2))));
        }
        String fecha(LocalDate f) { return fecha.format(f); }
    }

    @Configuration
    @PropertySource(value = "classpath:app.properties", encoding = "UTF-8")
    static class Config {

        @Bean
        Locale localeEmpresa(@Value("${app.locale:es-ES}") String etiqueta) {
            return Locale.forLanguageTag(etiqueta);
        }

        @Bean
        NumberFormat formatoMoneda(Locale localeEmpresa) {          // depende de otro @Bean
            return NumberFormat.getCurrencyInstance(localeEmpresa);
        }

        @Bean
        Formateador formateador(NumberFormat formatoMoneda, Locale localeEmpresa,
                                @Value("${app.iva}") BigDecimal iva) {
            return new Formateador(formatoMoneda,
                    DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(localeEmpresa), iva);
        }
    }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
            Formateador f = ctx.getBean(Formateador.class);
            System.out.println("Locale: " + ctx.getBean(Locale.class));
            System.out.println("Portátil UltraBook 14: " + f.precioConIva(new BigDecimal("899.00")));
            System.out.println("Monitor 27 4K:        " + f.precioConIva(new BigDecimal("329.00")));
            System.out.println("Fecha del pedido 5022: " + f.fecha(LocalDate.of(2026, 7, 1)));
            System.out.println("¿Un solo NumberFormat? "
                    + (f.moneda() == ctx.getBean(NumberFormat.class)));
        }
    }
}

Salida real:

Locale: es_ES
Portátil UltraBook 14: 1.087,79 €
Monitor 27 4K:        398,09 €
Fecha del pedido 5022: 1 de julio de 2026
¿Un solo NumberFormat? true

Los parámetros de un método @Bean se inyectan igual que los de un constructor, así que el orden de creación lo deduce Spring (primero el Locale, luego el NumberFormat y por último el Formateador). @Value convierte "21" en BigDecimal automáticamente. El formato de moneda y de fecha en español los aporta el JDK (módulo 05).

Ejercicio 3: Aspecto de auditoría y la trampa de la autoinvocación

Escribe un servicio CreditoService con limite(clienteId) (lanza IllegalArgumentException si el cliente no existe) y puedeComprar(clienteId, importe), que llama internamente a limite. Crea un aspecto con @AfterReturning (registra método, argumentos y resultado) y @AfterThrowing, que además cuente las llamadas y muestre un resumen en su @PreDestroy. Llama a limite(1002), puedeComprar(1003, 2500) y limite(9999). ¿Cuántas veces se audita limite?

Ver solución
public class Ejercicio3 {

    @Service
    public static class CreditoService {
        private final JdbcClient jdbc;
        public CreditoService(JdbcClient jdbc) { this.jdbc = jdbc; }

        public BigDecimal limite(int clienteId) {
            return jdbc.sql("SELECT limite_credito FROM clientes WHERE cliente_id = ?")
                       .param(clienteId).query(BigDecimal.class).optional()
                       .orElseThrow(() -> new IllegalArgumentException("No existe el cliente " + clienteId));
        }

        public boolean puedeComprar(int clienteId, BigDecimal importe) {
            return limite(clienteId).compareTo(importe) >= 0;    // llamada interna: ¡no pasa por el proxy!
        }
    }

    @Aspect
    @Component
    public static class AuditoriaAspect {
        private final Map<String, Integer> llamadas = new TreeMap<>();

        @AfterReturning(pointcut = "within(es.curso.empresa.ejercicios.ej3..*) && execution(public * *..CreditoService.*(..))",
                        returning = "resultado")
        public void exito(JoinPoint jp, Object resultado) {
            llamadas.merge(jp.getSignature().getName() + " OK", 1, Integer::sum);
            System.out.println("[AUDIT] " + jp.getSignature().getName() + java.util.Arrays.toString(jp.getArgs())
                    + " = " + resultado);
        }

        @AfterThrowing(pointcut = "execution(public * *..CreditoService.*(..))", throwing = "ex")
        public void fallo(JoinPoint jp, RuntimeException ex) {
            llamadas.merge(jp.getSignature().getName() + " ERROR", 1, Integer::sum);
            System.out.println("[AUDIT] " + jp.getSignature().getName() + " falló: " + ex.getMessage());
        }

        @PreDestroy
        void resumen() { System.out.println("[AUDIT] Resumen: " + llamadas); }
    }

    @Configuration
    @ComponentScan
    @EnableAspectJAutoProxy
    @Import(BaseDatosConfig.class)
    static class Config { }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
            CreditoService s = ctx.getBean(CreditoService.class);
            s.limite(1002);
            s.puedeComprar(1003, new BigDecimal("2500"));
            try {
                s.limite(9999);
            } catch (IllegalArgumentException e) {
                System.out.println("El programa recibe: " + e.getMessage());
            }
        }
    }
}

Salida real:

DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT limite_credito FROM clientes WHERE cliente_id = ?]
[AUDIT] limite[1002] = 25000.00
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT limite_credito FROM clientes WHERE cliente_id = ?]
[AUDIT] puedeComprar[1003, 2500] = false
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT limite_credito FROM clientes WHERE cliente_id = ?]
[AUDIT] limite falló: No existe el cliente 9999
El programa recibe: No existe el cliente 9999
[AUDIT] Resumen: {limite ERROR=1, limite OK=1, puedeComprar OK=1}

Aunque limite se ha ejecutado tres veces (se ven tres consultas), el aspecto solo la ha auditado dos: la llamada interna desde puedeComprar (this.limite(...)) no pasa por el proxy. Es la misma razón por la que @Transactional no funciona en la autoinvocación. @AfterThrowing registra el error, pero la excepción sigue su camino hasta el main. El resumen sale al cerrar el contexto, gracias a @PreDestroy en el propio aspecto (que también es un bean).

Ejercicio 4: Repositorio de clientes con JdbcTemplate

Escribe un ClienteRepository con porProvincia(String) (con RowMapper a un record), contarPorProvincia() (las cuatro provincias con más clientes en un Map, usando un RowCallbackHandler), insertar(Cliente) que devuelva el id AUTO_INCREMENT generado (con KeyHolder) y borrar(int). Pruébalo insertando y borrando un cliente de Madrid.

Ver solución
public class Ejercicio4 {

    public record Cliente(Integer id, String nombre, String nif, String ciudad, String provincia,
                          BigDecimal limiteCredito) { }

    @Repository
    public static class ClienteRepository {
        private final JdbcTemplate jdbc;
        public ClienteRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }

        public List<Cliente> porProvincia(String provincia) {
            return jdbc.query("""
                    SELECT cliente_id, nombre, nif, ciudad, provincia, limite_credito
                    FROM clientes WHERE provincia = ? ORDER BY nombre""",
                    (rs, n) -> new Cliente(rs.getInt(1), rs.getString(2), rs.getString(3),
                            rs.getString(4), rs.getString(5), rs.getBigDecimal(6)),
                    provincia);
        }

        public Map<String, Integer> contarPorProvincia() {
            Map<String, Integer> mapa = new LinkedHashMap<>();
            jdbc.query("SELECT provincia, COUNT(*) FROM clientes GROUP BY provincia ORDER BY 2 DESC, 1 LIMIT 4",
                    rs -> { mapa.put(rs.getString(1), rs.getInt(2)); });      // RowCallbackHandler
            return mapa;
        }

        /** INSERT que devuelve la clave AUTO_INCREMENT generada. */
        public int insertar(Cliente c) {
            KeyHolder clave = new GeneratedKeyHolder();
            jdbc.update(con -> {
                PreparedStatement ps = con.prepareStatement("""
                        INSERT INTO clientes (nombre, nif, ciudad, provincia, limite_credito)
                        VALUES (?, ?, ?, ?, ?)""", Statement.RETURN_GENERATED_KEYS);
                ps.setString(1, c.nombre());
                ps.setString(2, c.nif());
                ps.setString(3, c.ciudad());
                ps.setString(4, c.provincia());
                ps.setBigDecimal(5, c.limiteCredito());
                return ps;
            }, clave);
            return clave.getKey().intValue();
        }

        public boolean borrar(int id) {
            return jdbc.update("DELETE FROM clientes WHERE cliente_id = ?", id) == 1;
        }
    }

    @Configuration
    @ComponentScan
    @Import(BaseDatosConfig.class)
    static class Config { }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
            ClienteRepository repo = ctx.getBean(ClienteRepository.class);
            System.out.println("Provincias con más clientes: " + repo.contarPorProvincia());
            repo.porProvincia("Madrid").forEach(c -> System.out.println("  " + c));

            int id = repo.insertar(new Cliente(null, "Óptica Visión Clara", "B55667788",
                    "Getafe", "Madrid", new BigDecimal("4500")));
            System.out.println("Nuevo cliente con id " + id + "; Madrid tiene ahora "
                    + repo.porProvincia("Madrid").size() + " clientes");
            System.out.println("Borrado: " + repo.borrar(id));
        }
    }
}

Salida real (sin las líneas DEBUG de JdbcTemplate):

Provincias con más clientes: {Madrid=2, Sevilla=2, Valencia=2, Alicante=1}
  Cliente[id=1004, nombre=Academia Nova, nif=B34567890, ciudad=Madrid, provincia=Madrid, limiteCredito=8000.00]
  Cliente[id=1003, nombre=María José Herrera, nif=12345678Z, ciudad=Madrid, provincia=Madrid, limiteCredito=2000.00]
Nuevo cliente con id 1017; Madrid tiene ahora 3 clientes
Borrado: true

Compara este repositorio con el DAO JDBC del módulo 13: no hay ni un try, ni un close, ni un SQLException. El INSERT no indica fecha_alta, así que MySQL aplica su valor por defecto (CURRENT_DATE): con JDBC solo se envían las columnas que escribes (a diferencia de Hibernate, que envía todas las mapeadas). El id depende del AUTO_INCREMENT de tu base de datos.

Ejercicio 5: Pedido transaccional con control de stock

Con JdbcTemplate y @Transactional, escribe crearPedido(clienteId, empleadoId, Map<productoId, cantidad>): inserta el pedido, y para cada producto descuenta el stock con UPDATE productos SET stock = stock - ? WHERE producto_id = ? AND stock >= ? (si no se actualiza ninguna fila, lanza SinStockException) e inserta la línea con el precio actual. Comprueba que un pedido con un producto agotado (el 8, Pack teclado y ratón) no deja rastro: ni pedido, ni líneas, ni stock descontado de los demás productos. Escribe también anular(pedidoId), que devuelva el stock y borre el pedido.

Ver solución
public class Ejercicio5 {

    public static class SinStockException extends RuntimeException {
        public SinStockException(String m) { super(m); }
    }

    @Service
    public static class PedidoService {
        private final JdbcTemplate jdbc;
        public PedidoService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

        /** Crea el pedido, sus líneas y descuenta el stock: todo o nada. */
        @Transactional
        public int crearPedido(int clienteId, int empleadoId, Map<Integer, Integer> productos) {
            var clave = new GeneratedKeyHolder();
            jdbc.update(con -> {
                PreparedStatement ps = con.prepareStatement(
                        "INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido) VALUES (?, ?, ?)",
                        Statement.RETURN_GENERATED_KEYS);
                ps.setInt(1, clienteId);
                ps.setInt(2, empleadoId);
                ps.setObject(3, LocalDate.of(2026, 10, 4));
                return ps;
            }, clave);
            int pedidoId = clave.getKey().intValue();

            int linea = 1;
            for (var e : productos.entrySet()) {
                int producto = e.getKey(), cantidad = e.getValue();
                // Descuento atómico: solo si queda stock suficiente
                int filas = jdbc.update(
                        "UPDATE productos SET stock = stock - ? WHERE producto_id = ? AND stock >= ?",
                        cantidad, producto, cantidad);
                if (filas == 0) {
                    throw new SinStockException("No hay " + cantidad + " uds. del producto " + producto);
                }
                BigDecimal precio = jdbc.queryForObject(
                        "SELECT precio FROM productos WHERE producto_id = ?", BigDecimal.class, producto);
                jdbc.update("""
                        INSERT INTO lineas_pedido (pedido_id, num_linea, producto_id, cantidad, precio_unitario)
                        VALUES (?, ?, ?, ?, ?)""", pedidoId, linea++, producto, cantidad, precio);
            }
            return pedidoId;
        }

        @Transactional
        public void anular(int pedidoId) {          // devuelve el stock y borra el pedido
            jdbc.update("""
                    UPDATE productos p JOIN lineas_pedido l ON l.producto_id = p.producto_id
                    SET p.stock = p.stock + l.cantidad WHERE l.pedido_id = ?""", pedidoId);
            jdbc.update("DELETE FROM pedidos WHERE pedido_id = ?", pedidoId);   // las líneas, en cascada
        }

        public String estado() {
            return "pedidos=" + jdbc.queryForObject("SELECT COUNT(*) FROM pedidos", Integer.class)
                    + ", stock(6)=" + jdbc.queryForObject("SELECT stock FROM productos WHERE producto_id = 6", Integer.class)
                    + ", stock(8)=" + jdbc.queryForObject("SELECT stock FROM productos WHERE producto_id = 8", Integer.class);
        }
    }

    @Configuration
    @ComponentScan
    @EnableTransactionManagement
    @Import(BaseDatosConfig.class)
    static class Config { }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
            PedidoService s = ctx.getBean(PedidoService.class);
            System.out.println("Inicio:          " + s.estado());

            Map<Integer, Integer> bien = new LinkedHashMap<>();
            bien.put(6, 3);                    // 3 ratones (hay 200)
            int id = s.crearPedido(1004, 111, bien);
            System.out.println("Pedido " + id + " creado: " + s.estado());

            Map<Integer, Integer> mal = new LinkedHashMap<>();
            mal.put(6, 2);                     // esta línea sí se podría servir...
            mal.put(8, 1);                     // ...pero del pack 8 no queda stock
            try {
                s.crearPedido(1004, 111, mal);
            } catch (SinStockException e) {
                System.out.println("Rechazado (" + e.getMessage() + "): " + s.estado());
            }

            s.anular(id);
            System.out.println("Tras anular " + id + ":  " + s.estado());
        }
    }
}

Salida real (solo las líneas del programa y las de la transacción rechazada):

Inicio:          pedidos=24, stock(6)=200, stock(8)=0
Pedido 5027 creado: pedidos=25, stock(6)=197, stock(8)=0
DEBUG JdbcTransactionManager - Creating new transaction with name [es.curso.empresa.ejercicios.ej5.Ejercicio5$PedidoService.crearPedido]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...
DEBUG JdbcTemplate - Executing SQL update and returning generated keys
DEBUG JdbcTemplate - Executing prepared SQL statement
DEBUG JdbcTemplate - Executing prepared SQL update
DEBUG JdbcTemplate - Executing prepared SQL statement [UPDATE productos SET stock = stock - ? WHERE producto_id = ? AND stock >= ?]
DEBUG JdbcTemplate - Executing prepared SQL query
DEBUG JdbcTemplate - Executing prepared SQL statement [SELECT precio FROM productos WHERE producto_id = ?]
DEBUG JdbcTemplate - Executing prepared SQL update
DEBUG JdbcTemplate - Executing prepared SQL statement [INSERT INTO lineas_pedido (pedido_id, num_linea, producto_id, cantidad, precio_unitario)
VALUES (?, ?, ?, ?, ?)]
DEBUG JdbcTemplate - Executing prepared SQL update
DEBUG JdbcTemplate - Executing prepared SQL statement [UPDATE productos SET stock = stock - ? WHERE producto_id = ? AND stock >= ?]
DEBUG JdbcTransactionManager - Initiating transaction rollback
DEBUG JdbcTransactionManager - Rolling back JDBC transaction on Connection [...]
...
Rechazado (No hay 1 uds. del producto 8): pedidos=25, stock(6)=197, stock(8)=0
Tras anular 5027:  pedidos=24, stock(6)=200, stock(8)=0

En el pedido rechazado se llegaron a ejecutar el INSERT del pedido, el descuento de 2 ratones y el INSERT de su línea; al no poder descontar el producto 8 (el UPDATE afecta a 0 filas), la excepción provoca el rollback y todo se deshace: sigue habiendo 25 pedidos (el 5027, que sí se confirmó) y 197 ratones. El UPDATE ... AND stock >= ? es atómico: aunque dos usuarios compren a la vez, MySQL nunca dejará el stock en negativo (compáralo con el bloqueo optimista del módulo 16). anular devuelve el stock con un UPDATE ... JOIN y borra el pedido; las líneas desaparecen por el ON DELETE CASCADE de la tabla.

Ejercicio 6: Perfiles y eventos en un exportador de informes

Crea una interfaz Exportador con dos implementaciones: ExportadorCsv (activa con el perfil csv) y ExportadorTexto (activa cuando no está el perfil csv). Un InformeService genera el listado de departamentos con su número de empleados usando el exportador que toque y publica un evento InformeGenerado. Dos listeners ordenados con @Order reaccionan al evento. Ejecútalo sin perfil y con el perfil csv.

Ver solución
public class Ejercicio6 {

    public record Departamento(String nombre, String localidad, long empleados) { }
    public record InformeGenerado(String formato, int lineas) { }

    public interface Exportador {
        String exportar(List<Departamento> datos);
    }

    @Component
    @Profile("csv")
    public static class ExportadorCsv implements Exportador {
        public String exportar(List<Departamento> d) {
            return "nombre;localidad;empleados\n" + d.stream()
                    .map(x -> x.nombre() + ";" + x.localidad() + ";" + x.empleados())
                    .collect(Collectors.joining("\n"));
        }
    }

    @Component
    @Profile("!csv")                         // cualquier caso en que NO esté activo csv
    public static class ExportadorTexto implements Exportador {
        public String exportar(List<Departamento> d) {
            return d.stream().map(x -> String.format("%-14s %-10s %2d", x.nombre(), x.localidad(), x.empleados()))
                    .collect(Collectors.joining("\n"));
        }
    }

    @Service
    public static class InformeService {
        private final JdbcClient jdbc;
        private final Exportador exportador;
        private final ApplicationEventPublisher eventos;

        public InformeService(JdbcClient jdbc, Exportador exportador, ApplicationEventPublisher eventos) {
            this.jdbc = jdbc;
            this.exportador = exportador;
            this.eventos = eventos;
        }

        public String generar() {
            List<Departamento> datos = jdbc.sql("""
                    SELECT d.nombre, d.localidad, COUNT(e.emp_id) AS empleados
                    FROM departamentos d LEFT JOIN empleados e ON e.dept_id = d.dept_id
                    GROUP BY d.nombre, d.localidad ORDER BY d.nombre""")
                    .query(Departamento.class).list();
            String salida = exportador.exportar(datos);
            eventos.publishEvent(new InformeGenerado(exportador.getClass().getSimpleName(), datos.size()));
            return salida;
        }
    }

    @Component
    public static class Oyentes {
        @EventListener @Order(1)
        public void registrar(InformeGenerado e) {
            System.out.println("[1] Informe generado con " + e.formato() + " (" + e.lineas() + " filas)");
        }
        @EventListener @Order(2)
        public void avisar(InformeGenerado e) {
            System.out.println("[2] Aviso enviado a dirección");
        }
    }

    @Configuration
    @ComponentScan
    @Import(BaseDatosConfig.class)
    static class Config { }

    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext()) {
            if (args.length > 0) ctx.getEnvironment().setActiveProfiles(args[0]);
            ctx.register(Config.class);
            ctx.refresh();
            System.out.println(ctx.getBean(InformeService.class).generar());
        }
    }
}

Salida real sin perfil (sin las líneas DEBUG):

[1] Informe generado con ExportadorTexto (7 filas)
[2] Aviso enviado a dirección
CONTABILIDAD   Sevilla     3
DIRECCIÓN      Madrid      1
INFORMÁTICA    Valencia    6
INVESTIGACIÓN  Bilbao      0
LOGÍSTICA      Zaragoza    4
MARKETING      Barcelona   3
VENTAS         Madrid      6

Y con el perfil csv:

[1] Informe generado con ExportadorCsv (7 filas)
[2] Aviso enviado a dirección
nombre;localidad;empleados
CONTABILIDAD;Sevilla;3
DIRECCIÓN;Madrid;1
INFORMÁTICA;Valencia;6
INVESTIGACIÓN;Bilbao;0
LOGÍSTICA;Zaragoza;4
MARKETING;Barcelona;3
VENTAS;Madrid;6

Con cada perfil solo existe un Exportador, así que InformeService no necesita @Qualifier. Los mensajes de los listeners salen antes del informe porque los eventos son síncronos: se ejecutan dentro de publishEvent, antes de que generar() devuelva el texto, y en el orden fijado por @Order.

Ejercicios propuestos

Ejercicio 7: Del DAO del módulo 13 a Spring

Toma el DepartamentoDao JDBC que escribiste en el módulo 13 y conviértelo en un @Repository con JdbcClient. Crea un DepartamentoService con @Transactional que traslade un proyecto de un departamento a otro comprobando que el destino existe. Escribe una prueba unitaria del servicio con Mockito (sin arrancar Spring) y explica por qué es tan fácil gracias a la inyección por constructor.

Ejercicio 8: @Retryable de Spring 7

Investiga en la documentación de Spring Framework 7 la anotación @Retryable (paquete org.springframework.resilience.annotation) y cómo se activa con @EnableResilientMethods. Simula un servicio de cambio de divisas que falla las dos primeras veces y comprueba que el reintento automático lo resuelve. ¿Qué técnica de las vistas en este módulo usa Spring para implementarlo?

Ejercicio 9: Ámbito prototype con estado

Crea un bean prototype GeneradorInforme que acumule líneas y un singleton ServicioInformes que, para cada departamento, obtenga un generador nuevo con ObjectProvider y produzca su informe. Demuestra qué pasaría si inyectaras el GeneradorInforme directamente en el constructor del singleton.

Ejercicio 10: Aspecto de rendimiento configurable

Mejora RendimientoAspect para que solo escriba los métodos que tarden más de un umbral leído de la propiedad app.umbral-ms (por defecto 100), usando @Value en el propio aspecto. Añade un atributo nombre a la anotación @Medir y muéstralo en el mensaje (pista: un parámetro Medir medir en el advice con @Around("@annotation(medir)")).

Ejercicio 11: Eventos después del commit

Sustituye el @EventListener de la subida salarial por un @TransactionalEventListener y comprueba, con la subida que provoca rollback del ejemplo 07, que el listener ya no se ejecuta cuando la transacción se deshace. ¿Qué pasaría con un correo de notificación si usaras un @EventListener normal?

Ejercicio 12: Configuración por entornos

Crea dos ficheros app-dev.properties y app-prod.properties con distinto tamaño de pool y nombre de aplicación, y una configuración que cargue el adecuado según el perfil activo (pista: @PropertySource("classpath:app-${spring.profiles.active:dev}.properties")). Arranca la aplicación con -Dspring.profiles.active=prod y muestra los valores efectivos. Compara después con cómo lo resuelve Spring Boot en el módulo 18.


Resumen

Concepto Sintaxis / clave
IoC El contenedor crea y conecta los objetos, no tus clases
DI Las dependencias se reciben (constructor, setter, campo)
Contenedor new AnnotationConfigApplicationContext(AppConfig.class), getBean(...), close()
Configuración Java @Configuration + @Bean (para clases ajenas)
Escaneo @ComponentScan + @Component, @Service, @Repository, @Controller
Inyección recomendada Constructor con atributos final (@Autowired implícito con un solo constructor)
Ambigüedad @Primary, @Qualifier("nombre"), List<T>, Map<String, T>
Ámbitos singleton (defecto), prototype, request, session; ObjectProvider<T>
Ciclo de vida Constructor → inyección → @PostConstruct → proxies → uso → @PreDestroy
Propiedades @PropertySource(..., encoding = "UTF-8"), @Value("${clave:defecto}"), Environment
Perfiles @Profile("dev"), "!prod", setActiveProfiles, -Dspring.profiles.active
Eventos ApplicationEventPublisher.publishEvent(obj), @EventListener, @Order, @TransactionalEventListener
AOP @EnableAspectJAutoProxy, @Aspect, @Pointcut, @Before, @AfterReturning, @AfterThrowing, @After, @Around
JDBC JdbcTemplate (query, queryForObject, update, KeyHolder), JdbcClient (sql().param().query(Clase.class))
Transacciones @EnableTransactionManagement, JdbcTransactionManager, @Transactional(readOnly, rollbackFor, propagation)
Trampas Autoinvocación, métodos private/final, excepciones comprobadas, estado en singletons