En la lección anterior escribimos public EstacionService(EstacionRepositorio estacionRepositorio) y Spring encontró por sí solo la implementación correcta. Esa capacidad —construir un objeto entregándole sus colaboradores ya listos— es el corazón del contenedor y la razón por la que Spring existe. En esta lección desmontaremos el mecanismo: qué es la inversión de control, qué problema real resuelve, cuáles son los tres estilos de inyección y por qué solo uno es defendible, cómo resuelve Spring un tipo cuando hay varias implementaciones candidatas, cómo inyectar todas ellas de golpe en una List o un Map, qué hacer cuando una dependencia es opcional y cómo salir de una dependencia circular sin recurrir a parches. Todo ello construyendo la primera pieza del sistema de tarifas de la red de Ribalta.

Contenido

  1. Inversión de control e inyección de dependencias
  2. El antes y el después: acoplamiento frente a inyección
  3. Los tres tipos de inyección
  4. Por qué la inyección por constructor es la única recomendable
  5. Cómo resuelve Spring las dependencias
  6. Varias implementaciones del mismo tipo: @Primary y @Qualifier
  7. Inyectar todas las implementaciones: List y Map
  8. Dependencias opcionales: Optional y ObjectProvider
  9. Dependencias circulares
  10. Errores Comunes y Consejos
  11. Ejercicios

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

Son dos conceptos relacionados pero distintos, y conviene no mezclarlos.

Inversión de control (IoC) es un principio general: en lugar de que tu código controle el flujo y decida cuándo se crea cada cosa, es un framework el que controla el flujo y llama a tu código cuando toca. Cuando escribes un CommandLineRunner, tú no lo invocas: Spring lo hace. Cuando escribes un @GetMapping, tú no llamas al método: lo llama el DispatcherServlet. Eso es inversión de control.

Inyección de dependencias (DI) es una forma concreta de aplicar IoC a la construcción de objetos: una clase declara qué colaboradores necesita, pero no los crea; alguien externo —el contenedor— se los entrega ya construidos.

flowchart LR
    subgraph SIN["Sin inyección"]
        A1["EstacionService"] -->|new| B1["EstacionRepositorioEnMemoria"]
    end
    subgraph CON["Con inyección"]
        C["ApplicationContext"] -->|construye| B2["EstacionRepositorioEnMemoria"]
        C -->|construye e inyecta| A2["EstacionService"]
        A2 -.->|depende de la interfaz| I["EstacionRepositorio"]
    end

El contenedor de Spring, el ApplicationContext, es un inyector de dependencias: mantiene un catálogo de beans y sabe cómo satisfacer lo que cada uno pide.

  1. El antes y el después: acoplamiento frente a inyección

Veámoslo con la pieza que vamos a construir en esta lección: el cálculo de la tarifa de un alquiler en Ribalta.

Antes: la dependencia se crea dentro

package com.ciclourbana.alquileres;

import java.math.BigDecimal;
import java.time.Duration;

public class AlquilerService {

    // La dependencia se instancia aquí dentro
    private final TarifaEstandar calculadora = new TarifaEstandar();

    public BigDecimal finalizar(String matricula, Duration duracion) {
        return calculadora.calcular(duracion);
    }
}

Funciona. Y sin embargo tiene cuatro problemas graves:

  1. Acoplamiento a una implementación concreta. AlquilerService no depende de "una forma de calcular tarifas": depende de TarifaEstandar. Añadir la tarifa de estudiante obliga a modificar esta clase.
  2. Imposible de probar de forma aislada. No hay manera de sustituir la calculadora por una controlada en un test. Cualquier prueba de AlquilerService prueba también, inevitablemente, TarifaEstandar.
  3. Configuración enterrada. Si TarifaEstandar necesitara leer el precio por minuto de la configuración, AlquilerService tendría que saber cómo construirla con esos parámetros. La responsabilidad se contagia.
  4. Ciclo de vida descontrolado. Cada AlquilerService crea su propia calculadora. Si hubiera diez servicios, habría diez calculadoras idénticas ocupando memoria.

Después: la dependencia se recibe

package com.ciclourbana.alquileres;

import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;

@Service
public class AlquilerService {

    // Depende de la abstracción, no de una implementación
    private final CalculadoraTarifa calculadora;

    // Spring entrega el colaborador ya construido
    public AlquilerService(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }

    public BigDecimal finalizar(String matricula, Duration duracion) {
        return calculadora.calcular(duracion);
    }
}

Los cuatro problemas desaparecen:

Problema Cómo se resuelve
Acoplamiento AlquilerService solo conoce la interfaz CalculadoraTarifa.
Pruebas En un test basta con new AlquilerService(duracion -> new BigDecimal("2.50")). Sin Spring, sin base de datos, sin nada.
Configuración Quien construye TarifaEstandar es el contenedor, que sabe inyectarle sus propiedades.
Ciclo de vida Hay una instancia compartida, gestionada por Spring.

