A la lliçó anterior vam escriure public EstacioService(EstacioRepositori estacioRepositori) i Spring va trobar tot sol la implementació correcta. Aquesta capacitat —construir un objecte lliurant-li els seus col·laboradors ja llestos— és el cor del contenidor i la raó per la qual Spring existeix. En aquesta lliçó desmuntarem el mecanisme: què és la inversió de control, quin problema real resol, quins són els tres estils d'injecció i per què només un és defensable, com resol Spring un tipus quan hi ha diverses implementacions candidates, com injectar-les totes de cop en una List o un Map, què cal fer quan una dependència és opcional i com sortir d'una dependència circular sense recórrer a pedaços. Tot això construint la primera peça del sistema de tarifes de la xarxa de Ribalta.

Contingut

  1. Inversió de control i injecció de dependències
  2. L'abans i el després: acoblament davant d'injecció
  3. Els tres tipus d'injecció
  4. Per què la injecció per constructor és l'única recomanable
  5. Com resol Spring les dependències
  6. Diverses implementacions del mateix tipus: @Primary i @Qualifier
  7. Injectar totes les implementacions: List i Map
  8. Dependències opcionals: Optional i ObjectProvider
  9. Dependències circulars
  10. Errors Comuns i Consells
  11. Exercicis

  1. Inversió de control i injecció de dependències

Són dos conceptes relacionats però diferents, i convé no barrejar-los.

Inversió de control (IoC) és un principi general: en comptes que el teu codi controli el flux i decideixi quan es crea cada cosa, és un framework el que controla el flux i crida el teu codi quan toca. Quan escrius un CommandLineRunner, tu no l'invoques: ho fa Spring. Quan escrius un @GetMapping, tu no crides el mètode: el crida el DispatcherServlet. Això és inversió de control.

Injecció de dependències (DI) és una manera concreta d'aplicar IoC a la construcció d'objectes: una classe declara quins col·laboradors necessita, però no els crea; algú extern —el contenidor— els hi lliura ja construïts.

flowchart LR
    subgraph SENSE["Sense injecció"]
        A1["EstacioService"] -->|new| B1["EstacioRepositoriEnMemoria"]
    end
    subgraph AMB["Amb injecció"]
        C["ApplicationContext"] -->|construeix| B2["EstacioRepositoriEnMemoria"]
        C -->|construeix i injecta| A2["EstacioService"]
        A2 -.->|depèn de la interfície| I["EstacioRepositori"]
    end

El contenidor de Spring, l'ApplicationContext, és un injector de dependències: manté un catàleg de beans i sap com satisfer el que cadascun demana.

  1. L'abans i el després: acoblament davant d'injecció

Vegem-ho amb la peça que construirem en aquesta lliçó: el càlcul de la tarifa d'un lloguer a Ribalta.

Abans: la dependència es crea a dins

package com.ciclourbana.lloguers;

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

public class LloguerService {

    // La dependència s'instancia aquí dins
    private final TarifaEstandard calculadora = new TarifaEstandard();

    public BigDecimal finalitzar(String matricula, Duration durada) {
        return calculadora.calcular(durada);
    }
}

Funciona. I tanmateix té quatre problemes greus:

  1. Acoblament a una implementació concreta. LloguerService no depèn d'"una manera de calcular tarifes": depèn de TarifaEstandard. Afegir la tarifa d'estudiant obliga a modificar aquesta classe.
  2. Impossible de provar de manera aïllada. No hi ha manera de substituir la calculadora per una de controlada en un test. Qualsevol prova de LloguerService prova també, inevitablement, TarifaEstandard.
  3. Configuració enterrada. Si TarifaEstandard hagués de llegir el preu per minut de la configuració, LloguerService hauria de saber com construir-la amb aquests paràmetres. La responsabilitat es contagia.
  4. Cicle de vida descontrolat. Cada LloguerService crea la seva pròpia calculadora. Si hi hagués deu serveis, hi hauria deu calculadores idèntiques ocupant memòria.

Després: la dependència es rep

package com.ciclourbana.lloguers;

import org.springframework.stereotype.Service;

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

@Service
public class LloguerService {

    // Depèn de l'abstracció, no pas d'una implementació
    private final CalculadoraTarifa calculadora;

    // Spring lliura el col·laborador ja construït
    public LloguerService(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }

    public BigDecimal finalitzar(String matricula, Duration durada) {
        return calculadora.calcular(durada);
    }
}

Els quatre problemes desapareixen:

Problema Com es resol
Acoblament LloguerService només coneix la interfície CalculadoraTarifa.
Proves En un test n'hi ha prou amb new LloguerService(durada -> new BigDecimal("2.50")). Sense Spring, sense base de dades, sense res.
Configuració Qui construeix TarifaEstandard és el contenidor, que sap injectar-li les seves propietats.
Cicle de vida Hi ha una instància compartida, gestionada per Spring.

El segon punt mereix èmfasi: la testabilitat no és un benefici secundari de la injecció de dependències, és pràcticament la seva raó de ser. Quan al mòdul 6 escrivim proves amb Mockito, substituir col·laboradors serà trivial precisament per aquest disseny.

  1. Els tres tipus d'injecció

Spring admet tres mecanismes. Els veiem amb la mateixa classe per poder comparar-los.

Injecció per constructor (recomanada)

@Service
public class LloguerService {

    private final CalculadoraTarifa calculadora;
    private final EstacioService estacioService;

    // Des de Spring 4.3, @Autowired és innecessari si hi ha UN sol constructor
    public LloguerService(CalculadoraTarifa calculadora, EstacioService estacioService) {
        this.calculadora = calculadora;
        this.estacioService = estacioService;
    }
}

Injecció per setter

@Service
public class LloguerService {

    private CalculadoraTarifa calculadora;   // no pot ser final

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

Injecció per camp

@Service
public class LloguerService {

    @Autowired
    private CalculadoraTarifa calculadora;   // Spring l'assigna per reflexió
}

Comparades:

Criteri Constructor Setter Camp
Permet final (immutabilitat) Sí No No
Objecte sempre en estat vàlid Sí No (hi ha un instant sense dependència) No
Detecta dependències que falten A l'arrencada A l'arrencada A l'arrencada
Detecta dependències circulars A l'arrencada, amb error clar Les amaga Les amaga
Instanciable amb new en un test Sí Sí, amb setters extra No (requereix reflexió o Spring)
Requereix @Autowired No (amb un constructor) Sí Sí
Fa visible l'excés de dependències Sí: el constructor creix i molesta Poc No: tant li fa tenir 15 camps
Admet dependències opcionals Amb @Nullable/ObjectProvider Sí, natural Amb required = false
Recomanació oficial de Spring Sí Casos opcionals Desaconsellada

  1. Per què la injecció per constructor és l'única recomanable

Cinc arguments, en ordre d'importància:

1. Immutabilitat real. Només el constructor permet declarar els camps final. Un camp final no es pot reassignar per accident ni per una crida concurrent, i el compilador garanteix que està assignat. Amb setters o camps, qualsevol codi pot canviar la dependència en calent.

2. Un objecte no existeix mai a mig construir. Amb injecció per constructor, si l'objecte existeix, les seves dependències estan posades. Amb setter, hi ha una finestra entre new LloguerService() i setCalculadora(...) en què l'objecte és una bomba de NullPointerException.

3. Fallada primerenca i llegible. Si falta un bean, Spring falla a l'arrencada amb un missatge explícit, no pas en producció a les tres de la matinada:

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

Description:

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

Action:

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

4. Testabilitat sense framework. Aquesta és la diferència pràctica més notable:

// Amb injecció per constructor: una línia, sense Spring
var servei = new LloguerService(durada -> new BigDecimal("1.80"), estacioService);

// Amb injecció per camp: no hi ha manera neta.
// Cal ReflectionTestUtils o aixecar el context sencer
ReflectionTestUtils.setField(servei, "calculadora", calculadoraFalsa);

5. La pressió de disseny és sana. Un constructor amb vuit paràmetres fa vergonya d'escriure, i aquesta vergonya és informació: la classe fa massa coses i s'ha de partir. Amb injecció per camp, vuit @Autowired no molesten visualment i la classe creix sense límit. És el pitjor efecte de la injecció per camp, i és cultural, no pas tècnic.

Des de Spring 4.3, si la classe té un únic constructor, @Autowired és opcional. A Spring Boot 3 la pràctica universal és ometre'l. Amb dos constructors o més sí que cal marcar quin s'ha d'utilitzar:

@Service
public class LloguerService {

    private final CalculadoraTarifa calculadora;

    @Autowired   // necessari: hi ha més d'un constructor
    public LloguerService(CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }

    // Constructor auxiliar per a proves manuals
    public LloguerService() {
        this(durada -> BigDecimal.ZERO);
    }
}

  1. Com resol Spring les dependències

L'algorisme, en ordre:

flowchart TD
    A["Paràmetre de constructor:<br/>CalculadoraTarifa calculadora"] --> B["Cercar beans assignables<br/>a aquest tipus"]
    B --> C{"Quants n'hi ha?"}
    C -- "0" --> D["És opcional?"]
    D -- No --> E["NoSuchBeanDefinitionException<br/>Fallada d'arrencada"]
    D -- "Sí (Optional/@Nullable)" --> F["Injecta null o Optional.empty()"]
    C -- "1" --> G["S'injecta aquest bean"]
    C -- "2 o més" --> H{"Hi ha @Qualifier<br/>al punt d'injecció?"}
    H -- Sí --> I["S'injecta el bean amb aquest nom/qualificador"]
    H -- No --> J{"Hi ha un bean @Primary?"}
    J -- Sí --> K["S'injecta el @Primary"]
    J -- No --> L{"El nom del paràmetre<br/>coincideix amb un nom de bean?"}
    L -- Sí --> M["S'injecta per coincidència de nom"]
    L -- No --> N["NoUniqueBeanDefinitionException<br/>Fallada d'arrencada"]

Els dos criteris fonamentals són, per aquest ordre: per tipus primer, per nom com a desempat. Molta gent creu que Spring injecta pel nom de la variable; només ho fa com a últim recurs, i dependre'n és fràgil (n'hi ha prou amb reanomenar el paràmetre per trencar-ho).

  1. Diverses implementacions del mateix tipus: @Primary i @Qualifier

Arriba el moment de construir el sistema de tarifes de Ribalta. Comencem pel contracte, al nou paquet com.ciclourbana.lloguers:

package com.ciclourbana.lloguers;

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

/**
 * Contracte de càlcul de l'import d'un lloguer de la xarxa de Ribalta.
 * Les tarifes concretes (estàndard, estudiant, i les que vinguin)
 * són implementacions diferents d'aquesta interfície.
 */
public interface CalculadoraTarifa {

    /** Import en euros d'un lloguer de la durada indicada. */
    BigDecimal calcular(Duration durada);

    /** Identificador llegible de la tarifa, per a logs i factures. */
    default String nom() {
        return getClass().getSimpleName();
    }
}

I dues implementacions. L'estàndard:

package com.ciclourbana.lloguers;

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 desbloqueig + 0,12 €/minut.
 * Els imports es fixaran per configuració a la lliçó 02-05.
 */
@Component
@Primary   // és la tarifa per defecte de la xarxa
public class TarifaEstandard implements CalculadoraTarifa {

    private static final BigDecimal DESBLOQUEIG = new BigDecimal("0.50");
    private static final BigDecimal PER_MINUT = new BigDecimal("0.12");

    @Override
    public BigDecimal calcular(Duration durada) {
        BigDecimal minuts = BigDecimal.valueOf(Math.max(1, durada.toMinutes()));
        return DESBLOQUEIG
                .add(PER_MINUT.multiply(minuts))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nom() {
        return "estandard";
    }
}

I la d'estudiant:

package com.ciclourbana.lloguers;

import org.springframework.stereotype.Component;

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

/**
 * Tarifa universitària de Ribalta: sense desbloqueig i 0,08 €/minut.
 * Els primers 15 minuts són gratuïts (conveni amb la Universitat).
 */
@Component("tarifaEstudiant")
public class TarifaEstudiant implements CalculadoraTarifa {

    private static final BigDecimal PER_MINUT = new BigDecimal("0.08");
    private static final long MINUTS_GRATIS = 15;

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

    @Override
    public String nom() {
        return "estudiant";
    }
}

Ara hi ha dos beans de tipus CalculadoraTarifa. Sense més indicacions, l'arrencada fallaria així:

Parameter 0 of constructor in com.ciclourbana.lloguers.LloguerService
required a single bean, but 2 were found:
	- tarifaEstandard: defined in file [.../TarifaEstandard.class]
	- tarifaEstudiant: defined in file [.../TarifaEstudiant.class]

Tres maneres de desambiguar:

@Primary: designar un guanyador per defecte

Com que hem anotat TarifaEstandard amb @Primary, qualsevol punt d'injecció que demani un CalculadoraTarifa sense més precisió rebrà aquesta. És l'opció correcta quan existeix un cas clarament dominant.

@Service
public class LloguerService {

    private final CalculadoraTarifa calculadora;   // rep TarifaEstandard

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

@Qualifier: demanar-ne una de concreta

@Service
public class LloguerUniversitariService {

    private final CalculadoraTarifa calculadora;

    public LloguerUniversitariService(
            @Qualifier("tarifaEstudiant") CalculadoraTarifa calculadora) {
        this.calculadora = calculadora;
    }
}

@Qualifier guanya sempre a @Primary. El valor és el nom del bean o un qualificador declarat amb @Qualifier sobre la mateixa classe.

Un @Qualifier amb cadenes de text és fràgil: un error d'escriptura només es detecta en arrencar. L'alternativa robusta és un qualificador amb anotació pròpia, que el compilador verifica:

package com.ciclourbana.lloguers;

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 TarifaEstudiant implements CalculadoraTarifa { /* ... */ }
public LloguerUniversitariService(@Universitaria CalculadoraTarifa calculadora) {
    this.calculadora = calculadora;
}

Ara un error d'escriptura no compila. En projectes grans és clarament preferible.

Comparació

Mecanisme On es declara Quan utilitzar-lo
@Primary Sobre el bean Hi ha una implementació per defecte òbvia i la resta són excepcions.
@Qualifier("nom") Al punt d'injecció Puntual, quan cal una implementació concreta.
Qualificador propi (@Universitaria) Sobre el bean i al punt d'injecció Projectes grans: segur davant d'errors d'escriptura, autodocumentat.

  1. Injectar totes les implementacions: List i Map

A CicloUrbana la tarifa no la tria el desenvolupador: la tria el tipus d'usuari en temps d'execució. Triar amb un if entre dos beans injectats no escala. La solució idiomàtica és demanar a Spring totes les implementacions de cop.

Injecció d'un Map<String, T>

Quan el tipus del paràmetre és Map<String, CalculadoraTarifa>, Spring injecta un mapa amb el nom del bean com a clau i el bean com a valor:

package com.ciclourbana.lloguers;

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 segons el tipus d'usuari.
 * El mapa el construeix Spring: clau = nom del bean.
 */
@Service
public class SelectorTarifa {

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

    private final Map<String, CalculadoraTarifa> tarifesPerNom;

    public SelectorTarifa(Map<String, CalculadoraTarifa> tarifesPerNom) {
        this.tarifesPerNom = tarifesPerNom;
        log.info("Tarifes registrades a la xarxa: {}", tarifesPerNom.keySet());
    }

    public BigDecimal calcular(String nomTarifa, Duration durada) {
        CalculadoraTarifa calculadora = tarifesPerNom.get(nomTarifa);
        if (calculadora == null) {
            throw new IllegalArgumentException(
                    "Tarifa desconeguda: " + nomTarifa
                            + ". Disponibles: " + tarifesPerNom.keySet());
        }
        return calculadora.calcular(durada);
    }
}

A l'arrencada veuràs:

c.c.lloguers.SelectorTarifa : Tarifes registrades a la xarxa: [tarifaEstandard, tarifaEstudiant]

La gran virtut d'aquest patró: afegir una tarifa nova no requereix tocar SelectorTarifa. N'hi ha prou amb crear una classe anotada amb @Component i apareix al mapa. És el principi obert/tancat fet realitat amb dotze línies de codi.

Un detall important: les claus són els noms de bean (tarifaEstandard), no pas els valors de nom() (estandard). Si prefereixes indexar per l'identificador de negoci, construeix-lo tu a partir d'una List:

@Service
public class SelectorTarifa {

    private final Map<String, CalculadoraTarifa> tarifesPerNom;

    public SelectorTarifa(List<CalculadoraTarifa> tarifes) {
        // Indexem per l'identificador de negoci, no pel nom del bean
        this.tarifesPerNom = tarifes.stream()
                .collect(Collectors.toUnmodifiableMap(
                        CalculadoraTarifa::nom, Function.identity()));
    }
}

Amb aquesta variant les claus són estandard i estudiant, que és el que arribarà per l'API al mòdul 3.

Injecció d'una List<T> i @Order

Quan l'ordre importa —una cadena de validacions, per exemple—, Spring respecta @Order en construir la llista: menor valor, abans.

package com.ciclourbana.lloguers;

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

@Component
@Order(2)
public class ValidarUsuariSenseDeute implements ValidacioLloguer { /* ... */ }

@Component
@Order(3)
public class ValidarLimitLloguersSimultanis implements ValidacioLloguer { /* ... */ }
@Service
public class LloguerService {

    private final List<ValidacioLloguer> validacions;   // ordenada per @Order

    public LloguerService(List<ValidacioLloguer> validacions) {
        this.validacions = validacions;
    }

    public void iniciar(String matricula, Long estacioId) {
        validacions.forEach(v -> v.validar(matricula, estacioId));
        // ... resta del lloguer
    }
}

Alternatives a @Order: implementar Ordered (permet calcular l'ordre) o utilitzar @Priority de Jakarta. @Order és l'habitual.

Compte amb un matís: @Order afecta l'ordre dins d'una col·lecció injectada, no pas l'ordre de creació dels beans. Per a l'ordre de creació, l'eina és @DependsOn.

  1. Dependències opcionals: Optional i ObjectProvider

De vegades un col·laborador pot no existir: un mòdul desactivat, una integració opcional. Hi ha tres maneres d'expressar-ho.

package com.ciclourbana.lloguers;

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

import java.util.Optional;

@Service
public class LloguerService {

    private final CalculadoraTarifa calculadora;

    // Opció A: Optional<T>. Mai no és null; queda clar a la signatura.
    private final Optional<ServeiNotificacions> notificacions;

    // Opció B: @Nullable. Senzill, però el camp pot ser null.
    private final @Nullable ServeiFidelitzacio fidelitzacio;

    // Opció C: ObjectProvider<T>. La més flexible.
    private final ObjectProvider<ServeiAuditoria> auditoria;

    public LloguerService(CalculadoraTarifa calculadora,
                          Optional<ServeiNotificacions> notificacions,
                          @Nullable ServeiFidelitzacio fidelitzacio,
                          ObjectProvider<ServeiAuditoria> auditoria) {
        this.calculadora = calculadora;
        this.notificacions = notificacions;
        this.fidelitzacio = fidelitzacio;
        this.auditoria = auditoria;
    }

    public void finalitzar(String matricula) {
        // A: s'utilitza si existeix
        notificacions.ifPresent(n -> n.avisarFiLloguer(matricula));

        // B: comprovació manual
        if (fidelitzacio != null) {
            fidelitzacio.sumarPunts(matricula);
        }

        // C: resolució mandrosa, en el moment d'utilitzar-lo
        auditoria.ifAvailable(a -> a.registrar("fi-lloguer", matricula));
    }
}

ObjectProvider mereix un apartat propi perquè és la més potent:

Mètode Què fa
getIfAvailable() Retorna el bean o null si no existeix.
getIfUnique() Retorna el bean només si n'hi ha exactament un; null si n'hi ha diversos.
ifAvailable(Consumer) Executa l'acció només si el bean existeix.
getObject() Retorna el bean; llança una excepció si no existeix. Es resol a cada crida.
stream() Tots els beans del tipus, com a flux.
orderedStream() Igual, respectant @Order.

Dues propietats clau el distingeixen de les altres opcions: la resolució és mandrosa (el bean es busca quan crides el mètode, no pas en construir) i getObject() demana una instància nova cada vegada si el bean és d'àmbit prototype. Aquesta segona propietat és la solució al problema clàssic d'injectar un prototype en un singleton, que veurem a la lliçó 02-03.

  1. Dependències circulars

Passa quan A necessita B i B necessita A:

@Service
public class LloguerService {
    public LloguerService(EstacioService estacioService) { /* ... */ }
}

@Service
public class EstacioService {
    public EstacioService(LloguerService lloguerService) { /* ... */ }
}

Amb injecció per constructor això és lògicament impossible: per construir A cal B ja construït, i per construir B cal A. Spring ho detecta i falla l'arrencada, que és exactament el que ha de fer:

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

Description:

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

┌─────┐
|  lloguerService defined in file [.../LloguerService.class]
↑     ↓
|  estacioService defined in file [.../EstacioService.class]
└─────┘

Action:

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

A Spring Boot 3 els cicles estan prohibits per defecte. Existeix la propietat spring.main.allow-circular-references=true i existeix @Lazy sobre un dels dos punts d'injecció. No utilitzis cap de les dues com a solució. Totes dues fan desaparèixer el missatge sense arreglar res, i el cicle continua allà, a punt de produir un NullPointerException o un ordre d'inicialització imprevisible.

Un cicle és sempre un símptoma de disseny. Les tres cures reals:

Cura 1: extreure la responsabilitat compartida a un tercer component. Sol ser la millor.

flowchart LR
    subgraph MALAMENT["Cicle"]
        A["LloguerService"] --> B["EstacioService"]
        B --> A
    end
    subgraph BE["Sense cicle"]
        A2["LloguerService"] --> C["DisponibilitatAncoratges"]
        B2["EstacioService"] --> C
    end

Si LloguerService necessita d'EstacioService només el recompte d'ancoratges lliures, i EstacioService necessita de LloguerService només el nombre de bicicletes en curs, extreu aquest coneixement comú a un DisponibilitatAncoratges del qual depenguin tots dos. El cicle desapareix.

Cura 2: invertir la direcció amb esdeveniments. Si EstacioService només necessita reaccionar a alguna cosa que fa LloguerService, publica un esdeveniment en comptes de cridar el servei:

// A LloguerService: publica, no crida
publicador.publishEvent(new LloguerIniciat(matricula, estacioId));
// A EstacioService: escolta, no és cridat
@EventListener
public void enAlliberarseAncoratge(LloguerIniciat esdeveniment) {
    // actualitzar el recompte de l'estació
}

Ja vam utilitzar @EventListener a la lliçó 01-05 amb els esdeveniments del cicle de vida; aquí és el mateix mecanisme amb esdeveniments propis. L'acoblament passa a ser unidireccional.

Cura 3: revisar qui ha de cridar qui. Sovint el cicle apareix perquè una capa inferior crida una de superior. Si EstacioService crida LloguerService, cal preguntar-se si aquesta lògica no pertany en realitat a un orquestrador per damunt de tots dos.

Errors Comuns i Consells

Utilitzar @Autowired sobre camps privats. Continua sent el primer que apareix en tutorials antics. És la pitjor opció: impedeix final, impedeix construir la classe en un test sense reflexió i amaga el creixement descontrolat de dependències. Si el teu IDE és IntelliJ, veuràs un avís groc: fes-li cas.

Posar @Autowired a l'únic constructor. No és un error, però és soroll: des de Spring 4.3 és innecessari. Elimina'l.

Instanciar un bean amb new. new EstacioService(...) crea un objecte que no és un bean: no rep injeccions, no té cicle de vida gestionat, no li funcionen @Transactional ni @Cacheable. Si necessites un objecte de Spring, demana'l per constructor.

Confiar en la coincidència de noms. Escriure el paràmetre tarifaEstudiant perquè Spring triï aquest bean funciona, però és fràgil: en reanomenar el paràmetre —una cosa que un IDE fa sense avisar— l'arrencada es trenca o, pitjor, s'injecta silenciosament un altre bean. Utilitza @Qualifier explícit.

Injectar ApplicationContext per fer getBean(...). És service locator, l'antipatró que la injecció de dependències va venir a substituir: amaga les dependències reals de la classe i fa les proves impossibles. Es justifica en eines i diagnòstics (com el VerificadorBeans de la lliçó anterior), mai en lògica de negoci.

Resoldre un cicle amb @Lazy. Ho repetim per la seva importància: no és una solució, és una anestèsia. Redissenya.

Consell: si el constructor passa de quatre o cinc paràmetres, atura't. Gairebé sempre significa que la classe té més d'una responsabilitat. Extreu, no augmentis.

Consell: depèn sempre d'interfícies a les fronteres entre capes. EstacioService depèn d'EstacioRepositori, no pas d'EstacioRepositoriEnMemoria. És el que permetrà que el mòdul 4 canviï la implementació sense tocar el servei. Dins d'una mateixa capa, en canvi, crear una interfície amb una sola implementació sol ser cerimònia inútil.

Consell: no anotis classes de domini. Estacio i els futurs Lloguer o Bicicleta són dades, es creen amb new i no participen en la injecció.

Exercicis

Exercici 1: tercera tarifa sense modificar el selector

Afegeix a la xarxa de Ribalta una tarifa TarifaJubilat: sense desbloqueig, 0,05 €/minut, amb els primers 30 minuts gratuïts. Comprova que apareix automàticament al SelectorTarifa construït a partir d'una List<CalculadoraTarifa>, sense modificar ni una línia de SelectorTarifa. Registra a l'arrencada les tarifes disponibles i l'import d'un lloguer de 45 minuts amb cadascuna.

Exercici 2: cadena ordenada de validacions

Implementa tres validacions de lloguer (ValidacioLloguer) amb @Order: la matrícula ha de tenir el format RB-NNNN; l'estació d'origen ha d'existir a la xarxa; l'estació ha de tenir com a mínim una bicicleta (simula-ho amb la capacitat). Injecta-les com a List<ValidacioLloguer> a LloguerService i verifica que s'executen en ordre llançant un lloguer no vàlid.

Exercici 3: trencar un cicle amb esdeveniments

Provoca deliberadament una dependència circular entre LloguerService i EstacioService (injecta cadascun a l'altre), arrenca l'aplicació i anota el missatge d'error. Després trenca el cicle publicant un esdeveniment LloguerIniciat des de LloguerService i escoltant-lo a EstacioService.


Solucions

Solució 1

package com.ciclourbana.lloguers;

import org.springframework.stereotype.Component;

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

/**
 * Tarifa per a més grans de 65 anys empadronats a Ribalta:
 * sense desbloqueig, 0,05 €/minut i 30 minuts gratuïts.
 */
@Component
public class TarifaJubilat implements CalculadoraTarifa {

    private static final BigDecimal PER_MINUT = new BigDecimal("0.05");
    private static final long MINUTS_GRATIS = 30;

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

    @Override
    public String nom() {
        return "jubilat";
    }
}

El selector, sense tocar (així és com ha de quedar):

package com.ciclourbana.lloguers;

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> perNomNegoci;

    public SelectorTarifa(List<CalculadoraTarifa> tarifes) {
        this.perNomNegoci = tarifes.stream()
                .collect(Collectors.toUnmodifiableMap(
                        CalculadoraTarifa::nom, Function.identity()));
        log.info("Tarifes disponibles a Ribalta: {}", perNomNegoci.keySet());
    }

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

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

I el comprovador d'arrencada:

package com.ciclourbana.lloguers;

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 DemostracioTarifes implements CommandLineRunner {

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

    private final SelectorTarifa selectorTarifa;

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

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

Sortida:

c.c.lloguers.SelectorTarifa        : Tarifes disponibles a Ribalta: [estandard, estudiant, jubilat]
c.c.l.DemostracioTarifes           : Lloguer de 45 min amb tarifa 'estandard': 5.90 €
c.c.l.DemostracioTarifes           : Lloguer de 45 min amb tarifa 'estudiant': 2.40 €
c.c.l.DemostracioTarifes           : Lloguer de 45 min amb tarifa 'jubilat': 0.75 €

Comentari: el punt de l'exercici és comprovar que SelectorTarifa no ha canviat. Estendre el sistema ha consistit únicament a afegir una classe. Error freqüent: oblidar @Component a la tarifa nova; llavors no apareix a la llista i la fallada és silenciosa, sense missatge d'error. Si alguna cosa no apareix en una col·lecció injectada, el primer que cal mirar és si la classe és realment un bean.

Solució 2

package com.ciclourbana.lloguers;

public interface ValidacioLloguer {
    void validar(String matricula, Long estacioId);
}
package com.ciclourbana.lloguers;

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

import java.util.regex.Pattern;

@Component
@Order(1)
public class ValidarFormatMatricula implements ValidacioLloguer {

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

    @Override
    public void validar(String matricula, Long estacioId) {
        if (matricula == null || !FORMAT.matcher(matricula).matches()) {
            throw new IllegalArgumentException(
                    "Matrícula no vàlida: '" + matricula + "'. Format esperat: RB-0142");
        }
    }
}
package com.ciclourbana.lloguers;

import com.ciclourbana.estacions.EstacioService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(2)
public class ValidarEstacioExisteix implements ValidacioLloguer {

    private final EstacioService estacioService;

    public ValidarEstacioExisteix(EstacioService estacioService) {
        this.estacioService = estacioService;
    }

    @Override
    public void validar(String matricula, Long estacioId) {
        estacioService.cercarPerId(estacioId).orElseThrow(() ->
                new IllegalArgumentException(
                        "L'estació " + estacioId + " no existeix a la xarxa de Ribalta"));
    }
}
package com.ciclourbana.lloguers;

import com.ciclourbana.estacions.EstacioService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(3)
public class ValidarEstacioOperativa implements ValidacioLloguer {

    private final EstacioService estacioService;

    public ValidarEstacioOperativa(EstacioService estacioService) {
        this.estacioService = estacioService;
    }

    @Override
    public void validar(String matricula, Long estacioId) {
        // Simulació: fins al mòdul 4 no hi ha inventari real de bicicletes
        estacioService.cercarPerId(estacioId)
                .filter(estacio -> estacio.capacitat() >= 8)
                .orElseThrow(() -> new IllegalStateException(
                        "L'estació " + estacioId + " està fora de servei"));
    }
}
package com.ciclourbana.lloguers;

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

import java.util.List;

@Service
public class LloguerService {

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

    private final CalculadoraTarifa calculadoraPerDefecte;   // el @Primary
    private final List<ValidacioLloguer> validacions;        // ordenades per @Order

    public LloguerService(CalculadoraTarifa calculadoraPerDefecte,
                          List<ValidacioLloguer> validacions) {
        this.calculadoraPerDefecte = calculadoraPerDefecte;
        this.validacions = validacions;
        log.info("LloguerService amb tarifa '{}' i {} validacions: {}",
                calculadoraPerDefecte.nom(),
                validacions.size(),
                validacions.stream().map(v -> v.getClass().getSimpleName()).toList());
    }

    public void iniciar(String matricula, Long estacioId) {
        validacions.forEach(v -> v.validar(matricula, estacioId));
        log.info("Lloguer iniciat: bicicleta {} a l'estació {}", matricula, estacioId);
    }
}

Sortida a l'arrencada i en provar amb una matrícula no vàlida:

c.c.lloguers.LloguerService : LloguerService amb tarifa 'estandard' i 3 validacions:
    [ValidarFormatMatricula, ValidarEstacioExisteix, ValidarEstacioOperativa]
...
java.lang.IllegalArgumentException: Matrícula no vàlida: 'RB-14'. Format esperat: RB-0142

Comentari: en injectar una List, Spring ordena per @Order de menor a major. Si treus els @Order, l'ordre passa a ser el de descobriment de l'escaneig, que és estable a la pràctica però no està garantit: no en depenguis mai. Consell: quan l'ordre importa de debò, deixa salts entre els valors (10, 20, 30) per poder inserir validacions intermèdies sense renumerar.

Solució 3

Primer, el cicle deliberat:

@Service
public class LloguerService {
    public LloguerService(EstacioService estacioService) { /* ... */ }
}

@Service
public class EstacioService {
    // Provoca el cicle
    public EstacioService(EstacioRepositori repo, LloguerService lloguerService) { /* ... */ }
}

L'arrencada falla amb el requadre ┌─────┐ ... └─────┘ mostrat a l'apartat 9. Aquest diagrama al log és una de les millors eines de diagnòstic de Spring Boot: diu exactament quins beans formen el cicle i on estan definits.

Ara la solució amb esdeveniments. L'esdeveniment, un record al paquet de lloguers:

package com.ciclourbana.lloguers;

import java.time.Instant;

/** Esdeveniment de domini: s'ha iniciat un lloguer a la xarxa. */
public record LloguerIniciat(String matricula, Long estacioId, Instant moment) {

    public LloguerIniciat(String matricula, Long estacioId) {
        this(matricula, estacioId, Instant.now());
    }
}

L'emissor:

package com.ciclourbana.lloguers;

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

@Service
public class LloguerService {

    private final ApplicationEventPublisher publicador;

    // Ja no injecta EstacioService: el cicle desapareix
    public LloguerService(ApplicationEventPublisher publicador) {
        this.publicador = publicador;
    }

    public void iniciar(String matricula, Long estacioId) {
        // ... validacions i lògica del lloguer ...
        publicador.publishEvent(new LloguerIniciat(matricula, estacioId));
    }
}

El receptor:

package com.ciclourbana.estacions;

import com.ciclourbana.lloguers.LloguerIniciat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

@Service
public class EstacioService {

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

    private final EstacioRepositori estacioRepositori;

    // Sense LloguerService: dependència unidireccional
    public EstacioService(EstacioRepositori estacioRepositori) {
        this.estacioRepositori = estacioRepositori;
    }

    @EventListener
    public void enIniciarseUnLloguer(LloguerIniciat esdeveniment) {
        log.info("Ancoratge alliberat a l'estació {} per la bicicleta {}",
                esdeveniment.estacioId(), esdeveniment.matricula());
        // Al mòdul 4 això actualitzarà el recompte persistit
    }

    // ... resta de mètodes de la lliçó anterior
}

Comentari: ApplicationEventPublisher és un bean que Spring proporciona sempre; injectar-lo no crea dependència amb ningú. El resultat és que LloguerService no sap qui reacciona als seus esdeveniments: podrien ser zero, un o cinc oients. L'acoblament passa de bidireccional a inexistent.

Dues precaucions que convé conèixer des d'ara: els @EventListener són síncrons per defecte (s'executen al mateix fil i dins de la mateixa transacció que l'emissor); si l'oient llança una excepció, la propaga a l'emissor. Fer-los asíncrons amb @Async es tracta a la lliçó 07-03.

Conclusió

La injecció de dependències ha deixat de ser una anotació que es copia. Saps que IoC és el principi —el framework crida el teu codi— i que DI és la seva aplicació a la construcció d'objectes. Has vist en un exemple concret els quatre problemes que causa un new dins d'una classe i com desapareixen en rebre el col·laborador per constructor. Coneixes els tres estils d'injecció i els cinc arguments pels quals només el constructor és defensable, amb la testabilitat i la pressió de disseny al capdavant. Saps com resol Spring un punt d'injecció: per tipus primer, amb @Qualifier per damunt de @Primary, i pel nom només com a últim recurs. Saps injectar totes les implementacions d'una interfície en un Map o una List ordenada amb @Order, un patró que fa extensible el sistema sense tocar el codi que el consumeix. Saps expressar una dependència opcional amb Optional, @Nullable o ObjectProvider. I saps que una dependència circular no es pedaça amb @Lazy: es redissenya extraient allò comú o invertint la direcció amb esdeveniments.

CicloUrbana ja té el seu primer sistema extensible: la interfície CalculadoraTarifa amb TarifaEstandard (marcada @Primary) i TarifaEstudiant, el SelectorTarifa que les indexa per nom de negoci i un LloguerService que orquestra validacions ordenades. Els imports continuen escrits a foc en constants; a la lliçó 02-05 els traurem a la configuració.

Abans d'això queda una pregunta que hem fregat diverses vegades sense respondre. Hem dit que Spring crea una instància de cada bean i que per això EstacioRepositoriEnMemoria utilitza un ConcurrentHashMap. Per què només una? Pot haver-n'hi més? Quant viu cada bean i què passa exactament entre que s'instancia i queda disponible? La lliçó següent, Àmbit i Cicle de Vida dels Beans, respon tot això: els àmbits disponibles, el perill de l'estat mutable en un singleton, el cicle de vida complet amb @PostConstruct i @PreDestroy, la inicialització mandrosa i el punt d'extensió —BeanPostProcessor— sobre el qual es recolza bona part de la màgia aparent de Spring.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats