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
- El model relacional complet de CicloUrbana
- Les quatre cardinalitats i la seva anotació
@ManyToOne: el costat propietari natural@OneToManyimappedBy- Costat propietari i invers: l'error clàssic
@OneToOneamb@MapsId@ManyToManyi per què gairebé mai no convéFetchType.LAZYenfront d'EAGERLazyInitializationException- El problema N+1
cascadeiorphanRemoval- Col·leccions de valors amb
@ElementCollection - Herència d'entitats
- Errors Comuns i Consells
- Exercicis
- 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 |
- 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.
@ManyToOne: el costat propietari natural
@ManyToOne: el costat propietari naturalMoltes 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.
@OneToMany i mappedBy
@OneToMany i mappedByDes 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").
- 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 |
@OneToOne amb @MapsId
@OneToOne amb @MapsIdCada 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).
@ManyToMany i per què gairebé mai no convé
@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.
FetchType.LAZY enfront d'EAGER
FetchType.LAZY enfront d'EAGERAquesta é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.
LazyInitializationException
LazyInitializationExceptionÉs l'excepció més famosa de JPA:
org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.estacions.Estacio#1] - no SessionQuè 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.
- 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; -- +11 + 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:
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à.
cascade i orphanRemoval
cascade i orphanRemovalcascade 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.
- Col·leccions de valors amb
@ElementCollection
@ElementCollectionDe 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.
- 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 = LAZYa les quatre. AmbEAGER, llistar els lloguers del dia portaria usuaris, bicicletes i estacions completes encara que el llistat només mostri data i import. A mésBicicletaiEstaciotenen les seves pròpies relacions, i elsJOINs'encadenarien.optional = falsea usuari, bicicleta i origen: un lloguer sense ells no té sentit. Permet a Hibernate fer servirINNER JOINi documenta la regla a l'esquema ambnullable = false.optionalper 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 = falsea 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»), perbicicleta_id(historial d'una bicicleta) i perinici(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; consultarIncidenciagenera unUNION ALLde les quatre, no es pot fer servirIDENTITYi 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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