El segundo punto merece énfasis: la testabilidad no es un beneficio secundario de la inyección de dependencias, es prácticamente su razón de ser. Cuando en el módulo 6 escribamos pruebas con Mockito, sustituir colaboradores será trivial precisamente por este diseño.

  1. Los tres tipos de inyección

Spring soporta tres mecanismos. Los vemos con la misma clase para poder compararlos.

Inyección por constructor (recomendada)

@Service
public class AlquilerService {

    private final CalculadoraTarifa calculadora;
    private final EstacionService estacionService;

    // Desde Spring 4.3, @Autowired es innecesario si hay UN solo constructor
    public AlquilerService(CalculadoraTarifa calculadora, EstacionService estacionService) {
        this.calculadora = calculadora;
        this.estacionService = estacionService;
    }
}

Inyección por setter

@Service
public class AlquilerService {

    private CalculadoraTarifa calculadora;   // no puede ser final

    @Autowired
    public void setCalculadora(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }
}

Inyección por campo

@Service
public class AlquilerService {

    @Autowired
    private CalculadoraTarifa calculadora;   // Spring lo asigna por reflexión
}

Comparadas:

Criterio Constructor Setter Campo
Permite final (inmutabilidad) Sí No No
Objeto siempre en estado válido Sí No (existe un instante sin dependencia) No
Detecta dependencias faltantes En el arranque En el arranque En el arranque
Detecta dependencias circulares En el arranque, con error claro Las oculta Las oculta
Instanciable con new en un test Sí Sí, con setters extra No (requiere reflexión o Spring)
Requiere @Autowired No (con un constructor) Sí Sí
Hace visible el exceso de dependencias Sí: el constructor crece y molesta Poco No: da igual tener 15 campos
Admite dependencias opcionales Con @Nullable/ObjectProvider Sí, natural Con required = false
Recomendación oficial de Spring Sí Casos opcionales Desaconsejada

  1. Por qué la inyección por constructor es la única recomendable

Cinco argumentos, en orden de importancia:

1. Inmutabilidad real. Solo el constructor permite declarar los campos final. Un campo final no puede reasignarse por accidente ni por una llamada concurrente, y el compilador garantiza que está asignado. Con setters o campos, cualquier código puede cambiar la dependencia en caliente.

2. Un objeto nunca existe a medio construir. Con inyección por constructor, si el objeto existe, sus dependencias están puestas. Con setter, hay una ventana entre new AlquilerService() y setCalculadora(...) en la que el objeto es una bomba de NullPointerException.

3. Fallo temprano y legible. Si falta un bean, Spring falla en el arranque con un mensaje explícito, no en producción a las tres de la mañana:

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.ciclourbana.alquileres.AlquilerService
required a bean of type 'com.ciclourbana.alquileres.CalculadoraTarifa'
that could not be found.

Action:

Consider defining a bean of type 'com.ciclourbana.alquileres.CalculadoraTarifa'
in your configuration.

4. Testabilidad sin framework. Esta es la diferencia práctica más notable:

// Con inyección por constructor: una línea, sin Spring
var servicio = new AlquilerService(duracion -> new BigDecimal("1.80"), estacionService);

// Con inyección por campo: no hay forma limpia.
// Hace falta ReflectionTestUtils o levantar el contexto entero
ReflectionTestUtils.setField(servicio, "calculadora", calculadoraFalsa);

5. La presión de diseño es sana. Un constructor con ocho parámetros da vergüenza escribirlo, y esa vergüenza es información: la clase hace demasiado y debe partirse. Con inyección por campo, ocho @Autowired no molestan visualmente y la clase crece sin límite. Es el peor efecto de la inyección por campo, y es cultural, no técnico.

Desde Spring 4.3, si la clase tiene un único constructor, @Autowired es opcional. En Spring Boot 3 la práctica universal es omitirlo. Con dos o más constructores sí hay que marcar cuál usar:

@Service
public class AlquilerService {

    private final CalculadoraTarifa calculadora;

    @Autowired   // necesario: hay más de un constructor
    public AlquilerService(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }

    // Constructor auxiliar para pruebas manuales
    public AlquilerService() {
        this(duracion -> BigDecimal.ZERO);
    }
}

  1. Cómo resuelve Spring las dependencias

El algoritmo, en orden:

flowchart TD
    A["Parámetro de constructor:<br/>CalculadoraTarifa calculadora"] --> B["Buscar beans asignables<br/>a ese tipo"]
    B --> C{"¿Cuántos hay?"}
    C -- "0" --> D["¿Es opcional?"]
    D -- No --> E["NoSuchBeanDefinitionException<br/>Fallo de arranque"]
    D -- "Sí (Optional/@Nullable)" --> F["Inyecta null o Optional.empty()"]
    C -- "1" --> G["Se inyecta ese bean"]
    C -- "2 o más" --> H{"¿Hay @Qualifier<br/>en el punto de inyección?"}
    H -- Sí --> I["Se inyecta el bean con ese nombre/cualificador"]
    H -- No --> J{"¿Hay un bean @Primary?"}
    J -- Sí --> K["Se inyecta el @Primary"]
    J -- No --> L{"¿El nombre del parámetro<br/>coincide con un nombre de bean?"}
    L -- Sí --> M["Se inyecta por coincidencia de nombre"]
    L -- No --> N["NoUniqueBeanDefinitionException<br/>Fallo de arranque"]

Los dos criterios fundamentales son, por este orden: por tipo primero, por nombre como desempate. Mucha gente cree que Spring inyecta por nombre de variable; solo lo hace como último recurso, y depender de ello es frágil (basta con renombrar el parámetro para romperlo).

  1. Varias implementaciones del mismo tipo: @Primary y @Qualifier

Llega el momento de construir el sistema de tarifas de Ribalta. Empezamos por el contrato, en el nuevo paquete com.ciclourbana.alquileres:

package com.ciclourbana.alquileres;

import java.math.BigDecimal;
import java.time.Duration;

/**
 * Contrato de cálculo del importe de un alquiler de la red de Ribalta.
 * Las tarifas concretas (estándar, estudiante, y las que vengan)
 * son implementaciones distintas de esta interfaz.
 */
public interface CalculadoraTarifa {

    /** Importe en euros de un alquiler de la duración indicada. */
    BigDecimal calcular(Duration duracion);

    /** Identificador legible de la tarifa, para logs y facturas. */
    default String nombre() {
        return getClass().getSimpleName();
    }
}

Y dos implementaciones. La estándar:

package com.ciclourbana.alquileres;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

/**
 * Tarifa general de Ribalta: 0,50 € de desbloqueo + 0,12 €/minuto.
 * Los importes se fijarán por configuración en la lección 02-05.
 */
@Component
@Primary   // es la tarifa por defecto de la red
public class TarifaEstandar implements CalculadoraTarifa {

    private static final BigDecimal DESBLOQUEO = new BigDecimal("0.50");
    private static final BigDecimal POR_MINUTO = new BigDecimal("0.12");

    @Override
    public BigDecimal calcular(Duration duracion) {
        BigDecimal minutos = BigDecimal.valueOf(Math.max(1, duracion.toMinutes()));
        return DESBLOQUEO
                .add(POR_MINUTO.multiply(minutos))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nombre() {
        return "estandar";
    }
}

Y la de estudiante:

package com.ciclourbana.alquileres;

import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

/**
 * Tarifa universitaria de Ribalta: sin desbloqueo y 0,08 €/minuto.
 * Los primeros 15 minutos son gratuitos (convenio con la Universidad).
 */
@Component("tarifaEstudiante")
public class TarifaEstudiante implements CalculadoraTarifa {

    private static final BigDecimal POR_MINUTO = new BigDecimal("0.08");
    private static final long MINUTOS_GRATIS = 15;

    @Override
    public BigDecimal calcular(Duration duracion) {
        long facturables = Math.max(0, duracion.toMinutes() - MINUTOS_GRATIS);
        return POR_MINUTO
                .multiply(BigDecimal.valueOf(facturables))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nombre() {
        return "estudiante";
    }
}

Ahora hay dos beans de tipo CalculadoraTarifa. Sin más indicaciones, el arranque fallaría así:

Parameter 0 of constructor in com.ciclourbana.alquileres.AlquilerService
required a single bean, but 2 were found:
	- tarifaEstandar: defined in file [.../TarifaEstandar.class]
	- tarifaEstudiante: defined in file [.../TarifaEstudiante.class]

Tres formas de desambiguar:

@Primary: designar un ganador por defecto

Como hemos anotado TarifaEstandar con @Primary, cualquier punto de inyección que pida un CalculadoraTarifa sin más precisión recibirá esa. Es la opción correcta cuando existe un caso claramente dominante.

@Service
public class AlquilerService {

    private final CalculadoraTarifa calculadora;   // recibe TarifaEstandar

    public AlquilerService(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }
}

@Qualifier: pedir una concreta

@Service
public class AlquilerUniversitarioService {

    private final CalculadoraTarifa calculadora;

    public AlquilerUniversitarioService(
            @Qualifier("tarifaEstudiante") CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }
}

@Qualifier gana siempre a @Primary. El valor es el nombre del bean o un cualificador declarado con @Qualifier sobre la propia clase.

Un @Qualifier con cadenas de texto es frágil: un error de escritura solo se detecta al arrancar. La alternativa robusta es un cualificador con anotación propia, que el compilador verifica:

package com.ciclourbana.alquileres;

import org.springframework.beans.factory.annotation.Qualifier;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Universitaria {
}
@Component
@Universitaria
public class TarifaEstudiante implements CalculadoraTarifa { /* ... */ }
public AlquilerUniversitarioService(@Universitaria CalculadoraTarifa calculadora) {
    this.calculadora = calculadora;
}

Ahora un error de escritura no compila. En proyectos grandes es claramente preferible.

Comparación

Mecanismo Dónde se declara Cuándo usarlo
@Primary Sobre el bean Hay una implementación por defecto obvia y el resto son excepciones.
@Qualifier("nombre") En el punto de inyección Puntual, cuando hace falta una implementación concreta.
Cualificador propio (@Universitaria) Sobre el bean y en el punto de inyección Proyectos grandes: seguro frente a errores de escritura, autodocumentado.

  1. Inyectar todas las implementaciones: List y Map

En CicloUrbana la tarifa no la elige el desarrollador: la elige el tipo de usuario en tiempo de ejecución. Elegir con un if entre dos beans inyectados no escala. La solución idiomática es pedirle a Spring todas las implementaciones de golpe.

Inyección de un Map<String, T>

Cuando el tipo del parámetro es Map<String, CalculadoraTarifa>, Spring inyecta un mapa con el nombre del bean como clave y el bean como valor:

package com.ciclourbana.alquileres;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;

/**
 * Selecciona la tarifa aplicable según el tipo de usuario.
 * El mapa lo construye Spring: clave = nombre del bean.
 */
@Service
public class SelectorTarifa {

    private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);

    private final Map<String, CalculadoraTarifa> tarifasPorNombre;

    public SelectorTarifa(Map<String, CalculadoraTarifa> tarifasPorNombre) {
        this.tarifasPorNombre = tarifasPorNombre;
        log.info("Tarifas registradas en la red: {}", tarifasPorNombre.keySet());
    }

    public BigDecimal calcular(String nombreTarifa, Duration duracion) {
        CalculadoraTarifa calculadora = tarifasPorNombre.get(nombreTarifa);
        if (calculadora == null) {
            throw new IllegalArgumentException(
                    "Tarifa desconocida: " + nombreTarifa
                            + ". Disponibles: " + tarifasPorNombre.keySet());
        }
        return calculadora.calcular(duracion);
    }
}

En el arranque verás:

c.c.alquileres.SelectorTarifa : Tarifas registradas en la red: [tarifaEstandar, tarifaEstudiante]

La gran virtud de este patrón: añadir una tarifa nueva no requiere tocar SelectorTarifa. Basta con crear una clase anotada con @Component y aparece en el mapa. Es el principio abierto/cerrado hecho realidad con doce líneas de código.

Un detalle importante: las claves son los nombres de bean (tarifaEstandar), no los valores de nombre() (estandar). Si prefieres indexar por el identificador de negocio, constrúyelo tú a partir de una List:

@Service
public class SelectorTarifa {

    private final Map<String, CalculadoraTarifa> tarifasPorNombre;

    public SelectorTarifa(List<CalculadoraTarifa> tarifas) {
        // Indexamos por el identificador de negocio, no por el nombre del bean
        this.tarifasPorNombre = tarifas.stream()
                .collect(Collectors.toUnmodifiableMap(
                        CalculadoraTarifa::nombre, Function.identity()));
    }
}

Con esta variante las claves son estandar y estudiante, que es lo que llegará por la API en el módulo 3.

Inyección de una List<T> y @Order

Cuando el orden importa —una cadena de validaciones, por ejemplo—, Spring respeta @Order al construir la lista: menor valor, antes.

package com.ciclourbana.alquileres;

public interface ValidacionAlquiler {
    void validar(String matricula, Long estacionId);
}
@Component
@Order(1)
public class ValidarBicicletaDisponible implements ValidacionAlquiler { /* ... */ }

@Component
@Order(2)
public class ValidarUsuarioSinDeuda implements ValidacionAlquiler { /* ... */ }

@Component
@Order(3)
public class ValidarLimiteAlquileresSimultaneos implements ValidacionAlquiler { /* ... */ }
@Service
public class AlquilerService {

    private final List<ValidacionAlquiler> validaciones;   // ordenada por @Order

    public AlquilerService(List<ValidacionAlquiler> validaciones) {
        this.validaciones = validaciones;
    }

    public void iniciar(String matricula, Long estacionId) {
        validaciones.forEach(v -> v.validar(matricula, estacionId));
        // ... resto del alquiler
    }
}

Alternativas a @Order: implementar Ordered (permite calcular el orden) o usar @Priority de Jakarta. @Order es lo habitual.

Cuidado con un matiz: @Order afecta al orden dentro de una colección inyectada, no al orden de creación de los beans. Para el orden de creación, la herramienta es @DependsOn.

  1. Dependencias opcionales: Optional y ObjectProvider

A veces un colaborador puede no existir: un módulo desactivado, una integración opcional. Hay tres formas de expresarlo.

package com.ciclourbana.alquileres;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class AlquilerService {

    private final CalculadoraTarifa calculadora;

    // Opción A: Optional<T>. Nunca es null; queda claro en la firma.
    private final Optional<ServicioNotificaciones> notificaciones;

    // Opción B: @Nullable. Sencillo, pero el campo puede ser null.
    private final @Nullable ServicioFidelizacion fidelizacion;

    // Opción C: ObjectProvider<T>. La más flexible.
    private final ObjectProvider<ServicioAuditoria> auditoria;

    public AlquilerService(CalculadoraTarifa calculadora,
                           Optional<ServicioNotificaciones> notificaciones,
                           @Nullable ServicioFidelizacion fidelizacion,
                           ObjectProvider<ServicioAuditoria> auditoria) {
        this.calculadora = calculadora;
        this.notificaciones = notificaciones;
        this.fidelizacion = fidelizacion;
        this.auditoria = auditoria;
    }

    public void finalizar(String matricula) {
        // A: se usa si existe
        notificaciones.ifPresent(n -> n.avisarFinAlquiler(matricula));

        // B: comprobación manual
        if (fidelizacion != null) {
            fidelizacion.sumarPuntos(matricula);
        }

        // C: resolución perezosa, en el momento de usarlo
        auditoria.ifAvailable(a -> a.registrar("fin-alquiler", matricula));
    }
}

ObjectProvider merece un apartado propio porque es la más potente:

Método Qué hace
getIfAvailable() Devuelve el bean o null si no existe.
getIfUnique() Devuelve el bean solo si hay exactamente uno; null si hay varios.
ifAvailable(Consumer) Ejecuta la acción solo si el bean existe.
getObject() Devuelve el bean; lanza excepción si no existe. Se resuelve en cada llamada.
stream() Todos los beans del tipo, como flujo.
orderedStream() Igual, respetando @Order.

Dos propiedades clave lo distinguen de las demás opciones: la resolución es perezosa (el bean se busca cuando llamas al método, no al construir) y getObject() pide una instancia nueva cada vez si el bean es de ámbito prototype. Esa segunda propiedad es la solución al problema clásico de inyectar un prototype en un singleton, que veremos en la lección 02-03.

  1. Dependencias circulares

Ocurre cuando A necesita a B y B necesita a A:

@Service
public class AlquilerService {
    public AlquilerService(EstacionService estacionService) { /* ... */ }
}

@Service
public class EstacionService {
    public EstacionService(AlquilerService alquilerService) { /* ... */ }
}

Con inyección por constructor esto es lógicamente imposible: para construir A hace falta B ya construido, y para construir B hace falta A. Spring lo detecta y falla el arranque, que es exactamente lo que debe hacer:

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  alquilerService defined in file [.../AlquilerService.class]
↑     ↓
|  estacionService defined in file [.../EstacionService.class]
└─────┘

Action:

Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between beans.

En Spring Boot 3 los ciclos están prohibidos por defecto. Existe la propiedad spring.main.allow-circular-references=true y existe @Lazy sobre uno de los dos puntos de inyección. No uses ninguna de las dos como solución. Ambas hacen desaparecer el mensaje sin arreglar nada, y el ciclo sigue ahí, listo para producir un NullPointerException o un orden de inicialización imprevisible.

Un ciclo es siempre un síntoma de diseño. Las tres curas reales:

Cura 1: extraer la responsabilidad compartida a un tercer componente. Suele ser la mejor.

flowchart LR
    subgraph MAL["Ciclo"]
        A["AlquilerService"] --> B["EstacionService"]
        B --> A
    end
    subgraph BIEN["Sin ciclo"]
        A2["AlquilerService"] --> C["DisponibilidadAnclajes"]
        B2["EstacionService"] --> C
    end

Si AlquilerService necesita de EstacionService solo el recuento de anclajes libres, y EstacionService necesita de AlquilerService solo el número de bicicletas en curso, extrae ese conocimiento común a un DisponibilidadAnclajes del que ambos dependan. El ciclo desaparece.

Cura 2: invertir la dirección con eventos. Si EstacionService solo necesita reaccionar a algo que hace AlquilerService, publica un evento en vez de llamar al servicio:

// En AlquilerService: publica, no llama
publicador.publishEvent(new AlquilerIniciado(matricula, estacionId));
// En EstacionService: escucha, no es llamado
@EventListener
public void alLiberarseAnclaje(AlquilerIniciado evento) {
    // actualizar el conteo de la estación
}

Ya usamos @EventListener en la lección 01-05 con los eventos del ciclo de vida; aquí es el mismo mecanismo con eventos propios. El acoplamiento pasa a ser unidireccional.

Cura 3: revisar quién debe llamar a quién. A menudo el ciclo aparece porque una capa inferior llama a una superior. Si EstacionService llama a AlquilerService, hay que preguntarse si esa lógica no pertenece en realidad a un orquestador por encima de ambos.

Errores Comunes y Consejos

Usar @Autowired sobre campos privados. Sigue siendo lo primero que aparece en tutoriales antiguos. Es la peor opción: impide final, impide construir la clase en un test sin reflexión y oculta el crecimiento descontrolado de dependencias. Si tu IDE es IntelliJ, verás un aviso amarillo: hazle caso.

Poner @Autowired en el único constructor. No es un error, pero es ruido: desde Spring 4.3 es innecesario. Elimínalo.

Instanciar un bean con new. new EstacionService(...) crea un objeto que no es un bean: no recibe inyecciones, no tiene ciclo de vida gestionado, no le funcionan @Transactional ni @Cacheable. Si necesitas un objeto de Spring, pídelo por constructor.

Confiar en la coincidencia de nombres. Escribir el parámetro tarifaEstudiante para que Spring elija ese bean funciona, pero es frágil: al renombrar el parámetro —algo que un IDE hace sin avisar— el arranque se rompe o, peor, se inyecta silenciosamente otro bean. Usa @Qualifier explícito.

Inyectar ApplicationContext para hacer getBean(...). Es service locator, el antipatrón que la inyección de dependencias vino a sustituir: oculta las dependencias reales de la clase y hace las pruebas imposibles. Se justifica en herramientas y diagnósticos (como el VerificadorBeans de la lección anterior), nunca en lógica de negocio.

Resolver un ciclo con @Lazy. Repetimos por su importancia: no es una solución, es una anestesia. Rediseña.

Consejo: si el constructor pasa de cuatro o cinco parámetros, párate. Casi siempre significa que la clase tiene más de una responsabilidad. Extrae, no aumentes.

Consejo: depende siempre de interfaces en las fronteras entre capas. EstacionService depende de EstacionRepositorio, no de EstacionRepositorioEnMemoria. Es lo que permitirá que el módulo 4 cambie la implementación sin tocar el servicio. Dentro de una misma capa, en cambio, crear una interfaz con una sola implementación suele ser ceremonia inútil.

Consejo: no anotes clases de dominio. Estacion y los futuros Alquiler o Bicicleta son datos, se crean con new y no participan en la inyección.

Ejercicios

Ejercicio 1: tercera tarifa sin modificar el selector

Añade a la red de Ribalta una tarifa TarifaJubilado: sin desbloqueo, 0,05 €/minuto, con los primeros 30 minutos gratuitos. Comprueba que aparece automáticamente en el SelectorTarifa construido a partir de una List<CalculadoraTarifa>, sin modificar ni una línea de SelectorTarifa. Registra en el arranque las tarifas disponibles y el importe de un alquiler de 45 minutos con cada una.

Ejercicio 2: cadena ordenada de validaciones

Implementa tres validaciones de alquiler (ValidacionAlquiler) con @Order: la matrícula debe tener el formato RB-NNNN; la estación de origen debe existir en la red; la estación debe tener al menos una bicicleta (simúlalo con la capacidad). Inyéctalas como List<ValidacionAlquiler> en AlquilerService y verifica que se ejecutan en orden lanzando un alquiler inválido.

Ejercicio 3: romper un ciclo con eventos

Provoca deliberadamente una dependencia circular entre AlquilerService y EstacionService (inyecta cada uno en el otro), arranca la aplicación y anota el mensaje de error. Después rompe el ciclo publicando un evento AlquilerIniciado desde AlquilerService y escuchándolo en EstacionService.


Soluciones

Solución 1

package com.ciclourbana.alquileres;

import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

/**
 * Tarifa para mayores de 65 años empadronados en Ribalta:
 * sin desbloqueo, 0,05 €/minuto y 30 minutos gratuitos.
 */
@Component
public class TarifaJubilado implements CalculadoraTarifa {

    private static final BigDecimal POR_MINUTO = new BigDecimal("0.05");
    private static final long MINUTOS_GRATIS = 30;

    @Override
    public BigDecimal calcular(Duration duracion) {
        long facturables = Math.max(0, duracion.toMinutes() - MINUTOS_GRATIS);
        return POR_MINUTO
                .multiply(BigDecimal.valueOf(facturables))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nombre() {
        return "jubilado";
    }
}

El selector, sin tocar (así es como debe quedar):

package com.ciclourbana.alquileres;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class SelectorTarifa {

    private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);

    private final Map<String, CalculadoraTarifa> porNombreNegocio;

    public SelectorTarifa(List<CalculadoraTarifa> tarifas) {
        this.porNombreNegocio = tarifas.stream()
                .collect(Collectors.toUnmodifiableMap(
                        CalculadoraTarifa::nombre, Function.identity()));
        log.info("Tarifas disponibles en Ribalta: {}", porNombreNegocio.keySet());
    }

    public BigDecimal calcular(String tarifa, Duration duracion) {
        CalculadoraTarifa calculadora = porNombreNegocio.get(tarifa);
        if (calculadora == null) {
            throw new IllegalArgumentException("Tarifa desconocida: " + tarifa
                    + ". Disponibles: " + porNombreNegocio.keySet());
        }
        return calculadora.calcular(duracion);
    }

    public Map<String, CalculadoraTarifa> disponibles() {
        return porNombreNegocio;
    }
}

Y el comprobador de arranque:

package com.ciclourbana.alquileres;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
@Order(10)
public class DemostracionTarifas implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(DemostracionTarifas.class);

    private final SelectorTarifa selectorTarifa;

    public DemostracionTarifas(SelectorTarifa selectorTarifa) {
        this.selectorTarifa = selectorTarifa;
    }

    @Override
    public void run(String... args) {
        Duration duracion = Duration.ofMinutes(45);
        selectorTarifa.disponibles().keySet().forEach(tarifa ->
                log.info("Alquiler de 45 min con tarifa '{}': {} €",
                        tarifa, selectorTarifa.calcular(tarifa, duracion)));
    }
}

Salida:

c.c.alquileres.SelectorTarifa      : Tarifas disponibles en Ribalta: [estandar, estudiante, jubilado]
c.c.a.DemostracionTarifas          : Alquiler de 45 min con tarifa 'estandar': 5.90 €
c.c.a.DemostracionTarifas          : Alquiler de 45 min con tarifa 'estudiante': 2.40 €
c.c.a.DemostracionTarifas          : Alquiler de 45 min con tarifa 'jubilado': 0.75 €

Comentario: el punto del ejercicio es comprobar que SelectorTarifa no cambió. Extender el sistema consistió únicamente en añadir una clase. Error frecuente: olvidar @Component en la nueva tarifa; entonces no aparece en la lista y el fallo es silencioso, sin mensaje de error. Si algo no aparece en una colección inyectada, lo primero que hay que mirar es si la clase es realmente un bean.

Solución 2

package com.ciclourbana.alquileres;

public interface ValidacionAlquiler {
    void validar(String matricula, Long estacionId);
}
package com.ciclourbana.alquileres;

import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.regex.Pattern;

@Component
@Order(1)
public class ValidarFormatoMatricula implements ValidacionAlquiler {

    private static final Pattern FORMATO = Pattern.compile("RB-\\d{4}");

    @Override
    public void validar(String matricula, Long estacionId) {
        if (matricula == null || !FORMATO.matcher(matricula).matches()) {
            throw new IllegalArgumentException(
                    "Matrícula inválida: '" + matricula + "'. Formato esperado: RB-0142");
        }
    }
}
package com.ciclourbana.alquileres;

import com.ciclourbana.estaciones.EstacionService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(2)
public class ValidarEstacionExiste implements ValidacionAlquiler {

    private final EstacionService estacionService;

    public ValidarEstacionExiste(EstacionService estacionService) {
        this.estacionService = estacionService;
    }

    @Override
    public void validar(String matricula, Long estacionId) {
        estacionService.buscarPorId(estacionId).orElseThrow(() ->
                new IllegalArgumentException(
                        "La estación " + estacionId + " no existe en la red de Ribalta"));
    }
}
package com.ciclourbana.alquileres;

import com.ciclourbana.estaciones.EstacionService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(3)
public class ValidarEstacionOperativa implements ValidacionAlquiler {

    private final EstacionService estacionService;

    public ValidarEstacionOperativa(EstacionService estacionService) {
        this.estacionService = estacionService;
    }

    @Override
    public void validar(String matricula, Long estacionId) {
        // Simulación: hasta el módulo 4 no hay inventario real de bicicletas
        estacionService.buscarPorId(estacionId)
                .filter(estacion -> estacion.capacidad() >= 8)
                .orElseThrow(() -> new IllegalStateException(
                        "La estación " + estacionId + " está fuera de servicio"));
    }
}
package com.ciclourbana.alquileres;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class AlquilerService {

    private static final Logger log = LoggerFactory.getLogger(AlquilerService.class);

    private final CalculadoraTarifa calculadoraPorDefecto;   // el @Primary
    private final List<ValidacionAlquiler> validaciones;     // ordenadas por @Order

    public AlquilerService(CalculadoraTarifa calculadoraPorDefecto,
                           List<ValidacionAlquiler> validaciones) {
        this.calculadoraPorDefecto = calculadoraPorDefecto;
        this.validaciones = validaciones;
        log.info("AlquilerService con tarifa '{}' y {} validaciones: {}",
                calculadoraPorDefecto.nombre(),
                validaciones.size(),
                validaciones.stream().map(v -> v.getClass().getSimpleName()).toList());
    }

    public void iniciar(String matricula, Long estacionId) {
        validaciones.forEach(v -> v.validar(matricula, estacionId));
        log.info("Alquiler iniciado: bicicleta {} en la estación {}", matricula, estacionId);
    }
}

Salida en el arranque y al probar con una matrícula inválida:

c.c.alquileres.AlquilerService : AlquilerService con tarifa 'estandar' y 3 validaciones:
    [ValidarFormatoMatricula, ValidarEstacionExiste, ValidarEstacionOperativa]
...
java.lang.IllegalArgumentException: Matrícula inválida: 'RB-14'. Formato esperado: RB-0142

Comentario: al inyectar una List, Spring ordena por @Order de menor a mayor. Si quitas los @Order, el orden pasa a ser el de descubrimiento del escaneo, que es estable en la práctica pero no está garantizado: nunca dependas de él. Consejo: cuando el orden importa de verdad, deja saltos entre los valores (10, 20, 30) para poder insertar validaciones intermedias sin renumerar.

Solución 3

Primero, el ciclo deliberado:

@Service
public class AlquilerService {
    public AlquilerService(EstacionService estacionService) { /* ... */ }
}

@Service
public class EstacionService {
    // Provoca el ciclo
    public EstacionService(EstacionRepositorio repo, AlquilerService alquilerService) { /* ... */ }
}

El arranque falla con el recuadro ┌─────┐ ... └─────┘ mostrado en el apartado 9. Ese diagrama en el log es una de las mejores herramientas de diagnóstico de Spring Boot: dice exactamente qué beans forman el ciclo y dónde están definidos.

Ahora la solución con eventos. El evento, un record en el paquete de alquileres:

package com.ciclourbana.alquileres;

import java.time.Instant;

/** Evento de dominio: se ha iniciado un alquiler en la red. */
public record AlquilerIniciado(String matricula, Long estacionId, Instant momento) {

    public AlquilerIniciado(String matricula, Long estacionId) {
        this(matricula, estacionId, Instant.now());
    }
}

El emisor:

package com.ciclourbana.alquileres;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class AlquilerService {

    private final ApplicationEventPublisher publicador;

    // Ya no inyecta EstacionService: el ciclo desaparece
    public AlquilerService(ApplicationEventPublisher publicador) {
        this.publicador = publicador;
    }

    public void iniciar(String matricula, Long estacionId) {
        // ... validaciones y lógica del alquiler ...
        publicador.publishEvent(new AlquilerIniciado(matricula, estacionId));
    }
}

El receptor:

package com.ciclourbana.estaciones;

import com.ciclourbana.alquileres.AlquilerIniciado;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

@Service
public class EstacionService {

    private static final Logger log = LoggerFactory.getLogger(EstacionService.class);

    private final EstacionRepositorio estacionRepositorio;

    // Sin AlquilerService: dependencia unidireccional
    public EstacionService(EstacionRepositorio estacionRepositorio) {
        this.estacionRepositorio = estacionRepositorio;
    }

    @EventListener
    public void alIniciarseUnAlquiler(AlquilerIniciado evento) {
        log.info("Anclaje liberado en la estación {} por la bicicleta {}",
                evento.estacionId(), evento.matricula());
        // En el módulo 4 esto actualizará el conteo persistido
    }

    // ... resto de métodos de la lección anterior
}

Comentario: ApplicationEventPublisher es un bean que Spring proporciona siempre; inyectarlo no crea dependencia con nadie. El resultado es que AlquilerService no sabe quién reacciona a sus eventos: podrían ser cero, uno o cinco oyentes. El acoplamiento pasa de bidireccional a inexistente.

Dos precauciones que conviene conocer desde ya: los @EventListener son síncronos por defecto (se ejecutan en el mismo hilo y dentro de la misma transacción que el emisor); si el oyente lanza una excepción, la propaga al emisor. Hacerlos asíncronos con @Async se trata en la lección 07-03.

Conclusión

La inyección de dependencias ha dejado de ser una anotación que se copia. Sabes que IoC es el principio —el framework llama a tu código— y que DI es su aplicación a la construcción de objetos. Has visto en un ejemplo concreto los cuatro problemas que causa un new dentro de una clase y cómo desaparecen al recibir el colaborador por constructor. Conoces los tres estilos de inyección y los cinco argumentos por los que solo el constructor es defendible, con la testabilidad y la presión de diseño a la cabeza. Sabes cómo resuelve Spring un punto de inyección: por tipo primero, con @Qualifier por encima de @Primary, y por nombre solo como último recurso. Sabes inyectar todas las implementaciones de una interfaz en un Map o una List ordenada con @Order, un patrón que hace extensible el sistema sin tocar el código que lo consume. Sabes expresar una dependencia opcional con Optional, @Nullable u ObjectProvider. Y sabes que una dependencia circular no se parchea con @Lazy: se rediseña extrayendo lo común o invirtiendo la dirección con eventos.

CicloUrbana tiene ya su primer sistema extensible: la interfaz CalculadoraTarifa con TarifaEstandar (marcada @Primary) y TarifaEstudiante, el SelectorTarifa que las indexa por nombre de negocio y un AlquilerService que orquesta validaciones ordenadas. Los importes siguen estando escritos a fuego en constantes; en la lección 02-05 los sacaremos a la configuración.

Antes de eso queda una pregunta que hemos rozado varias veces sin responder. Hemos dicho que Spring crea una instancia de cada bean y que por eso EstacionRepositorioEnMemoria usa un ConcurrentHashMap. ¿Por qué una sola? ¿Puede haber más? ¿Cuánto vive cada bean y qué ocurre exactamente entre que se instancia y queda disponible? La próxima lección, Ámbito y Ciclo de Vida de los Beans, responde a todo eso: los ámbitos disponibles, el peligro del estado mutable en un singleton, el ciclo de vida completo con @PostConstruct y @PreDestroy, la inicialización perezosa y el punto de extensión —BeanPostProcessor— sobre el que se apoya buena parte de la magia aparente de Spring.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados