Les entitats de CicloUrbana ja viuen en taules, però estan aïllades. Una Bicicleta no sap a quina estació de Ribalta està aparcada; un Lloguer desa usuariId i bicicletaId com a números solts, sense cap garantia que apuntin a res real. El model relacional que vam dibuixar a 04-01 té fletxes i encara no n'hem traçat cap.

Aquesta lliçó les traça. És, de llarg, la part de JPA on més projectes es torcen, perquè les relacions introdueixen dos comportaments que no existeixen en un model en memòria: la càrrega mandrosa —un objecte que fingeix ser-hi i només va a buscar les seves dades quan el toques— i la propagació en cascada —una operació que es contagia a les entitats veïnes—. Mal entesos produeixen la LazyInitializationException que tothom ha patit i el problema N+1, la causa individual més comuna de lentitud en aplicacions amb ORM. Modelarem cada relació amb la seva anotació correcta i, sobretot, amb la comprensió de quin SQL genera.

Contingut

  1. El model relacional complet de CicloUrbana
  2. Les quatre cardinalitats i la seva anotació
  3. @ManyToOne: el costat propietari natural
  4. @OneToMany i mappedBy
  5. Costat propietari i invers: l'error clàssic
  6. @OneToOne amb @MapsId
  7. @ManyToMany i per què gairebé mai no convé
  8. FetchType.LAZY enfront d'EAGER
  9. LazyInitializationException
  10. El problema N+1
  11. cascade i orphanRemoval
  12. Col·leccions de valors amb @ElementCollection
  13. Herència d'entitats
  14. Errors Comuns i Consells
  15. Exercicis

  1. El model relacional complet de CicloUrbana

erDiagram
    ESTACIONS ||--o{ BICICLETES : "acull (0..n)"
    ESTACIONS ||--o{ LLOGUERS : "origen"
    ESTACIONS ||--o{ LLOGUERS : "destí"
    BICICLETES ||--|| FITXES_TECNIQUES : "té (1..1)"
    BICICLETES ||--o{ LLOGUERS : "es lloga a"
    BICICLETES ||--o{ INCIDENCIES : "acumula"
    USUARIS ||--o{ LLOGUERS : "realitza"
    USUARIS }o--o{ PROMOCIONS : "gaudeix"

Cada fletxa es tradueix en una anotació diferent:

Relació Cardinalitat Anotació a CicloUrbana
Bicicleta → Estacio Moltes a una @ManyToOne a Bicicleta
Estacio → bicicletes Una a moltes @OneToMany(mappedBy = "estacio")
Bicicleta → FitxaTecnica Una a una @OneToOne amb @MapsId
Lloguer → Usuari, Bicicleta, estacions Moltes a una (×4) Quatre @ManyToOne
Usuari ↔ Promocio Moltes a moltes @ManyToMany (o entitat intermèdia)
Bicicleta → incidències Una a moltes amb cascada @OneToMany + cascade + orphanRemoval

  1. Les quatre cardinalitats i la seva anotació

Cardinalitat Anotació On va la clau forana fetch per defecte Exemple
Molts a un @ManyToOne A la taula del costat «molts» EAGER Bicicleta → Estació
Un a molts @OneToMany A l'altra taula (costat propietari) LAZY Estació → bicicletes
Un a un @OneToOne A la taula del costat propietari EAGER Bicicleta → fitxa tècnica
Molts a molts @ManyToMany En una taula d'unió LAZY Usuari ↔ promocions

Dues columnes mereixen atenció immediata. La del fetch per defecte és un parany: @ManyToOne i @OneToOne carreguen EAGER, és a dir, porten l'entitat relacionada sempre, la facis servir o no; és l'origen de la majoria de problemes de rendiment amb JPA i a l'apartat 8 veurem que la solució és posar-les totes en LAZY. I la clau forana viu sempre en un sol lloc: en una relació bidireccional la columna física és en una de les dues taules, i aquesta és la que mana. Se'n diu costat propietari, i és el concepte de l'apartat 5.

  1. @ManyToOne: el costat propietari natural

Moltes bicicletes són en una estació. Al model relacional, la taula bicicletes té una columna estacio_id:

@Entity
@Table(name = "bicicletes")
public class Bicicleta extends EntitatAuditable {

    // ... id, matricula, estat, nivellBateria, versio

    @ManyToOne(fetch = FetchType.LAZY, optional = true)
    @JoinColumn(name = "estacio_id",
                foreignKey = @ForeignKey(name = "fk_bicicletes_estacio"))
    private Estacio estacio;

    public Estacio getEstacio() { return estacio; }
    public void setEstacio(Estacio e) { this.estacio = e; }
}
Element Què fa
@ManyToOne Moltes bicicletes apunten a una estació
fetch = LAZY No carregar l'estació fins que es faci servir
optional = true La columna admet nuls: una bici al carrer no té estació
@JoinColumn(name = ...) Nom de la columna de clau forana
foreignKey = @ForeignKey(name = ...) Anomena la restricció, en lloc de FK7a3b1c...

@ManyToOne és el costat propietari natural i no hi ha elecció possible: la clau forana només pot ser a la taula del costat «molts», perquè si fos a estacions una fila hauria d'apuntar a diverses bicicletes. La conseqüència pràctica: per assignar una bicicleta a una estació n'hi ha prou amb bicicleta.setEstacio(estacio), l'única operació que escriu a la base de dades.

optional no és cosmètica: amb optional = false, Hibernate sap que l'associació mai no és nul·la, pot fer servir INNER JOIN en lloc de LEFT JOIN i, en alguns casos, optimitzar la càrrega mandrosa.

El Lloguer acumula quatre relacions d'aquest tipus, i és un bon exemple de per què @JoinColumn necessita nom explícit:

@Entity
@Table(name = "lloguers")
public class Lloguer extends EntitatAuditable {

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuari_id", nullable = false) private Usuari usuari;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false) private Bicicleta bicicleta;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "estacio_origen_id", nullable = false) private Estacio estacioOrigen;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "estacio_desti_id")
    private Estacio estacioDesti;   // nul mentre el lloguer és en curs
}

Dues relacions diferents apunten a Estacio: sense @JoinColumn explícit, totes dues generarien noms derivats que podrien col·lidir o resultar incomprensibles. I estacioDesti és nul·la mentre el lloguer és en curs, cosa que modela un fet del negoci, no un descuit.

  1. @OneToMany i mappedBy

Des de l'estació volem navegar a les seves bicicletes. Com que la clau forana ja és a bicicletes, aquest costat és invers: no escriu res, només llegeix.

@Entity
@Table(name = "estacions")
public class Estacio extends EntitatAuditable {

    // ... id, nom, adreca, capacitat, ubicacio, versio

    @OneToMany(mappedBy = "estacio", fetch = FetchType.LAZY)
    private Set<Bicicleta> bicicletes = new HashSet<>();

    public Set<Bicicleta> getBicicletes() { return Collections.unmodifiableSet(bicicletes); }

    /** Mètodes auxiliars: sincronitzen TOTS DOS costats de la relació. */
    public void afegirBicicleta(Bicicleta bicicleta) {
        bicicletes.add(bicicleta);
        bicicleta.setEstacio(this);
    }

    public void treureBicicleta(Bicicleta bicicleta) {
        bicicletes.remove(bicicleta);
        bicicleta.setEstacio(null);
    }
}

mappedBy = "estacio" significa literalment: «aquesta relació ja està mapejada pel camp estacio de Bicicleta; jo només la reflecteixo». Sense mappedBy, JPA assumiria que són dues relacions independents i crearia una taula d'unió estacions_bicicletes que ningú no vol.

Per què els mètodes auxiliars són obligatoris. Java no sincronitza referències pel seu compte. Si fas només estacio.getBicicletes().add(bicicleta), la col·lecció en memòria conté la bicicleta, però bicicleta.getEstacio() continua sent null i, sobretot, la columna estacio_id no s'actualitza: en rellegir des de la base de dades, la bicicleta no és a l'estació. L'objecte i la fila es contradiuen. afegirBicicleta i treureBicicleta encapsulen la doble escriptura perquè sigui impossible oblidar-la, i retornar la col·lecció com a unmodifiableSet reforça la regla: qui la vulgui modificar, que passi pel mètode.

Per què Set i no List. Amb List, Hibernate pot esborrar tota la col·lecció i reinserir-la en eliminar un sol element. Amb Set i un equals/hashCode correcte (apartat 11 de 04-03) el comportament és predictible. Si l'ordre importa, List amb @OrderBy("matricula").

  1. Costat propietari i invers: l'error clàssic

És el concepte que cal tenir gravat, i es resumeix en una frase:

Només el costat propietari escriu a la base de dades. El costat invers, el que té mappedBy, s'ignora per complet en generar el SQL.

L'error clàssic, en versió executable: desti.getBicicletes().add(bici) dins d'un mètode @Transactional. Només toca el costat invers, així que no es genera cap UPDATE i la columna estacio_id no canvia. El mètode no falla, no avisa i no fa res: la col·lecció en memòria canvia fins que acaba la transacció i després tot torna a estar com estava. La versió correcta:

@Transactional
public void moureBicicleta(Long biciId, Long estacioId) {
    Bicicleta bici = bicicletaRepositori.findById(biciId)
            .orElseThrow(() -> new RecursNoTrobatException("Bicicleta", biciId));
    Estacio desti = estacioRepositori.findById(estacioId)
            .orElseThrow(() -> new RecursNoTrobatException("Estació", estacioId));

    if (desti.getBicicletes().size() >= desti.getCapacitat()) {
        throw new EstacioPlenaException(estacioId);
    }
    desti.afegirBicicleta(bici);   // escriu TOTS DOS costats
    // El dirty checking genera: UPDATE bicicletes SET estacio_id = ? WHERE id = ?
}

EstacioPlenaException és l'excepció del domini que vam definir a 03-06; el @RestControllerAdvice la converteix en un 409 amb ProblemDetail. Fixa't també que no hi ha cap crida a save(): la bicicleta és una entitat gestionada i el dirty checking se n'encarrega (04-07).

Situació S'escriu a la BD?
bici.setEstacio(desti) (propietari) Sí
desti.getBicicletes().add(bici) (invers) No
desti.afegirBicicleta(bici) (tots dos) Sí, i la memòria queda coherent

  1. @OneToOne amb @MapsId

Cada bicicleta de CicloUrbana té una fitxa tècnica: model, fabricant, número de sèrie, data de compra. Són dades voluminoses que gairebé mai no es consulten, així que separar-les a la seva pròpia taula manté àgil la consulta habitual de bicicletes.

@Entity
@Table(name = "fitxes_tecniques")
public class FitxaTecnica {

    @Id
    private Long id;   // sense @GeneratedValue: l'aporta @MapsId

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @MapsId
    @JoinColumn(name = "id", foreignKey = @ForeignKey(name = "fk_fitxes_bicicleta"))
    private Bicicleta bicicleta;

    @Column(name = "model", nullable = false, length = 80) private String model;
    @Column(name = "numero_serie", nullable = false, length = 40, updatable = false)
    private String numeroSerie;
    @Column(name = "data_compra", nullable = false) private LocalDate dataCompra;
}

I a Bicicleta, el costat invers:

@OneToOne(mappedBy = "bicicleta", fetch = FetchType.LAZY,
          cascade = CascadeType.ALL, orphanRemoval = true)
private FitxaTecnica fitxaTecnica;

Què aporta @MapsId. Sense ell, fitxes_tecniques tindria dues columnes: el seu propi id i una bicicleta_id. Amb @MapsId, la clau primària és la clau forana: la fitxa de la bicicleta 42 té id = 42. Això estalvia una columna i un índex, garanteix la unicitat a l'esquema —és impossible que dues fitxes apuntin a la mateixa bicicleta— i fa que els JOIN vagin per clau primària, el camí més ràpid.

Advertiment sobre el LAZY a @OneToOne. El costat invers (Bicicleta.fitxaTecnica) no pot ser realment mandrós: per retornar un proxy, Hibernate hauria de saber si existeix la fila relacionada, i per saber-ho l'ha de consultar. En carregar una bicicleta s'executa una consulta extra a fitxes_tecniques encara que ningú no faci servir la fitxa. El costat propietari sí que és mandrós de debò, perquè la clau forana és a la seva pròpia fila. La solució pràctica: evita els @OneToOne bidireccionals; deixa la relació només al costat propietari i demana la fitxa al repositori amb findById(bicicletaId).

  1. @ManyToMany i per què gairebé mai no convé

L'ajuntament de Ribalta llança promocions (bo estudiant, mes gratuït) i un usuari pot tenir-ne diverses, i cada promoció diversos usuaris. És una relació molts a molts de llibre:

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
    name = "usuaris_promocions",
    joinColumns = @JoinColumn(name = "usuari_id"),
    inverseJoinColumns = @JoinColumn(name = "promocio_id"),
    uniqueConstraints = @UniqueConstraint(
        name = "uk_usuari_promocio", columnNames = {"usuari_id", "promocio_id"})
)
private Set<Promocio> promocions = new HashSet<>();

I a Promocio, el costat invers: @ManyToMany(mappedBy = "promocions") private Set<Usuari> usuaris;.

Funciona. I tot i així la recomanació és substituir-lo per una entitat intermèdia, per un motiu que es descobreix sempre massa tard: la taula d'unió no pot tenir atributs propis. Tan bon punt el negoci pregunta «quan es va aplicar?» o «quants usos queden?», @ManyToMany es queda curt i cal refer el model amb dades ja en producció. La versió amb entitat intermèdia:

@Entity
@Table(name = "promocions_usuari",
       uniqueConstraints = @UniqueConstraint(name = "uk_promocio_usuari",
                                             columnNames = {"usuari_id", "promocio_id"}))
public class PromocioUsuari extends EntitatAuditable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "prom_usuari_seq")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuari_id", nullable = false)
    private Usuari usuari;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "promocio_id", nullable = false)
    private Promocio promocio;

    @Column(name = "aplicada_el", nullable = false) private Instant aplicadaEl;
    @Column(name = "usos_restants", nullable = false) private int usosRestants;
}
Aspecte @ManyToMany Entitat intermèdia
Atributs a la relació Impossible Sí
Consultable directament No Sí, amb el seu repositori
Auditoria (creatEl) No Sí, hereta d'EntitatAuditable
Complexitat inicial Menor Una mica més gran
Cost de migrar després Alt —

La regla de CicloUrbana: fes servir @ManyToMany només si estàs segur que la relació mai no tindrà atributs. Etiquetes d'una incidència, sí. Qualsevol cosa amb matisos de negoci, entitat intermèdia.

  1. FetchType.LAZY enfront d'EAGER

Aquesta és la decisió de rendiment més important de tota la lliçó.

Mode Quan carrega la relació Cost
EAGER Sempre, juntament amb l'entitat Un JOIN o una consulta extra en cada càrrega
LAZY Només en accedir al camp Res fins que es fa servir; pot fallar fora de la transacció

I els valors per defecte de l'especificació:

Anotació Per defecte És correcte?
@ManyToOne EAGER No, canvia'l
@OneToOne EAGER No, canvia'l
@OneToMany LAZY Sí
@ManyToMany LAZY Sí

Per què EAGER és una mala idea per defecte, amb un cas de CicloUrbana: si Lloguer deixés els seus quatre @ManyToOne en EAGER, consultar un lloguer dispararia:

select ... from lloguers l
  left join usuaris u on u.id = l.usuari_id
  left join bicicletes b on b.id = l.bicicleta_id
  left join estacions eo on eo.id = l.estacio_origen_id
  left join estacions ed on ed.id = l.estacio_desti_id
 where l.id = ?

I com que Bicicleta té al seu torn una Estacio i una FitxaTecnica en EAGER, aquests JOIN s'encadenen: amb quatre o cinc nivells, una consulta que havia de llegir una fila llegeix un producte cartesià de diversos milers. El pitjor és que no es pot desactivar per consulta: EAGER obliga sempre, fins i tot quan només vols l'import.

La regla de CicloUrbana, sense excepcions: escriu fetch = FetchType.LAZY a totes les associacions, encara que a @OneToMany i @ManyToMany sigui redundant. Quan una consulta concreta necessiti dades relacionades, es demanen explícitament amb JOIN FETCH o @EntityGraph (04-06). És la diferència entre decidir a cada consulta i patir una decisió presa a l'entitat.

  1. LazyInitializationException

És l'excepció més famosa de JPA:

org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.estacions.Estacio#1] - no Session

Què passa. Amb LAZY, Hibernate no posa l'entitat real al camp sinó un proxy: un objecte d'una subclasse generada que només desa l'id i una referència a la sessió. En cridar qualsevol dels seus mètodes, el proxy va a la base de dades a completar-se; si la sessió —el context de persistència— ja està tancada, no pot, i llança l'excepció.

public EstacioDetallResponse veureDetall(Long id) {   // sense @Transactional!
    Estacio estacio = estacioRepositori.findById(id).orElseThrow();
    // aquí la transacció implícita del repositori ja ha acabat
    return mapper.aDetall(estacio, estacio.getBicicletes()); // BUM!
}

Per què desactivar open-in-view la fa visible abans. Amb open-in-view: true (el valor per defecte, que a 04-02 vam desactivar), el context continua obert durant tota la petició HTTP, així que l'accés mandrós funciona... disparant consultes durant la serialització JSON, sense que ningú no ho vegi. Amb open-in-view: false, l'excepció salta en desenvolupament, assenyalant exactament on falta carregar dades. És una errada sorollosa que substitueix una lentitud silenciosa: un canvi excel·lent.

Les quatre solucions, de millor a pitjor:

Solució Quan fer-la servir Valoració
Carregar el que cal a la consulta (JOIN FETCH, @EntityGraph) Gairebé sempre La correcta
Mapejar a DTO dins de la transacció Sempre, com a complement La correcta
Ampliar la transacció amb @Transactional al servei Quan la feina pertany al servei Acceptable
Posar la relació en EAGER Mai Canvia un error per lentitud permanent
Reactivar open-in-view Mai Amaga el problema fins a producció

La versió correcta de l'exemple anterior:

@Transactional(readOnly = true)
public EstacioDetallResponse veureDetall(Long id) {
    Estacio estacio = estacioRepositori.cercarAmbBicicletes(id)   // JOIN FETCH
            .orElseThrow(() -> new RecursNoTrobatException("Estació", id));
    return mapper.aDetall(estacio);   // el mapatge passa DINS de la transacció
}

Convergeixen aquí dues idees ja assentades: el servei retorna DTOs completament mapejats (03-05) i el mapatge passa dins de la transacció. Amb aquesta disciplina, la LazyInitializationException deixa d'aparèixer.

  1. El problema N+1

És el problema de rendiment característic dels ORM, i mereix entendre's amb un cas concret.

L'ajuntament demana un llistat de les quatre estacions amb el nombre de bicicletes de cadascuna:

@Transactional(readOnly = true)
public List<EstacioResponse> llistar() {
    List<Estacio> estacions = estacioRepositori.findAll();   // 1 consulta
    return estacions.stream()
            .map(e -> new EstacioResponse(e.getId(), e.getNom(),
                    e.getBicicletes().size()))               // N consultes
            .toList();
}

El SQL resultant:

select e1_0.id, e1_0.nom, ... from estacions e1_0;                -- 1
select b1_0.id, ... from bicicletes b1_0 where b1_0.estacio_id=1;  -- +1
select b1_0.id, ... from bicicletes b1_0 where b1_0.estacio_id=2;  -- +1
select b1_0.id, ... from bicicletes b1_0 where b1_0.estacio_id=3;  -- +1
select b1_0.id, ... from bicicletes b1_0 where b1_0.estacio_id=4;  -- +1

1 + N consultes, d'aquí el nom. Amb les 4 estacions de Ribalta són 5 i ningú no ho nota; quan la xarxa creixi a 200 seran 201, i a 2 ms d'anada i tornada cadascuna l'endpoint passa de 10 ms a més de 400 ms sense que cap consulta individual sigui lenta. Aquí hi ha la traïció: el profiler no troba culpable perquè totes són ràpides.

Com detectar-lo. Amb la configuració de registres de 04-02 (org.hibernate.SQL: DEBUG i hibernate.generate_statistics: true), al final de cada petició apareix:

Session Metrics { 201 JDBC statements, 200 collections fetched, 1204 entities loaded }

Dues-centes una sentències per llistar estacions és un N+1 de manual. La regla mental: el nombre de consultes d'un endpoint ha de ser constant, no proporcional al nombre de resultats.

Les tres solucions, que desenvoluparem a 04-06 i 09-01:

// 1. JOIN FETCH: una sola consulta amb JOIN. La més directa.
@Query("select distinct e from Estacio e left join fetch e.bicicletes")
List<Estacio> cercarTotesAmbBicicletes();

// 2. @EntityGraph: declaratiu, sense escriure JPQL.
@EntityGraph(attributePaths = "bicicletes") List<Estacio> findAll();

// 3. @BatchSize (Hibernate): agrupa les N consultes en N/mida.
@OneToMany(mappedBy = "estacio") @BatchSize(size = 25)
private Set<Bicicleta> bicicletes = new HashSet<>();
Solució Consultes Avantatge Inconvenient
JOIN FETCH 1 Òptim en nombre de viatges Trenca la paginació amb col·leccions
@EntityGraph 1 Declaratiu i reutilitzable Mateix límit amb la paginació
@BatchSize 1 + N/mida Compatible amb la paginació No arriba a una sola consulta

L'advertiment sobre la paginació és important: en fer JOIN FETCH d'una col·lecció, el LIMIT de SQL s'aplica a les files del producte cartesià, no a les estacions. Hibernate ho detecta, avisa amb HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory i carrega tot en memòria per paginar després. Allà @BatchSize és la resposta correcta.

I hi ha una quarta solució, sovint la millor: no carregar entitats en absolut. Si només necessites el recompte, demana'l amb una projecció (04-06):

@Query("""
       select new com.ciclourbana.estacions.dto.EstacioResum(
              e.id, e.nom, count(b))
         from Estacio e left join e.bicicletes b
        group by e.id, e.nom
       """)
List<EstacioResum> resumOcupacio();

Una sola consulta, sense entitats gestionades i sense portar dades que ningú no mirarà.

  1. cascade i orphanRemoval

cascade propaga una operació del pare als fills.

Tipus Què propaga Ús típic a CicloUrbana
PERSIST Desar el pare desa els fills nous Lloguer → incidències creades amb ell
MERGE Fusionar el pare fusiona els fills Actualitzacions en bloc
REMOVE Esborrar el pare esborra els fills Bicicleta → la seva fitxa tècnica
REFRESH Recarregar el pare recarrega els fills Poc freqüent
DETACH Separar el pare separa els fills Poc freqüent
ALL Els cinc anteriors Només en composició estricta

Quan aplicar cascada. La pregunta correcta no és tècnica sinó de domini: el fill existeix per si mateix o només té sentit dins del pare? Una FitxaTecnica no existeix sense la seva bicicleta (cascade = ALL, orphanRemoval = true); una Incidencia, tampoc (cascade = {PERSIST, MERGE}, orphanRemoval = true). En canvi una Bicicleta sí que existeix sense la seva estació —es mou a una altra o es porta al taller, i esborrar una estació no ha d'esborrar les seves bicicletes—, igual que un Usuari existeix al marge dels seus lloguers: sense cascada en tots dos casos.

@Entity
@Table(name = "bicicletes")
public class Bicicleta extends EntitatAuditable {

    @OneToMany(mappedBy = "bicicleta",
               cascade = {CascadeType.PERSIST, CascadeType.MERGE},
               orphanRemoval = true,
               fetch = FetchType.LAZY)
    private Set<Incidencia> incidencies = new HashSet<>();

    public void reportarIncidencia(Incidencia incidencia) {
        incidencies.add(incidencia);
        incidencia.setBicicleta(this);
        if (incidencia.esBloquejant()) this.estat = EstatBicicleta.RETIRADA;
    }

    public void resoldreIncidencia(Incidencia incidencia) {
        incidencies.remove(incidencia);   // amb orphanRemoval, s'ESBORRA de la BD
    }
}

orphanRemoval enfront de CascadeType.REMOVE. Es confonen constantment:

CascadeType.REMOVE orphanRemoval = true
Esborra els fills en esborrar el pare Sí Sí
Esborra un fill en treure'l de la col·lecció No (queda orfe amb FK nul·la o falla) Sí

orphanRemoval és més fort i expressa millor la composició: el fill no pot existir fora del pare. Per això a resoldreIncidencia n'hi ha prou amb treure-la de la col·lecció perquè desaparegui de la taula.

Advertiment: cascade = REMOVE sobre una col·lecció gran carrega totes les entitats filles en memòria i emet un DELETE per cadascuna. Esborrar una bicicleta amb 5.000 registres de sensors generaria 5.001 sentències. Per a volums així, un DELETE massiu amb @Modifying (04-06) o un ON DELETE CASCADE a l'esquema (04-08) són molt millors.

  1. Col·leccions de valors amb @ElementCollection

De vegades una entitat necessita una llista de valors simples que no mereixen ser entitats: les etiquetes d'una incidència, per exemple.

@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "incidencia_etiquetes",
                 joinColumns = @JoinColumn(name = "incidencia_id"),
                 foreignKey = @ForeignKey(name = "fk_etiquetes_incidencia"))
@Column(name = "etiqueta", length = 40)
private Set<String> etiquetes = new HashSet<>();

Es crea una taula incidencia_etiquetes amb dues columnes, però les seves files no són entitats: sense id propi, sense repositori i no consultables per separat.

Aspecte @ElementCollection @OneToMany a una entitat
Identitat pròpia No Sí
Repositori No Sí
Consultable per separat No Sí
En modificar la col·lecció Hibernate esborra i insereix tot UPDATE selectiu
Adequat per a Valors simples, llistes curtes Qualsevol cosa amb vida pròpia

Aquest «esborra i insereix tot» és la limitació clau: modificar un element d'una col·lecció de 500 valors genera 501 sentències. @ElementCollection és per a llistes curtes i estables. També admet @Embeddable: per exemple, l'històric de posicions GPS d'un lloguer com a col·lecció d'Ubicacio.

  1. Herència d'entitats

Les incidències de CicloUrbana no són totes iguals: una bateria esgotada, un acte de vandalisme i una avaria mecànica comparteixen camps però tenen dades pròpies. JPA ofereix tres estratègies.

@Entity
@Table(name = "incidencies")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "tipus", discriminatorType = DiscriminatorType.STRING,
                     length = 20)
public abstract class Incidencia extends EntitatAuditable {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "incidencies_seq")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false)
    private Bicicleta bicicleta;

    @Column(name = "descripcio", nullable = false, length = 500)
    private String descripcio;

    public abstract boolean esBloquejant();
}

@Entity
@DiscriminatorValue("BATERIA")
public class IncidenciaBateria extends Incidencia {
    @Column(name = "nivell_detectat") private Integer nivellDetectat;
    @Override public boolean esBloquejant() { return nivellDetectat < 5; }
}

@Entity
@DiscriminatorValue("VANDALISME")
public class IncidenciaVandalisme extends Incidencia {
    @Column(name = "denuncia_policial", length = 40) private String denunciaPolicial;
    @Override public boolean esBloquejant() { return true; }
}
Estratègia Com es desa Consultes NOT NULL als fills Quan triar-la
SINGLE_TABLE (per defecte) Una taula amb totes les columnes i un discriminador Ràpides, sense JOIN Impossible Pocs camps propis, prioritat al rendiment
JOINED Una taula base + una per subclasse JOIN per cada nivell Sí Molts camps propis, prioritat a la integritat
TABLE_PER_CLASS Una taula completa per subclasse UNION ALL en consultar el pare Sí Gairebé mai; complica claus i consultes polimòrfiques

Per a CicloUrbana triem SINGLE_TABLE, amb la seva contrapartida assumida: les columnes específiques (nivell_detectat, denuncia_policial) han d'admetre nuls, perquè una fila de vandalisme no té nivell de bateria. És un preu raonable amb pocs camps propis; si cada tipus acumulés deu columnes obligatòries, JOINED seria l'elecció correcta.

Convé recordar a més la distinció amb 04-03: @MappedSuperclass no és herència d'entitats. EntitatAuditable no és consultable ni polimòrfica, només aporta columnes a cada taula filla. Incidencia sí que és una entitat, i incidenciaRepositori.findAll() retorna instàncies de les seves subclasses.

Errors Comuns i Consells

Deixar @ManyToOne i @OneToOne amb el seu fetch per defecte. Són EAGER, i aquest oblit genera JOIN encadenats a cada consulta. Escriu fetch = FetchType.LAZY sempre, encara que sembli redundant.

Modificar només el costat invers. No genera SQL. El símptoma és «deso i no es desa» sense cap error. Fes servir sempre mètodes auxiliars que sincronitzin tots dos costats.

Oblidar mappedBy en un @OneToMany. JPA crea una taula d'unió inesperada. Si a la consola d'H2 apareix una taula estacions_bicicletes, aquest és el diagnòstic.

Confondre orphanRemoval amb CascadeType.REMOVE. El primer esborra el fill en treure'l de la col·lecció; el segon només en esborrar el pare.

Posar cascade = ALL per costum. Esborrar una estació esborraria totes les seves bicicletes. Aplica cascada només quan el fill no tingui vida pròpia.

Fer servir @ManyToMany per a relacions amb matisos. Quan el negoci demani una data o un comptador a la relació, caldrà migrar el model amb dades en producció.

Consell: compta les consultes dels teus endpoints. Activa generate_statistics i comprova que el nombre de sentències no creix amb el nombre de resultats. És la millor xarxa contra el N+1.

Consell: fes servir Set a les col·leccions i equals/hashCode correctes. Amb List, Hibernate pot esborrar i reinserir la col·lecció sencera en eliminar un element.

Consell: no modelis relacions que ningú no navega. Cada associació bidireccional afegeix complexitat. Si CicloUrbana mai no necessita anar d'un usuari als seus lloguers en memòria, deixa només Lloguer → Usuari i consulta per repositori.

Exercicis

Exercici 1: modelar Lloguer complet

Escriu l'entitat Lloguer amb les seves quatre relacions (Usuari, Bicicleta, estació d'origen i estació de destí), sabent que la de destí és nul·la mentre el lloguer és en curs. Justifica el fetch, l'optional i l'absència o presència de cascada en cadascuna. Indica també quins índexs declararies.

Exercici 2: diagnosticar i corregir un N+1

Aquest endpoint de CicloUrbana triga 1,2 segons amb 200 estacions. Identifica el problema, calcula el nombre de consultes i proposa tres solucions diferents indicant quina triaries i per què.

@GetMapping("/api/v1/estacions/ocupacio")
public List<OcupacioResponse> ocupacio() {
    return estacioRepositori.findAll().stream()
            .map(e -> new OcupacioResponse(
                    e.getNom(),
                    e.getCapacitat(),
                    e.getBicicletes().stream()
                        .filter(b -> b.getEstat() == EstatBicicleta.DISPONIBLE)
                        .count()))
            .toList();
}

Exercici 3: triar l'estratègia d'herència

L'ajuntament amplia les incidències amb quatre tipus: bateria (nivell detectat), vandalisme (denúncia policial, fotos, cost estimat de reparació), avaria mecànica (component, gravetat, taller assignat, data prevista) i accident (part d'accident, asseguradora, ferits, informe policial, cost). Tria l'estratègia d'herència adequada i justifica-la enfront de les altres dues.

Solucions

Solució 1.

@Entity
@Table(name = "lloguers", indexes = {
    @Index(name = "idx_lloguers_usuari", columnList = "usuari_id"),
    @Index(name = "idx_lloguers_bicicleta", columnList = "bicicleta_id"),
    @Index(name = "idx_lloguers_inici", columnList = "inici")
})
public class Lloguer extends EntitatAuditable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "lloguers_seq")
    @SequenceGenerator(name = "lloguers_seq", sequenceName = "lloguers_id_seq",
                       allocationSize = 50)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuari_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_lloguers_usuari"))
    private Usuari usuari;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_lloguers_bicicleta"))
    private Bicicleta bicicleta;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "estacio_origen_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_lloguers_estacio_origen"))
    private Estacio estacioOrigen;

    @ManyToOne(fetch = FetchType.LAZY)   // optional = true per defecte
    @JoinColumn(name = "estacio_desti_id",
                foreignKey = @ForeignKey(name = "fk_lloguers_estacio_desti"))
    private Estacio estacioDesti;

    @Column(name = "inici", nullable = false, updatable = false) private Instant inici;
    @Column(name = "fi") private Instant fi;
    @Column(name = "import_total", precision = 8, scale = 2) private BigDecimal importTotal;
    @Version @Column(name = "versio", nullable = false) private Long versio;

    protected Lloguer() { }
}

Justificació de cada decisió:

  • fetch = LAZY a les quatre. Amb EAGER, llistar els lloguers del dia portaria usuaris, bicicletes i estacions completes encara que el llistat només mostri data i import. A més Bicicleta i Estacio tenen les seves pròpies relacions, i els JOIN s'encadenarien.
  • optional = false a usuari, bicicleta i origen: un lloguer sense ells no té sentit. Permet a Hibernate fer servir INNER JOIN i documenta la regla a l'esquema amb nullable = false.
  • optional per defecte (true) al destí: és nul durant tot el lloguer i s'omple en tornar la bicicleta. És un fet del negoci, no una omissió.
  • Sense cascada en cap. L'usuari, la bicicleta i les estacions existeixen per si mateixos. Cascada aquí significaria que esborrar un lloguer esborra l'usuari: un desastre.
  • updatable = false a usuari, bicicleta i origen: un cop iniciat el lloguer, aquestes dades són immutables. És integritat històrica.
  • Índexs: per usuari_id (consulta «els meus lloguers»), per bicicleta_id (historial d'una bicicleta) i per inici (informes per rang de dates). La clau forana no crea índex automàticament a PostgreSQL, al contrari que a MySQL: cal declarar-lo.

Solució 2.

El problema és un N+1. findAll() executa 1 consulta i getBicicletes() dispara una consulta mandrosa per cada estació: 201 consultes amb 200 estacions. A ~5 ms cada anada i tornada, uns 1,2 segons, sense que cap consulta individual sigui lenta.

Solució A — JOIN FETCH: @Query("select distinct e from Estacio e left join fetch e.bicicletes"). Una sola consulta, però porta totes les bicicletes de totes les estacions a memòria encara que només calgui un recompte.

Solució B — @EntityGraph: @EntityGraph(attributePaths = "bicicletes") sobre findAll(). Equivalent en resultat, declaratiu i sense JPQL d'associacions; mateix inconvenient.

Solució C — projecció amb agregació (la que triaria):

@Query("""
       select new com.ciclourbana.estacions.dto.OcupacioResponse(
              e.nom, e.capacitat, count(b.id))
         from Estacio e
         left join e.bicicletes b on b.estat = com.ciclourbana.bicicletes.EstatBicicleta.DISPONIBLE
        group by e.id, e.nom, e.capacitat
        order by e.nom
       """)
List<OcupacioResponse> consultarOcupacio();

Per què la C. Les tres eliminen el N+1, però A i B carreguen totes les bicicletes de la xarxa —amb 200 estacions i 30 bicicletes cadascuna, 6.000 entitats gestionades al context de persistència— per acabar comptant. La C fa que la base de dades compti, que és exactament per al que està optimitzada, i retorna 200 files amb tres columnes. Menys SQL, menys memòria, menys recol·lecció d'escombraries i cap risc de LazyInitializationException perquè no hi ha entitats gestionades. A més el resultat és directament el DTO de resposta.

Regla general: si només necessites dades agregades, no carreguis entitats. Les projeccions es desenvolupen a 04-06.

Solució 3. L'estratègia adequada és JOINED.

Comptant camps propis: bateria 1, vandalisme 4, avaria 4, accident 5. Són 14 columnes específiques repartides entre quatre subclasses.

Amb SINGLE_TABLE, la taula incidencies tindria aquestes 14 columnes més les comunes, i totes les específiques obligatòriament nul·lables. Conseqüències: no es pot exigir a l'esquema que una incidència d'accident tingui asseguradora, cada fila malbarata espai en columnes buides, i qualsevol persona que consulti la taula directament necessita saber quines columnes apliquen a cada tipus. Amb quatre tipus i camps obligatoris diferents, la integritat quedaria enterament en mans del codi Java.

Amb JOINED hi ha una taula incidencies amb el comú (id, bicicleta, descripció, data, auditoria) i quatre taules filles —incidencies_bateria, incidencies_vandalisme, incidencies_averia, incidencies_accident— la clau primària de les quals és també clau forana a la base, i cadascuna declara els seus NOT NULL on correspon. L'esquema queda normalitzat i descriu fidelment el domini. El cost és un JOIN per nivell en consultar; amb un sol nivell d'herència i un volum d'incidències baix comparat amb els lloguers, és assumible, i les consultes més freqüents —«incidències obertes d'aquesta bicicleta»— només toquen la taula base.

Per què no les altres dues:

  • SINGLE_TABLE: 14 columnes nul·lables i cap garantia d'integritat a la base de dades. Seria correcta si cada subclasse tingués un o dos camps propis i el volum fos molt alt.
  • TABLE_PER_CLASS: quatre taules independents que repeteixen les columnes comunes; consultar Incidencia genera un UNION ALL de les quatre, no es pot fer servir IDENTITY i les claus foranes cap a la taula base són impossibles. Pràcticament mai no és la resposta.

Conclusió

El model de CicloUrbana ja té fletxes. Saps traduir cada cardinalitat a la seva anotació i, més important, saps on viu la clau forana i què implica: el costat propietari és l'únic que escriu, el costat amb mappedBy només reflecteix, i modificar únicament l'invers és l'error que no falla, no avisa i no desa res. Has escrit @ManyToOne amb @JoinColumn anomenada —imprescindible quan Lloguer apunta dues vegades a Estacio—, @OneToMany amb mappedBy i els seus mètodes auxiliars que sincronitzen tots dos costats, @OneToOne amb @MapsId perquè la clau primària sigui la forana, i saps per què convé evitar els @OneToOne bidireccionals. Coneixes @ManyToMany i, sobretot, per què gairebé sempre se substitueix per una entitat intermèdia: tan bon punt el negoci pregunta «quan?» o «quantes vegades?», la taula d'unió es queda curta i la migració ja és cara.

Tens les dues regles que governen el rendiment amb un ORM. La primera: fetch = FetchType.LAZY a totes les associacions, contra els valors per defecte EAGER de @ManyToOne i @OneToOne, perquè EAGER pren a l'entitat una decisió que correspon a cada consulta. La segona: el nombre de consultes d'un endpoint ha de ser constant, no proporcional al nombre de resultats, que és la formulació operativa del problema N+1, detectable amb generate_statistics i resoluble amb JOIN FETCH, @EntityGraph, @BatchSize o —sovint la millor— no carregant entitats en absolut. I entens la LazyInitializationException no com un enemic sinó com un avís valuós que arriba abans gràcies a haver desactivat open-in-view a 04-02. Has aplicat cascade i orphanRemoval segons un criteri de domini —el fill existeix per si mateix?—, modelat col·leccions de valors amb @ElementCollection i triat entre les tres estratègies d'herència per a les incidències de Ribalta.

El que continua faltant és com s'usen totes aquestes entitats. EstacioRepositoriEnMemoria encara és allà, amb el seu ConcurrentHashMap i els seus mètodes escrits a mà, i hem anat escrivint consultes d'exemple sobre un repositori que encara no existeix. La lliçó 04-05, Ús de Repositoris de Spring Data, ho resol: veurem la jerarquia d'interfícies i quina triar, com Spring fabrica la implementació a l'arrencada mitjançant proxies, i substituirem EstacioRepositoriEnMemoria per EstacioRepositori extends JpaRepository<Estacio, Long> comprovant quant codi desapareix de cop. Repassarem la semàntica exacta de cada mètode heretat —inclosa la diferència entre findById i getReferenceById, i el fet que save fa merge si l'entitat ja té id—, afegirem paginació i ordenació a l'API de Ribalta amb Pageable, i veurem per què mai no es retorna un Page directament al client.

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