La lliçó anterior va acabar amb una ironia: BiblioTech té un @Transactional sobre prestar que no fa absolutament res, perquè a sota no hi ha cap base de dades que pugui confirmar o revertir. Tota la persistència continua sent el que era al mòdul 7: fitxers CSV escrits a mà.
Aquesta lliçó els substitueix per una base de dades relacional de veritat.
Hibernate és l'ORM més usat del món Java, i la implementació de referència de Jakarta Persistence (abans JPA). La seva feina és traduir entre dos móns que no encaixen: el dels objectes, amb herència, referències i navegació; i el de les taules, amb files, columnes i claus foranes. Aquesta feina és tediosa, repetitiva i fàcil de fer malament, i per això existeix una eina per automatitzar-la.
Però un ORM no és una capa transparent que es pugui fer servir sense entendre-la. És probablement la peça de la qual més gent abusa sense saber què està passant: consultes que es multipliquen per cent, excepcions que apareixen fora de la transacció, entitats que es desen soles sense que ningú cridi save, i esquemes de producció destruïts per una propietat mal posada. Tot això té explicació, i aquesta lliçó la dóna.
L'avantatge amb què arribes és el de sempre: Hibernate llegeix @Entity i @Column per reflexió, exactament igual que el teu ExportadorAnotat llegia @CampCsv. I Spring Data JPA et donarà repositoris funcionant sense escriure la implementació, amb el mateix Proxy.newProxyInstance de 10-03.
En acabar sabràs què automatitza un ORM i què costa; mapejaràs entitats i relacions completes; entendràs el context de persistència i els estats d'una entitat; sabràs per què apareix la LazyInitializationException i què és el problema N+1, amb les seves solucions; escriuràs consultes JPQL amb paràmetres —i sabràs per què concatenar cadenes és una vulnerabilitat—; manejaràs transaccions, propagació i bloqueig optimista; i BiblioTech desarà les seves dades a H2 amb Material, Empleat i Prestec com a entitats reals.
Contingut
- El desajust objecte-relacional
- Què es guanya i què es perd enfront de JDBC
- JDBC en vint línies: la línia base
- JPA enfront d'Hibernate: especificació i implementació
- Posar en marxa JPA amb H2 a BiblioTech
- La primera entitat:
@Entity,@Table,@Id @GeneratedValuei les seves estratègies@Columni el mapatge de tipus@Enumerated: per quèSTRINGi maiORDINALLocalDate,Instanti els conversors propis@Embeddablei@Embedded- Relacions: els quatre tipus
- Costat propietari i
mappedBy FetchType: càrrega mandrosa enfront d'ansiosa- La
LazyInitializationException - El problema N+1
- Resoldre'l:
JOIN FETCHi@EntityGraph CascadeTypeiorphanRemoval- El context de persistència i l'
EntityManager - Els quatre estats d'una entitat
- La comprovació de canvis automàtica
flushi l'ordre de les operacions- Les operacions:
persist,merge,find,getReference,remove - Consultes: JPQL
- Consultes amb nom, API de criteris i SQL natiu
- Injecció SQL: per què mai no es concatena
- Transaccions:
@Transactionalamb base de dades real - Propagació i aïllament
- Bloqueig optimista amb
@Version - Memòries cau de primer i segon nivell
ddl-autoi per què maiupdateen producció- Spring Data JPA
- BiblioTech: de CSV a H2
- Errors comuns i consells
- Exercicis
- El desajust objecte-relacional
El problema té nom propi: desajust d'impedància objecte-relacional (object-relational impedance mismatch). El model d'objectes i el model relacional es van inventar per a coses diferents i no encaixen de forma natural.
Els punts de fricció concrets:
| Concepte | En objectes | En taules |
|---|---|---|
| Identitat | == (referència) i equals (valor): dos conceptes |
Clau primària: un de sol |
| Herència | Material segellada amb Llibre, Revista, Dvd |
No existeix |
| Associació | Referència dirigida (prestec.material()) |
Clau forana bidireccional per naturalesa |
| Navegació | Encadenada: prestec.material().editorial().pais() |
Amb JOIN, tot de cop |
| Granularitat | Objectes petits: Adreca, Diner |
Columnes a la mateixa fila |
| Col·leccions | List, Set, Map |
Files relacionades |
| Tipus | LocalDate, Optional, enums, record |
DATE, VARCHAR, NUMERIC |
null |
Absència de referència | NULL amb semàntica de tres valors |
Fixa't en la fila de l'herència, perquè és la més brutal: el model relacional no té herència. La jerarquia segellada Material de BiblioTech, que a 10-06 permetia switch exhaustius verificats pel compilador, senzillament no es pot expressar en SQL. Algú ha de decidir com es representa, i hi ha tres estratègies diferents, cadascuna amb els seus compromisos.
I la de la navegació és la que més problemes de rendiment causa: en Java, prestec.material().editorial() és gratis; en SQL, cada salt pot ser una consulta. D'aquí surt el problema N+1 de l'apartat 16.
Un ORM (Object-Relational Mapper) és una eina que tradueix automàticament entre els dos móns. No elimina el desajust: el gestiona. I saber que existeix és la diferència entre fer servir Hibernate bé i patir-lo.
- Què es guanya i què es perd enfront de JDBC
Amb honestedat, perquè hi ha opinions fortes en tots dos sentits i totes dues tenen raó en part.
| Aspecte | JDBC directe | ORM (Hibernate/JPA) |
|---|---|---|
| Codi repetitiu | Moltíssim: ResultSet a objecte, a mà, per columna |
Gairebé cap |
| Control del SQL | Total | Parcial; es pot recuperar amb SQL natiu |
| Corba d'aprenentatge | Baixa (si saps SQL) | Alta |
| Rendiment senzill | Predictible | Bo, amb memòries cau que ajuden |
| Rendiment mal fet servir | Dolent per culpa teva | Catastròfic (N+1, càrrega ansiosa massiva) |
| Portabilitat entre motors | Baixa: el SQL varia | Alta: dialectes |
| Consultes complexes / informes | Natural | Incòmode; s'acaba fent servir SQL natiu |
| Escriptura d'un graf d'objectes | Molt laboriosa | Trivial: cascades |
| Transparència | Veus cada sentència | Genera SQL que no veus si no el demanes |
| Memòria cau | La fas tu | Primer i segon nivell inclosos |
La regla professional raonable:
- ORM per al CRUD del domini, que és el 80 % del codi d'una aplicació de gestió: desar un préstec, carregar un empleat, actualitzar un material.
- SQL directe (JDBC,
JdbcTemplateo jOOQ) per a informes, agregacions complexes i processos massius.
Totes dues coses conviuen perfectament al mateix projecte, i això és el que fa BiblioTech.
- JDBC en vint línies: la línia base
Per entendre què automatitza Hibernate, primer cal veure què calia escriure. Això és JDBC pur llegint préstecs:
package com.nexussoftware.bibliotech.persistencia;
import java.sql.*;
import java.time.LocalDate;
import java.util.*;
public class RepositoriPrestecsJdbc {
private final DataSource dataSource;
public RepositoriPrestecsJdbc(DataSource dataSource) {
this.dataSource = dataSource;
}
public List<Prestec> cercarPerEmpleat(String correuEmpleat) {
String sql = """
SELECT id, isbn, empleat_correu, data_prestec,
data_venciment, data_devolucio
FROM prestecs
WHERE empleat_correu = ?
""";
List<Prestec> resultat = new ArrayList<>();
// try-with-resources del modul 6: tanca els tres recursos en ordre invers
try (Connection connexio = dataSource.getConnection();
PreparedStatement sentencia = connexio.prepareStatement(sql)) {
sentencia.setString(1, correuEmpleat); // parametre, MAI concatenacio
try (ResultSet files = sentencia.executeQuery()) {
while (files.next()) {
// Mapatge MANUAL columna a columna: aixo es el que automatitza l'ORM
Date devolucio = files.getDate("data_devolucio");
resultat.add(new Prestec(
files.getLong("id"),
files.getString("isbn"),
files.getString("empleat_correu"),
files.getDate("data_prestec").toLocalDate(),
files.getDate("data_venciment").toLocalDate(),
devolucio == null
? Optional.empty()
: Optional.of(devolucio.toLocalDate())));
}
}
} catch (SQLException e) {
throw new PersistenciaException("Error en cercar prestecs de " + correuEmpleat, e);
}
return resultat;
}
}El que hi ha aquí, i el que Hibernate eliminarà:
- SQL escrit a mà per a cada consulta.
- Mapatge columna a columna, amb
getLong,getString,getDate. - Conversió de tipus:
java.sql.DateaLocalDate,nullaOptional.empty(). - Gestió de recursos: tres
try-with-resourcesimbricats. - Traducció d'excepcions:
SQLExceptiona la jerarquia del mòdul 6. - Noms de columna com a cadenes:
"data_venciment"mal escrit falla en execució.
Multiplica això per les quaranta consultes d'una aplicació real i per les sis entitats, i tindràs uns quants milers de línies que no aporten valor i que cal mantenir cada vegada que canvia una columna.
JDBC no és dolent. És de baix nivell. Amb Hibernate, aquesta consulta sencera es converteix en:
I ni tan sols cal escriure aquesta línia, com veuràs a l'apartat 32.
- JPA enfront d'Hibernate: especificació i implementació
Una altra confusió clàssica que convé aclarir.
Jakarta Persistence (JPA) és una especificació: un conjunt d'interfícies i anotacions estandarditzades, al paquet jakarta.persistence. Defineix EntityManager, @Entity, @Id, @OneToMany, JPQL... però no conté codi que funcioni.
Hibernate és una implementació d'aquesta especificació. És qui de veritat genera el SQL, gestiona el context de persistència i parla amb la base de dades.
graph TD
A["El teu codi de BiblioTech"] --> B["API JPA<br/>jakarta.persistence<br/>@Entity, EntityManager, JPQL"]
B --> C["Hibernate 6.x<br/>(la implementacio)"]
B -.->|alternatives| D["EclipseLink"]
B -.-> E["OpenJPA"]
C --> F["JDBC"]
F --> G[("H2 / PostgreSQL /<br/>MySQL / Oracle")]
| Aspecte | JPA (jakarta.persistence) |
Hibernate (org.hibernate) |
|---|---|---|
| Què és | Especificació, interfícies | Implementació amb codi |
| Paquet | jakarta.persistence.* |
org.hibernate.* |
| Exemples | @Entity, EntityManager, JPQL |
Session, @BatchSize, HQL, filtres |
| Portable a una altra implementació | Sí | No |
La recomanació pràctica: programa contra JPA sempre que puguis i fes servir extensions d'Hibernate només quan ho necessitis de veritat. És el mateix principi de la façana que veuràs amb SLF4J a 11-07.
Avís de versions, important. Jakarta EE va reanomenar tots els paquets
javax.*ajakarta.*. Spring Boot 3 i Hibernate 6 fan servirjakarta.persistence. Si copies un exemple ambimport javax.persistence.Entity, és de Spring Boot 2 o anterior i no compilarà. És l'error de copia i enganxa més freqüent avui.
- Posar en marxa JPA amb H2 a BiblioTech
Dues dependències afegides al pom.xml d'11-02:
<!-- Hibernate + JPA + Spring Data + gestio de transaccions -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2: base de dades en memoria. No cal instal.lar RES -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>I la configuració a application.yml:
spring:
datasource:
url: jdbc:h2:mem:bibliotech;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true # http://localhost:8080/h2-console (nomes en dev)
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop # NOMES en desenvolupament. Veure apartat 31
show-sql: true # mostra el SQL generat
properties:
hibernate:
format_sql: true # el formata llegible
jdbc:
batch_size: 25
open-in-view: false # IMPORTANT. Veure apartat 15Amb això ja està. Executa:
I a l'arrencada veuràs les condicions de l'apartat 19 d'11-02 complint-se: DataSourceAutoConfiguration detecta H2 al classpath, veu que tu no has definit un DataSource, i el crea. HibernateJpaAutoConfiguration detecta Hibernate i munta l'EntityManagerFactory i el gestor de transaccions. Zero codi de configuració.
Dues notes sobre show-sql: és imprescindible mentre aprens —veuràs exactament quin SQL genera Hibernate i això és la meitat de l'aprenentatge— i cal desactivar-lo en producció, on el logging correcte és per SLF4J (11-07):
logging:
level:
org.hibernate.SQL: DEBUG # les sentencies
org.hibernate.orm.jdbc.bind: TRACE # els parametres
- La primera entitat:
@Entity, @Table, @Id
@Entity, @Table, @IdConvertim Llibre de BiblioTech en una entitat:
package com.nexussoftware.bibliotech.domini;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity // "aquesta classe es mapeja a una taula"
@Table(name = "materials", // nom de taula explicit
indexes = @Index(name = "idx_materials_isbn", columnList = "isbn", unique = true))
public class Llibre {
@Id // clau primaria
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "isbn", nullable = false, unique = true, length = 20)
private String isbn;
@Column(nullable = false, length = 200)
private String titol;
@Column(name = "any_publicacio")
private Integer anyPublicacio;
@Column(name = "exemplars_disponibles", nullable = false)
private int exemplarsDisponibles;
// JPA EXIGEIX un constructor sense arguments (pot ser protected)
protected Llibre() { }
public Llibre(String isbn, String titol, int exemplarsDisponibles) {
this.isbn = isbn;
this.titol = titol;
this.exemplarsDisponibles = exemplarsDisponibles;
}
// getters i (nomes els necessaris) setters
public Long getId() { return id; }
public String getIsbn() { return isbn; }
public String getTitol() { return titol; }
public int getExemplarsDisponibles() { return exemplarsDisponibles; }
// Comportament de domini: l'entitat no es una bossa de dades
public void prestarUnExemplar() {
if (exemplarsDisponibles <= 0) {
throw new SenseExemplarsException(isbn);
}
exemplarsDisponibles--;
}
public void retornarUnExemplar() { exemplarsDisponibles++; }
}Els requisits que imposa JPA a una entitat, i que convé conèixer perquè els errors són críptics:
| Requisit | Motiu |
|---|---|
Anotada amb @Entity |
És el que la fa mapejable |
Un @Id |
Sense identitat no hi ha fila |
| Constructor sense arguments | Hibernate la instancia per reflexió (10-03) |
Classe no final |
Necessita generar subclasses proxy per a la càrrega mandrosa |
Camps no final |
Els omple per reflexió |
Els mètodes no poden ser final |
Intercepció de la càrrega mandrosa |
I aquí tens per què un record no pot ser una entitat JPA: és final, els seus camps són final i no té constructor sense arguments. Els record de BiblioTech (Fitxa, ResumSessio) continuaran sent perfectes com a DTO i com a projeccions de consulta, però les entitats han de ser classes mutables. És un compromís real de l'ORM, no un caprici.
Sobre @Table: si no la poses, la taula s'anomena com la classe. Posar-la explícitament és bona pràctica, perquè Prestec mapejaria a una taula PRESTEC, i paraules com ORDER, USER o GROUP són reservades en SQL i donen errors desconcertants.
@GeneratedValue i les seves estratègies
@GeneratedValue i les seves estratègiesQui genera la clau primària. És una decisió amb impacte real en el rendiment.
| Estratègia | Com funciona | Avantatges | Inconvenients |
|---|---|---|---|
IDENTITY |
Columna autoincremental del motor | Simple; funciona a tots | Impedeix l'agrupament d'insercions: obliga a un INSERT immediat per conèixer l'id |
SEQUENCE |
Seqüència de la base de dades | La millor: permet reservar ids per lots i agrupar insercions | No tots els motors tenen seqüències (MySQL antic) |
TABLE |
Una taula auxiliar de comptadors | Portable a tot | Lenta; contenció. Evita-la |
AUTO |
Hibernate tria | Còmoda | Impredictible entre motors |
| (cap) | L'assignes tu | Control total; claus naturals | Has de garantir la unicitat |
La recomanació amb Hibernate 6 i una base de dades que admeti seqüències:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "materials_seq")
@SequenceGenerator(name = "materials_seq", sequenceName = "materials_seq",
allocationSize = 50) // reserva 50 ids de cop: 1 consulta cada 50 insercions
private Long id;El motiu de l'allocationSize importa: amb IDENTITY, Hibernate no pot agrupar insercions, perquè necessita executar cada INSERT perquè el motor li retorni l'id. Amb SEQUENCE i allocationSize=50, pot acumular 50 insercions i enviar-les en un lot. En una importació de 10.000 materials, la diferència és de minuts.
Per als exemples amb H2 d'aquesta lliçó farem servir IDENTITY per simplicitat, però coneix la raó per la qual en producció es prefereix SEQUENCE.
@Column i el mapatge de tipus
@Column i el mapatge de tipus@Column descriu la columna. Els seus atributs útils:
| Atribut | Què fa | Exemple |
|---|---|---|
name |
Nom de la columna | @Column(name = "data_prestec") |
nullable |
NOT NULL a l'esquema generat |
nullable = false |
unique |
Restricció d'unicitat | unique = true |
length |
Longitud de VARCHAR (per defecte 255) |
length = 20 |
precision / scale |
Per a BigDecimal |
precision = 10, scale = 2 |
insertable / updatable |
Excloure d'INSERT o UPDATE |
updatable = false |
columnDefinition |
SQL en cru. Trenca la portabilitat | Últim recurs |
Els tipus que JPA mapeja automàticament:
| Tipus Java | Columna SQL típica |
|---|---|
String |
VARCHAR |
int, Integer, long, Long |
INTEGER, BIGINT |
boolean, Boolean |
BOOLEAN |
BigDecimal |
NUMERIC(p,s) |
LocalDate |
DATE |
LocalDateTime |
TIMESTAMP |
Instant |
TIMESTAMP (UTC) |
byte[] |
BLOB / VARBINARY |
enum |
Depèn de @Enumerated (apartat 9) |
| Un altre | Necessita un AttributeConverter (apartat 10) |
Dos avisos concrets sobre BiblioTech:
Un: mai double per a diners. Ja es va dir a 01-04 i aquí importa més que mai. Les multes són BigDecimal amb precision i scale explícits:
Dos: Optional no es mapeja. Optional<LocalDate> dataDevolucio era una decisió excel·lent a 10-04 per a l'API pública, però JPA no sap persistir Optional. La solució és un camp anul·lable i un getter que retorni Optional:
@Column(name = "data_devolucio")
private LocalDate dataDevolucio; // camp: pot ser null
public Optional<LocalDate> getDataDevolucio() { // API: Optional
return Optional.ofNullable(dataDevolucio);
}Així conserves la garantia de 10-04 —qui cridi mai no rep null— i JPA pot persistir el camp. És el patró estàndard.
@Enumerated: per què STRING i mai ORDINAL
@Enumerated: per què STRING i mai ORDINALBiblioTech té tres enums des del mòdul 4: Gravetat, TipusMaterial i EstatPrestec. El seu mapatge té un parany amb conseqüències greus.
// MALAMENT: per defecte JPA fa servir ORDINAL (l'index: 0, 1, 2)
@Enumerated(EnumType.ORDINAL)
private EstatPrestec estat;
// BE: sempre STRING
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
private EstatPrestec estat;Per què ORDINAL és perillós. Amb ell, la base de dades desa 0, 1, 2. Ara imagina que d'aquí a sis mesos algú afegeix un estat al seu lloc lògic:
Tots els préstecs que estaven desats amb 1 (VENCUT) passen a llegir-se com a RENOVAT, i els 2 (RETORNAT) com a VENCUT. Totes les dades històriques queden corrompudes silenciosament. Cap excepció, cap avís. Es descobreix setmanes després, quan algú reclama una multa d'un llibre que va retornar.
A més, ORDINAL fa la base de dades il·legible: SELECT * FROM prestecs retorna números que no signifiquen res.
| Aspecte | ORDINAL |
STRING |
|---|---|---|
| Què desa | L'índex (0, 1, 2) | El nom ("ACTIU") |
| Espai | Menys | Una mica més |
| Reordenar o inserir constants | Corromp les dades | Sense problema |
| Reanomenar una constant | Sense problema | Cal migrar (i la fallada és visible) |
| Llegible en SQL | No | Sí |
La regla és absoluta: @Enumerated(EnumType.STRING), sempre. L'estalvi d'espai d'ORDINAL no compensa ni de bon tros el risc. I el més perillós és que ORDINAL és el valor per defecte: si oblides l'anotació, tens el comportament dolent.
LocalDate, Instant i els conversors propis
LocalDate, Instant i els conversors propisBones notícies: des de JPA 2.2, java.time es mapeja de forma nativa. Tota la feina de 10-05 es trasllada directament:
@Column(name = "data_prestec", nullable = false)
private LocalDate dataPrestec; // -> DATE
@Column(name = "data_venciment", nullable = false)
private LocalDate dataVenciment; // -> DATE
@Column(name = "instant_registre", nullable = false)
private Instant instantRegistre; // -> TIMESTAMP en UTCLa distinció de 10-05 continua sent la correcta: LocalDate per a dates de negoci (un préstec venç "el dia 20", sense hora ni zona) i Instant per a marques de temps d'auditoria (un instant absolut a la línia temporal).
I java.util.Date i Calendar no es fan servir mai més. Si veus @Temporal(TemporalType.DATE) en un exemple, és codi anterior a Java 8.
Conversors per a tipus propis
I si vols persistir un tipus que JPA no coneix? Aquí entra AttributeConverter. BiblioTech té un record Isbn com a objecte de valor:
package com.nexussoftware.bibliotech.domini;
public record Isbn(String valor) {
public Isbn {
if (valor == null || !valor.matches("\\d{3}-\\d{10}")) {
throw new IsbnInvalidException(valor);
}
}
}El conversor:
package com.nexussoftware.bibliotech.persistencia;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import com.nexussoftware.bibliotech.domini.Isbn;
@Converter(autoApply = true) // s'aplica a TOTS els camps de tipus Isbn
public class ConversorIsbn implements AttributeConverter<Isbn, String> {
@Override
public String convertToDatabaseColumn(Isbn isbn) {
return isbn == null ? null : isbn.valor();
}
@Override
public Isbn convertToEntityAttribute(String columna) {
return columna == null ? null : new Isbn(columna);
}
}Amb autoApply = true, qualsevol camp Isbn es converteix sol. Sense ell, cal marcar-lo:
@Convert(converter = ConversorIsbn.class)
@Column(name = "isbn", nullable = false, unique = true)
private Isbn isbn;Això permet tenir objectes de valor amb validació al domini i columnes simples a la base de dades: el millor dels dos móns. Altres usos habituals: xifrar un camp sensible, desar una llista curta com a cadena separada per comes, o mapejar un booleà a 'S'/'N' en una base de dades heretada.
@Embeddable i @Embedded
@Embeddable i @EmbeddedUn incrustable és un objecte que no té identitat pròpia i els camps del qual es desen a la mateixa fila que l'entitat que el conté. Serveix per donar estructura al model d'objectes sense crear taules.
package com.nexussoftware.bibliotech.domini;
import jakarta.persistence.Embeddable;
@Embeddable
public class DadesPublicacio {
private String editorial;
private Integer any;
private String idioma;
protected DadesPublicacio() { }
public DadesPublicacio(String editorial, Integer any, String idioma) {
this.editorial = editorial;
this.any = any;
this.idioma = idioma;
}
public boolean esRecent(int anyActual) { // comportament propi
return any != null && anyActual - any <= 3;
}
}Ús:
@Entity
public class Llibre {
@Embedded
private DadesPublicacio publicacio;
// Si el mateix incrustable apareix dues vegades, cal reanomenar columnes:
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "editorial", column = @Column(name = "editorial_original")),
@AttributeOverride(name = "any", column = @Column(name = "any_original"))
})
private DadesPublicacio publicacioOriginal;
}La taula resultant té columnes editorial, any, idioma, editorial_original, any_original... a la mateixa fila de materials. No hi ha taula dades_publicacio ni JOIN.
| Concepte | @Entity |
@Embeddable |
|---|---|---|
| Taula pròpia | Sí | No: columnes a la del contenidor |
Identitat (@Id) |
Sí | No |
| Cicle de vida | Propi | El de l'entitat que el conté |
| Es pot consultar sol | Sí | No |
| Exemple | Llibre, Prestec |
DadesPublicacio, Adreca, Diner |
És la resposta al problema de granularitat de l'apartat 1: pots tenir objectes petits i cohesius en Java sense fragmentar l'esquema.
- Relacions: els quatre tipus
El model de BiblioTech:
- Un
Prestecpertany a unMateriali a unEmpleat. - Un
Materialté moltsPrestec. - Un
Empleatté moltsPresteci moltesReserva. - Un
Materialté moltesCategoriai unaCategoriaté moltsMaterial.
@ManyToOne: el costat que té la clau forana
@Entity
@Table(name = "prestecs")
public class Prestec {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // SEMPRE LAZY. Apartat 14
@JoinColumn(name = "material_id", nullable = false) // la columna FK
private Material material;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "empleat_id", nullable = false)
private Empleat empleat;
@Column(name = "data_prestec", nullable = false)
private LocalDate dataPrestec;
@Column(name = "data_venciment", nullable = false)
private LocalDate dataVenciment;
@Column(name = "data_devolucio")
private LocalDate dataDevolucio;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EstatPrestec estat;
@Version // bloqueig optimista. Apartat 29
private Long version;
}La taula prestecs tindrà columnes material_id i empleat_id amb claus foranes.
@OneToMany: el costat invers
@Entity
@Table(name = "empleats")
public class Empleat {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String correu;
@Column(nullable = false)
private String nom;
// mappedBy = "empleat" -> el camp de Prestec que es amo de la relacio
@OneToMany(mappedBy = "empleat",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY) // LAZY es el valor per defecte aqui
private List<Prestec> prestecs = new ArrayList<>();
// Metode de conveniencia: mante TOTS DOS costats sincronitzats
public void afegirPrestec(Prestec prestec) {
prestecs.add(prestec);
prestec.assignarEmpleat(this);
}
public void treurePrestec(Prestec prestec) {
prestecs.remove(prestec);
prestec.assignarEmpleat(null);
}
}Aquests mètodes de conveniència no són opcionals a la pràctica: si afegeixes a la llista sense posar el camp de l'altre costat, la relació no es persisteix (apartat 13) i a més l'estat en memòria queda incoherent.
@OneToOne
@Entity
public class Empleat {
@OneToOne(mappedBy = "empleat", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private FitxaBiblioteca fitxa;
}
@Entity
public class FitxaBiblioteca {
@Id @GeneratedValue private Long id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "empleat_id", unique = true)
private Empleat empleat; // costat propietari
}Parany conegut: un @OneToOne opcional al costat invers no pot ser mandrós amb proxies, perquè Hibernate necessita consultar per saber si hi ha fila o no. Si el rendiment importa, posa-hi optional = false o fes servir una clau primària compartida.
@ManyToMany
@Entity
public class Material {
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "materials_categories",
joinColumns = @JoinColumn(name = "material_id"),
inverseJoinColumns = @JoinColumn(name = "categoria_id"))
private Set<Categoria> categories = new HashSet<>();
}
@Entity
public class Categoria {
@ManyToMany(mappedBy = "categories")
private Set<Material> materials = new HashSet<>();
}Consell professional molt repetit: @ManyToMany pur es queda curt gairebé sempre. Tan bon punt necessitis un atribut a la relació —data en què es va assignar la categoria, qui la va assignar— cal convertir-la en una entitat intermèdia amb dos @ManyToOne. Molts equips modelen directament l'entitat intermèdia des del principi.
Resum:
| Anotació | On va la FK | Costat propietari | Ús a BiblioTech |
|---|---|---|---|
@ManyToOne |
En aquesta taula | Aquest | Prestec → Material, Prestec → Empleat |
@OneToMany |
A l'altra taula | L'altre (mappedBy) |
Empleat → Prestec |
@OneToOne |
En una de les dues | El que té @JoinColumn |
Empleat → FitxaBiblioteca |
@ManyToMany |
Taula intermèdia | El que té @JoinTable |
Material ↔ Categoria |
- Costat propietari i
mappedBy
mappedByAquest és un concepte que confon gairebé tothom i que provoca el bug de "he desat i no s'ha desat res".
En una base de dades relacional, la relació és una sola columna: la clau forana. En Java, la relació bidireccional són dos camps. Algú ha de decidir quin dels dos mana.
El costat propietari és el que té la clau forana. És l'únic que Hibernate mira per persistir la relació. L'altre costat porta
mappedByi és de només lectura a efectes de persistència.
La conseqüència:
// MALAMENT: nomes es toca el costat invers
Empleat marta = repositoriEmpleats.findById(1L).orElseThrow();
Prestec prestec = new Prestec(llibre, LocalDate.now(rellotge));
marta.getPrestecs().add(prestec); // nomes el costat INVERS
repositoriPrestecs.save(prestec);
// -> La columna empleat_id queda a NULL. La relacio NO s'ha desat.// BE: es toca el costat propietari
prestec.assignarEmpleat(marta); // el costat PROPIETARI (@ManyToOne)
repositoriPrestecs.save(prestec);
// -> empleat_id es desa correctament// MILLOR: metode de conveniencia que sincronitza els dos costats
marta.afegirPrestec(prestec); // posa tots dos camps
repositoriPrestecs.save(prestec);La regla pràctica: sempre que tinguis una relació bidireccional, escriu mètodes de conveniència que actualitzin els dos costats. Mai manipulis les col·leccions directament des de fora.
FetchType: càrrega mandrosa enfront d'ansiosa
FetchType: càrrega mandrosa enfront d'ansiosaQuan carregues un Prestec, ha de carregar Hibernate també el seu Material i el seu Empleat? Aquesta decisió és FetchType, i és la decisió amb més impacte en el rendiment de tota la lliçó.
FetchType |
Comportament | Quan es carrega l'associació |
|---|---|---|
EAGER (ansiós) |
Es carrega sempre, juntament amb l'entitat | Immediatament, amb un JOIN o una consulta extra |
LAZY (mandrós) |
Es posa un proxy; es carrega en accedir-hi | La primera vegada que crides un mètode seu |
Els valors per defecte de JPA són, desafortunadament, incoherents:
| Anotació | Valor per defecte | És bona idea? |
|---|---|---|
@ManyToOne |
EAGER |
No |
@OneToOne |
EAGER |
No |
@OneToMany |
LAZY |
Sí |
@ManyToMany |
LAZY |
Sí |
Per què l'EAGER per defecte de @ManyToOne és dolent: imagina que Prestec té @ManyToOne ansiosos a Material i Empleat, i que Material té un @ManyToOne ansiós a Editorial, que en té un altre a Pais. Carregar un préstec arrossega cinc taules. I aquest cost el pagues sempre, fins i tot quan només volies la data de venciment.
Regla professional: posa
fetch = FetchType.LAZYexplícitament a TOTES les associacions. I quan necessitis les dades relacionades, demana-les explícitament a la consulta ambJOIN FETCHo@EntityGraph.
@ManyToOne(fetch = FetchType.LAZY) // SEMPRE explicit
@JoinColumn(name = "material_id")
private Material material;Com funciona un proxy mandrós, i aquí torna el mòdul 10: quan carregues un Prestec, Hibernate no posa un Material al camp. Posa un objecte d'una subclasse generada de Material que no té dades, només l'id. La primera vegada que crides material.getTitol(), aquesta subclasse intercepta la crida, llança el SELECT i retorna el valor.
És el teu proxy dinàmic de 10-03 un altre cop, i d'aquí surten dues conseqüències que ja coneixes:
- Una entitat no pot ser
final: no es podria generar la subclasse. - En depurar veuràs objectes anomenats
Material$HibernateProxy$xYz123. És el teu material, sense carregar.
- La
LazyInitializationException
LazyInitializationExceptionLa contrapartida de la càrrega mandrosa, i una de les excepcions més famoses del món Java.
@Service
public class ServeiInformes {
@Transactional(readOnly = true)
public Prestec obtenir(Long id) {
return repositori.findById(id).orElseThrow();
} // <- LA TRANSACCIO ACABA AQUI. El context de persistencia es tanca.
}
// En un altre lloc:
Prestec prestec = servei.obtenir(1L);
System.out.println(prestec.getMaterial().getTitol()); // BUMorg.hibernate.LazyInitializationException: could not initialize proxy
[com.nexussoftware.bibliotech.domini.Material#7] - no SessionQuè ha passat, pas a pas:
- Dins de la transacció, Hibernate va carregar el préstec i va posar un proxy al camp
material. - La transacció va acabar i el context de persistència es va tancar.
- En cridar
getTitol(), el proxy intenta llançar elSELECT... i ja no hi ha sessió amb la qual fer-ho.
Les solucions, de millor a pitjor:
1. Portar el que necessites a la consulta (la correcta):
@Query("SELECT p FROM Prestec p JOIN FETCH p.material JOIN FETCH p.empleat WHERE p.id = :id")
Optional<Prestec> cercarAmbDetalls(@Param("id") Long id);2. Retornar un DTO en comptes de l'entitat (la millor arquitectònicament):
@Transactional(readOnly = true)
public FitxaPrestec obtenir(Long id) {
Prestec p = repositori.findById(id).orElseThrow();
// S'accedeix a tot DINS de la transaccio i es retorna un objecte desconnectat
return new FitxaPrestec(p.getId(), p.getMaterial().getTitol(),
p.getEmpleat().getNom(), p.getDataVenciment());
}Aquesta FitxaPrestec pot ser perfectament un record — on sí que encaixen de meravella.
3. Ampliar la transacció fins a cobrir l'ús: solució vàlida de vegades, però mantenir transaccions obertes més temps del necessari té el seu propi cost.
4. spring.jpa.open-in-view=true: desaconsellada, encara que és el valor per defecte de Spring Boot. Manté el context de persistència obert durant tota la petició HTTP, de manera que l'excepció no apareix mai... i a canvi amaga el problema N+1, manté connexions ocupades més temps i provoca consultes des de la capa de presentació. Per això a l'application.yml de l'apartat 5 apareix explícitament:
Consell: mantenir-lo a false fa que la LazyInitializationException aparegui a la teva màquina en comptes que el problema de rendiment aparegui en producció.
- El problema N+1
El problema de rendiment número u dels ORM. I és tan fàcil de provocar que gairebé tothom el té sense saber-ho.
@Transactional(readOnly = true)
public void informeDePrestecs() {
List<Prestec> prestecs = repositori.findAll(); // 1 consulta
for (Prestec p : prestecs) {
System.out.println(p.getMaterial().getTitol()); // 1 consulta CADA VEGADA!
}
}Amb 500 préstecs, el log de show-sql mostra:
-- 1: la consulta principal
select p.id, p.material_id, p.empleat_id, ... from prestecs p
-- N: una per cada prestec, en accedir al seu material
select m.id, m.isbn, m.titol, ... from materials m where m.id=1
select m.id, m.isbn, m.titol, ... from materials m where m.id=2
select m.id, m.isbn, m.titol, ... from materials m where m.id=3
-- ... 500 vegades501 consultes on n'hi hauria d'haver una. Amb 2 ms de latència per consulta, un segon sencer perdut. I si hi afegeixes l'empleat, en són 1001.
L'insidiós de l'assumpte: en desenvolupament, amb 5 préstecs de prova, és instantani. El problema apareix en producció amb dades reals, i per llavors la causa és en un bucle que sembla inofensiu.
graph TD
A["findAll()<br/>1 consulta: 500 prestecs"] --> B["Bucle sobre els 500"]
B --> C["p.getMaterial() -> proxy"]
C --> D["getTitol() dispara SELECT"]
D --> E["500 consultes addicionals"]
E --> F["TOTAL: 501 consultes<br/>en comptes d'1"]
Nota important: canviar a EAGER no ho arregla. Amb EAGER, Hibernate carrega les associacions sempre, però moltes vegades ho fa igualment amb una consulta per entitat. L'únic que aconsegueixes és tenir el problema sempre, fins i tot quan no anaves a fer servir els materials.
Com detectar-lo:
spring.jpa.show-sql=truei comptar les consultes al log.- Estadístiques d'Hibernate:
spring.jpa.properties.hibernate.generate_statistics=true. - En producció: mètriques i traces distribuïdes (12-07).
- Resoldre'l:
JOIN FETCH i @EntityGraph
JOIN FETCH i @EntityGraphDues solucions, totes dues de l'especificació.
JOIN FETCH en JPQL
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
@Query("""
SELECT p FROM Prestec p
JOIN FETCH p.material
JOIN FETCH p.empleat
WHERE p.estat = :estat
""")
List<Prestec> cercarActiusAmbDetalls(@Param("estat") EstatPrestec estat);
}Ara es genera una sola consulta:
select p.*, m.*, e.*
from prestecs p
join materials m on p.material_id = m.id
join empleats e on p.empleat_id = e.id
where p.estat = 'ACTIU'De 501 consultes a 1.
Compte amb un detall: JOIN FETCH sobre una col·lecció pot duplicar files. Si portes un empleat amb els seus 5 préstecs, el JOIN retorna 5 files i podries obtenir l'empleat repetit. Es resol amb SELECT DISTINCT o demanant un Set. I una limitació coneguda: no es pot paginar un JOIN FETCH de col·lecció de forma eficient; Hibernate avisa que està paginant en memòria. En aquest cas, la solució és portar els ids paginats en una consulta i les entitats completes en una altra.
@EntityGraph: declaratiu
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
@EntityGraph(attributePaths = { "material", "empleat" })
List<Prestec> findByEstat(EstatPrestec estat);
@EntityGraph(attributePaths = { "material", "material.categories" })
Optional<Prestec> findById(Long id);
}Avantatge: el mateix mètode pot tenir diversos grafs, i no cal escriure JPQL.
Comparació
| Solució | Quan | Avantatge | Inconvenient |
|---|---|---|---|
JOIN FETCH |
Consulta concreta a mida | Control total | JPQL a mà |
@EntityGraph |
Sobre mètodes derivats | Declaratiu, reutilitzable | Menys flexible |
@BatchSize(size = 25) |
Quan el mandrós és inevitable | De 501 a 21 consultes | Continua sense ser 1 |
| DTO per projecció | Consultes de només lectura | Porta només les columnes necessàries | No són entitats gestionades |
EAGER |
Mai | — | Empitjora el problema |
La projecció a DTO mereix menció a part perquè és la solució més eficient per a informes:
@Query("""
SELECT new com.nexussoftware.bibliotech.domini.FitxaPrestec(
p.id, m.titol, e.nom, p.dataVenciment)
FROM Prestec p JOIN p.material m JOIN p.empleat e
WHERE p.estat = :estat
""")
List<FitxaPrestec> fitxesActives(@Param("estat") EstatPrestec estat);Una consulta, només quatre columnes, sense entitats gestionades i sense risc d'N+1. I FitxaPrestec és un record — que per fi troba el seu lloc a la capa de persistència.
CascadeType i orphanRemoval
CascadeType i orphanRemovalLes cascades propaguen operacions del pare als fills.
CascadeType |
Què propaga | Exemple a BiblioTech |
|---|---|---|
PERSIST |
Desar el pare desa els fills nous | Desar Empleat desa els seus Prestec |
MERGE |
Fusionar el pare fusiona els fills | Actualitzar un graf desconnectat |
REMOVE |
Esborrar el pare esborra els fills | Perillós: esborrar un Material esborraria el seu historial |
REFRESH |
Recarregar en cascada | Rar |
DETACH |
Separar en cascada | Rar |
ALL |
Els cinc | Còmode i perillós |
@OneToMany(mappedBy = "empleat",
cascade = { CascadeType.PERSIST, CascadeType.MERGE },
orphanRemoval = true)
private List<Prestec> prestecs = new ArrayList<>();orphanRemoval = true significa: si treus un fill de la col·lecció, esborra'l de la base de dades.
empleat.getPrestecs().remove(prestec);
// Amb orphanRemoval = true -> DELETE FROM prestecs WHERE id = ...
// Sense ell -> UPDATE prestecs SET empleat_id = NULL (o error si es NOT NULL)Diferència amb CascadeType.REMOVE:
CascadeType.REMOVE |
orphanRemoval = true |
|
|---|---|---|
| Es dispara en | Esborrar el pare | Treure el fill de la col·lecció |
| Semàntica | "En esborrar el tot, esborra les parts" | "Un fill sense pare no té sentit" |
Avís seriós: CascadeType.ALL sobre relacions on el fill té vida pròpia és una font de pèrdua de dades. Si Material tingués cascade = ALL sobre els seus préstecs, donar de baixa un llibre esborraria tot el seu historial de préstecs, amb les seves multes i la seva auditoria. Fes servir cascades només quan la relació sigui de composició real (el fill no existeix sense el pare).
- El context de persistència i l'
EntityManager
EntityManagerAquí hi ha el cor conceptual de JPA, i el que separa qui entén Hibernate de qui el pateix.
L'EntityManager és la porta d'entrada a JPA. I gestiona un context de persistència: una memòria cau d'entitats on cada entitat carregada o desada queda gestionada mentre duri la transacció.
Tres propietats del context de persistència, totes amb conseqüències:
- És una memòria cau de primer nivell. Si demanes dues vegades la mateixa entitat a la mateixa transacció, la segona no genera consulta.
- Garanteix identitat. Dins d'una transacció, la mateixa fila és sempre el mateix objecte Java:
a == béstrue. Això resol el problema d'identitat de l'apartat 1. - Detecta canvis automàticament. És l'apartat 21, i és el que més sorprèn.
@Transactional
public void exempleContext(Long id) {
Material a = entityManager.find(Material.class, id); // SELECT
Material b = entityManager.find(Material.class, id); // sense consulta: memoria cau
System.out.println(a == b); // true: MATEIX objecte
}A Spring, l'EntityManager s'injecta així:
@Repository
public class RepositoriMaterialsJpa {
@PersistenceContext
private EntityManager entityManager; // Spring injecta un proxy per transaccio
}Aquest EntityManager injectat no és una instància real: és un proxy que a cada crida busca el context de persistència associat a la transacció del fil actual. Un altre cop el mateix mecanisme del mòdul 10.
- Els quatre estats d'una entitat
Tota entitat és en un de quatre estats, i saber en quin és la clau per entendre què farà Hibernate.
stateDiagram-v2
[*] --> Nova: new Material(...)
Nova --> Gestionada: persist()
Gestionada --> Separada: tancament de la transaccio<br/>o detach()
Separada --> Gestionada: merge()
Gestionada --> Eliminada: remove()
Eliminada --> Nova: persist()
Eliminada --> [*]: flush / commit
Gestionada --> Gestionada: els canvis es detecten sols
[*] --> Gestionada: find() / consulta
| Estat | Què significa | Hibernate la vigila? | Té id? |
|---|---|---|---|
| Nova (transient) | Acabada de crear amb new |
No | No |
| Gestionada (managed) | Al context de persistència | Sí | Sí |
| Separada (detached) | Va estar gestionada, ja no | No | Sí |
| Eliminada (removed) | Marcada per esborrar | Sí | Sí |
L'exemple que ho aclareix tot:
@Transactional
public void demostracio() {
// NOVA: nomes un objecte Java. La base de dades no en sap res.
Material llibre = new Llibre("978-0000000001", "Java Eficac", 3);
// GESTIONADA: entra al context. S'inserira en confirmar.
entityManager.persist(llibre);
// Gestionada: aquest canvi es desara SOL, sense cridar res.
llibre.setTitol("Java Eficac, 3a edicio");
} // COMMIT: INSERT amb el titol ja corregit
// Fora del metode: SEPARADA. Els canvis ja no es detecten.I l'error clàssic que produeix:
// En un controlador o servei, fora de transaccio
Material material = servei.cercarPerIsbn("978-0000000001"); // separada
material.setTitol("Un altre titol"); // no passa RES
// El canvi es queda en memoria i es perd.Perquè un canvi sobre una entitat separada es desi, cal reincorporar-la amb merge (apartat 23).
- La comprovació de canvis automàtica
Això és el que més sorprèn de JPA, i alhora el més potent.
@Transactional
public void renovarPrestec(Long prestecId, int dies) {
Prestec prestec = repositori.findById(prestecId).orElseThrow();
prestec.renovar(dies); // canvia dataVenciment
// NO hi ha cap save(). NO hi ha cap update().
} // I tanmateix, en confirmar s'executa un UPDATE.Com? El mecanisme s'anomena dirty checking i funciona així:
- En carregar l'entitat, Hibernate desa una instantània de l'estat de tots els seus camps.
- En fer
flush(normalment en confirmar), compara l'estat actual amb la instantània. - Per a cada camp diferent, genera l'
UPDATEcorresponent.
Conseqüències pràctiques, totes importants:
Una: no cal cridar save sobre entitats gestionades. Moltíssim codi Spring Data crida save() innecessàriament. No fa mal, però és soroll.
Dues: qualsevol modificació accidental es persisteix. Si dins d'una transacció toques un camp d'una entitat gestionada "només per calcular alguna cosa", aquest canvi va a la base de dades. És una font real de bugs desconcertants.
Tres: readOnly = true la desactiva i estalvia feina.
@Transactional(readOnly = true) // sense instantanies, sense comparacions: mes rapid
public List<Prestec> llistar() { ... }En consultes que retornen moltes entitats, la diferència és mesurable. És un bon costum marcar readOnly = true a tots els mètodes de només lectura.
Quatre: el cost creix amb el nombre d'entitats gestionades. Carregar 100.000 entitats en una transacció fa que cada flush compari 100.000 instantànies. Per a processos massius cal netejar el context periòdicament amb entityManager.clear().
flush i l'ordre de les operacions
flush i l'ordre de les operacionsflush és el moment en què Hibernate escriu realment el SQL pendent a la base de dades. No és el mateix que commit.
Quan passa automàticament:
- En confirmar la transacció (sempre).
- Abans d'executar una consulta que es pogués veure afectada pels canvis pendents.
- Quan crides
entityManager.flush()explícitament.
I aquí ve un detall que sorprèn: Hibernate no executa el SQL en l'ordre en què tu crides els mètodes. Reordena les operacions així:
INSERTd'entitats, en ordre depersistUPDATE- Esborrat d'elements de col·leccions
- Inserció d'elements de col·leccions
DELETEd'entitats
Per això aquest codi pot fallar de forma incomprensible:
@Transactional
public void reemplacarMaterial(String isbn, Material nou) {
Material vell = repositori.cercarPerIsbn(isbn).orElseThrow();
entityManager.remove(vell); // "esborra" el que te isbn = X
entityManager.persist(nou); // "insereix" un altre amb isbn = X
} // En confirmar: PRIMER l'INSERT, DESPRES el DELETE
// -> violacio de la restriccio d'unicitat d'isbnLa solució és forçar l'ordre:
entityManager.remove(vell);
entityManager.flush(); // executa el DELETE ara
entityManager.persist(nou);Entendre que existeix un "SQL pendent" que s'executa més tard i reordenat explica la meitat dels comportaments estranys d'Hibernate.
- Les operacions:
persist, merge, find, getReference, remove
persist, merge, find, getReference, remove| Operació | Què fa | Estat resultant | Quan |
|---|---|---|---|
persist(e) |
Marca una entitat nova per inserir | Gestionada (la mateixa instància) | Entitats noves |
merge(e) |
Copia l'estat d'una entitat separada al context | Gestionada (una altra instància) | Entitats separades |
find(C, id) |
Cerca per clau primària | Gestionada o null |
Necessites les dades |
getReference(C, id) |
Retorna un proxy sense carregar | Gestionada (proxy) | Només necessites la referència |
remove(e) |
Marca per esborrar | Eliminada | Esborrat |
detach(e) |
Treu del context | Separada | Processos massius |
refresh(e) |
Recarrega des de la base de dades | Gestionada | Descartar canvis locals |
La diferència entre persist i merge (que provoca bugs reals)
// persist: la MATEIXA instancia queda gestionada
Material llibre = new Llibre("978-0000000003", "Refactoritzacio", 2);
entityManager.persist(llibre);
System.out.println(llibre.getId()); // ja te id: es la instancia gestionada
// merge: retorna UNA ALTRA instancia. L'original continua separada.
Material separat = new Llibre(...); separat.setId(7L);
Material gestionat = entityManager.merge(separat);
gestionat.setTitol("A"); // ES DESA
separat.setTitol("B"); // NO es desa: continua separada
System.out.println(gestionat == separat); // falseLa regla: fes servir sempre l'objecte que retorna merge. Ignorar el valor de retorn és un error freqüentíssim.
getReference: l'optimització que s'oblida
// Necessites associar un prestec a un material, pero no en necessites les dades.
// Opcio A: find -> executa un SELECT innecessari
Material material = entityManager.find(Material.class, materialId);
prestec.assignarMaterial(material);
// Opcio B: getReference -> NO consulta; nomes crea un proxy amb l'id
Material material = entityManager.getReference(Material.class, materialId);
prestec.assignarMaterial(material); // suficient per posar la FKL'únic que necessita l'INSERT és el material_id, i getReference el té. Estalvies un SELECT per operació. La contrapartida: si accedeixes a qualsevol propietat del proxy fora de la sessió, tindràs una LazyInitializationException, i si l'id no existeix, l'error arriba tard (EntityNotFoundException en accedir-hi, no en demanar la referència).
- Consultes: JPQL
JPQL (Jakarta Persistence Query Language) sembla SQL però opera sobre entitats i els seus atributs Java, no sobre taules i columnes.
// SQL: noms de TAULA i COLUMNA
SELECT p.* FROM prestecs p WHERE p.data_venciment < ?
// JPQL: noms d'ENTITAT i ATRIBUT
SELECT p FROM Prestec p WHERE p.dataVenciment < :dataExemples complets sobre BiblioTech:
@Repository
public class RepositoriConsultes {
@PersistenceContext
private EntityManager em;
// Consulta simple amb parametre amb nom
public List<Prestec> vencuts(LocalDate data) {
return em.createQuery("""
SELECT p FROM Prestec p
WHERE p.dataVenciment < :data
AND p.dataDevolucio IS NULL
ORDER BY p.dataVenciment
""", Prestec.class)
.setParameter("data", data)
.getResultList();
}
// JOIN FETCH per evitar l'N+1
public List<Prestec> vencutsAmbDetalls(LocalDate data) {
return em.createQuery("""
SELECT p FROM Prestec p
JOIN FETCH p.material
JOIN FETCH p.empleat
WHERE p.dataVenciment < :data
""", Prestec.class)
.setParameter("data", data)
.getResultList();
}
// Agregacio amb GROUP BY, retornant un record directament
public List<ComptePerEmpleat> prestecsPerEmpleat() {
return em.createQuery("""
SELECT new com.nexussoftware.bibliotech.domini.ComptePerEmpleat(
e.nom, COUNT(p))
FROM Prestec p JOIN p.empleat e
GROUP BY e.id, e.nom
HAVING COUNT(p) > 2
ORDER BY COUNT(p) DESC
""", ComptePerEmpleat.class)
.getResultList();
}
// Paginacio
public List<Material> pagina(int numero, int mida) {
return em.createQuery("SELECT m FROM Material m ORDER BY m.titol", Material.class)
.setFirstResult(numero * mida)
.setMaxResults(mida)
.getResultList();
}
// Modificacio massiva: NO passa pel context de persistencia
@Modifying
public int marcarVencuts(LocalDate data) {
return em.createQuery("""
UPDATE Prestec p SET p.estat = :vencut
WHERE p.dataVenciment < :data AND p.dataDevolucio IS NULL
""")
.setParameter("vencut", EstatPrestec.VENCUT)
.setParameter("data", data)
.executeUpdate();
}
}Avís sobre aquesta última: les consultes de modificació massiva s'executen directament a la base de dades i no actualitzen les entitats ja carregades al context de persistència, que queden desincronitzades. Després d'una així, convé em.clear().
Diferències clau amb SQL:
| JPQL | SQL |
|---|---|
FROM Prestec p (entitat) |
FROM prestecs p (taula) |
p.dataVenciment (atribut) |
p.data_venciment (columna) |
JOIN p.material (navega la relació) |
JOIN materials ON ... (condició explícita) |
SELECT p retorna entitats |
SELECT * retorna files |
| Portable entre motors | Depèn del dialecte |
- Consultes amb nom, API de criteris i SQL natiu
Consultes amb nom
Es declaren a l'entitat i es validen en arrencar, no en executar-se:
@Entity
@NamedQuery(name = "Prestec.vencuts",
query = """
SELECT p FROM Prestec p
WHERE p.dataVenciment < :data AND p.dataDevolucio IS NULL
""")
public class Prestec { ... }List<Prestec> vencuts = em.createNamedQuery("Prestec.vencuts", Prestec.class)
.setParameter("data", LocalDate.now(rellotge))
.getResultList();Avantatge real: un error de sintaxi al JPQL impedeix arrencar l'aplicació, en comptes d'esclatar el dia que algú invoqui aquest informe.
L'API de criteris, esmentada
Per a consultes construïdes dinàmicament —un cercador amb cinc filtres opcionals—, concatenar JPQL a mà és lleig i perillós. JPA ofereix una API amb tipus:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Prestec> consulta = cb.createQuery(Prestec.class);
Root<Prestec> p = consulta.from(Prestec.class);
List<Predicate> filtres = new ArrayList<>();
if (isbn != null) filtres.add(cb.equal(p.get("material").get("isbn"), isbn));
if (des != null) filtres.add(cb.greaterThanOrEqualTo(p.get("dataPrestec"), des));
consulta.where(cb.and(filtres.toArray(new Predicate[0])));
List<Prestec> resultat = em.createQuery(consulta).getResultList();És verbosa, i per això molta gent prefereix les Specification de Spring Data o QueryDSL. S'esmenta perquè sàpigues que existeix i per a què serveix; a la pràctica es fa servir poc.
SQL natiu
Quan necessites una funció específica del motor, una consulta molt optimitzada o un informe complex:
List<Object[]> files = em.createNativeQuery("""
SELECT m.titol, COUNT(p.id) AS total
FROM materials m LEFT JOIN prestecs p ON p.material_id = m.id
WHERE p.data_prestec >= :des
GROUP BY m.id, m.titol
ORDER BY total DESC
FETCH FIRST 10 ROWS ONLY
""")
.setParameter("des", des)
.getResultList();És perfectament legítim. Un ORM no obliga a fer-ho tot amb ell, i pretendre el contrari porta a JPQL recargolats que ningú entén. L'única pèrdua és la portabilitat entre motors.
- Injecció SQL: per què mai no es concatena
Això no és una recomanació d'estil. És una vulnerabilitat de seguretat.
// MAI. DE CAP MANERA.
public List<Prestec> cercarPerEmpleat(String correu) {
return em.createQuery(
"SELECT p FROM Prestec p WHERE p.empleat.correu = '" + correu + "'",
Prestec.class).getResultList();
}Si correu ve d'un formulari o d'un paràmetre d'una API, un atacant pot enviar:
I la consulta es converteix en:
que retorna tots els préstecs de tots els empleats. Amb SQL natiu, les conseqüències són pitjors: depenent del motor i dels permisos, es pot arribar a llegir altres taules o modificar dades.
La solució és sempre la mateixa, i és trivial:
// Parametre amb nom
em.createQuery("SELECT p FROM Prestec p WHERE p.empleat.correu = :correu", Prestec.class)
.setParameter("correu", correu)
.getResultList();
// O posicional
em.createQuery("SELECT p FROM Prestec p WHERE p.empleat.correu = ?1", Prestec.class)
.setParameter(1, correu)
.getResultList();Amb paràmetres, el valor mai no s'interpreta com a part de la consulta: viatja a part i es tracta com una dada literal, passi el que passi a dins. És la mateixa raó per la qual l'exemple de JDBC de l'apartat 3 feia servir PreparedStatement amb ? i setString.
La regla, sense excepcions: tot valor que vingui de fora va com a paràmetre. Ni tan sols "quan sé que és un número" —perquè demà aquest codi es copia a un altre lloc on no ho és.
L'únic que no es pot parametritzar són els noms de taula i columna i el sentit d'un ORDER BY. Si necessites que siguin dinàmics, valida'ls contra una llista blanca de valors permesos, mai els concatenis tal qual.
La seguretat d'aplicacions —validació d'entrada, autorització, gestió de secrets, capçaleres— es tracta a 12-07. Aquí n'hi ha prou amb la regla: paràmetres sempre.
- Transaccions:
@Transactional amb base de dades real
@Transactional amb base de dades realAra que hi ha base de dades, el @Transactional d'11-02 fa alguna cosa.
Una transacció compleix les propietats ACID:
| Propietat | Què garanteix |
|---|---|
| Atomicitat | O totes les operacions o cap |
| Consistència | Es respecten les restriccions d'integritat |
| Aïllament | Les transaccions concurrents no es trepitgen |
| Durabilitat | El que s'ha confirmat sobreviu a una caiguda |
El cas de BiblioTech:
@Service
public class GestorPrestecs {
@Transactional
public Prestec prestar(String isbn, String correuEmpleat) {
Material material = repositoriMaterials.cercarPerIsbn(isbn)
.orElseThrow(() -> new MaterialNoTrobatException(isbn));
Empleat empleat = repositoriEmpleats.cercarPerCorreu(correuEmpleat)
.orElseThrow(() -> new EmpleatNoTrobatException(correuEmpleat));
if (repositoriPrestecs.comptarActius(empleat) >= propietats.prestec().maximPerEmpleat()) {
throw new LimitPrestecsException(correuEmpleat);
}
material.prestarUnExemplar(); // UPDATE (per dirty checking)
Prestec prestec = new Prestec(material, empleat,
LocalDate.now(rellotge), propietats.prestec().diesPerDefecte());
repositoriPrestecs.save(prestec); // INSERT
registreAuditoria.anotar(prestec); // INSERT
return prestec;
}
@Transactional(readOnly = true) // optimitzat: sense dirty checking
public List<Prestec> actiusDe(String correu) {
return repositoriPrestecs.cercarActiusPerEmpleat(correu);
}
}Si registreAuditoria.anotar falla, es reverteix tot: l'exemplar torna a estar disponible i no queda cap préstec orfe. Sense transacció, quedaria un exemplar menys i un préstec sense auditar, i ningú sabria per què.
Recorda les dues regles d'11-02, que continuen valent aquí: rollback només davant d'excepcions no comprovades per defecte, i les crides internes no passen pel proxy.
- Propagació i aïllament
Propagació: què fer si ja hi ha una transacció oberta
| Propagació | Comportament | Ús |
|---|---|---|
REQUIRED (per defecte) |
S'uneix a l'existent; si no n'hi ha, la crea | El 95 % dels casos |
REQUIRES_NEW |
Suspèn l'actual i n'obre una d'independent | Auditoria que ha de persistir encara que la resta falli |
SUPPORTS |
S'uneix si n'hi ha; si no, sense transacció | Consultes |
MANDATORY |
Exigeix que ja n'hi hagi una; si no, error | Mètodes interns que mai no s'han de cridar sols |
NOT_SUPPORTED |
Suspèn l'actual i executa sense transacció | Operacions molt llargues |
NEVER |
Error si hi ha transacció | Rar |
NESTED |
Punt de desat dins de l'actual | Suport limitat |
El cas on REQUIRES_NEW és la resposta correcta:
@Service
public class RegistreAuditoria {
// Es vol registrar l'INTENT encara que el prestec es reverteixi
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarIntent(String isbn, String empleat, String resultat) {
em.persist(new EntradaAuditoria(isbn, empleat, resultat, Instant.now(rellotge)));
}
}Compte: REQUIRES_NEW consumeix una segona connexió del pool mentre la primera continua oberta. Abusar-ne esgota el pool i provoca interbloquejos. Fes-lo servir només quan la independència sigui un requisit real.
Aïllament: com es veuen les transaccions concurrents
| Nivell | Lectura bruta | Lectura no repetible | Lectura fantasma | Cost |
|---|---|---|---|---|
READ_UNCOMMITTED |
Possible | Possible | Possible | Mínim |
READ_COMMITTED |
No | Possible | Possible | Baix (per defecte a PostgreSQL, Oracle, H2) |
REPEATABLE_READ |
No | No | Possible | Mitjà (per defecte a MySQL) |
SERIALIZABLE |
No | No | No | Alt |
Els tres fenòmens, amb BiblioTech:
- Lectura bruta: llegeixes un exemplar com a prestat i aquesta transacció es reverteix. Vas llegir una cosa que mai no va existir.
- Lectura no repetible: llegeixes
exemplars_disponibles = 1, un altre presta, tornes a llegir i hi ha0. Dues lectures, dos resultats. - Lectura fantasma: comptes 3 préstecs actius, un altre n'insereix un, repeteixes la consulta i n'hi ha 4.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public InformeMensual generar(YearMonth mes) { ... }A la pràctica, gairebé mai no es toca l'aïllament. READ_COMMITTED amb bloqueig optimista (apartat següent) resol el 99 % dels casos amb molt menys cost que pujar el nivell.
- Bloqueig optimista amb
@Version
@VersionEl cas concret, que reprèn directament el mòdul 8:
Marta Ruiz i Diego Alonso obren alhora la fitxa de "Java Eficaç", que té 1 exemplar disponible. Tots dos premen "Prestar" al mateix segon.
sequenceDiagram
participant M as Marta
participant BD as Base de dades
participant D as Diego
M->>BD: SELECT material 1 (disponibles=1)
D->>BD: SELECT material 1 (disponibles=1)
M->>BD: UPDATE disponibles=0
D->>BD: UPDATE disponibles=0
Note over BD: Dos prestecs, un exemplar.<br/>ACTUALITZACIO PERDUDA
És exactament la condició de cursa de 08-04, però distribuïda: aquí synchronized no serveix de res, perquè els dos usuaris poden estar en instàncies diferents de l'aplicació.
Dues estratègies:
| Bloqueig pessimista | Bloqueig optimista | |
|---|---|---|
| Suposició | Hi haurà conflicte | Gairebé mai no hi haurà conflicte |
| Com | SELECT ... FOR UPDATE: bloqueja la fila |
Columna version comprovada en actualitzar |
| Cost | Alt: bloquejos, esperes, interbloquejos | Molt baix |
| Quan | Conflictes freqüents i cars | Gairebé sempre |
| JPA | @Lock(LockModeType.PESSIMISTIC_WRITE) |
@Version |
El bloqueig optimista és una sola anotació:
@Entity
public class Material {
@Id @GeneratedValue private Long id;
@Version // Hibernate la gestiona sola
private Long version;
@Column(name = "exemplars_disponibles", nullable = false)
private int exemplarsDisponibles;
}Amb això, cada UPDATE inclou la versió llegida a la condició:
Si un altre ja va actualitzar, la fila té version=6 i la condició no troba cap fila. Hibernate ho detecta i llança:
Gestionar-ho bé:
@Service
public class GestorPrestecs {
@Transactional
public Prestec prestar(String isbn, String correu) {
// ... com abans
}
public Resultat<Prestec> prestarAmbReintent(String isbn, String correu) {
for (int intent = 1; intent <= 3; intent++) {
try {
return Resultat.exit(self.prestar(isbn, correu));
} catch (OptimisticLockingFailureException e) {
log.warn("Conflicte de concurrencia a {} (intent {}/3)", isbn, intent);
}
}
return Resultat.error("El material s'esta modificant. Torna-ho a provar.");
}
}Aquest Resultat<T> és el tipus genèric que vas escriure a 10-01, i aquí encaixa perfectament. I aquest bucle de reintents és, conceptualment, el teu ProxyReintents de 10-03: en un projecte real el declararies amb @Retryable de Spring Retry.
Bloqueig pessimista, quan l'operació és cara i el conflicte probable:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Material m WHERE m.isbn = :isbn")
Optional<Material> cercarPerActualitzar(@Param("isbn") String isbn);Genera SELECT ... FOR UPDATE: el segon espera el primer. Correcte, però amb cost real de concurrència.
- Memòries cau de primer i segon nivell
| Memòria cau | Àmbit | Activada per defecte | Què desa |
|---|---|---|---|
| Primer nivell | El context de persistència (la transacció) | Sí, sempre | Entitats gestionades |
| Segon nivell | L'EntityManagerFactory (tota l'aplicació) |
No | Entitats entre transaccions |
| De consulta | Global | No | Resultats de consultes |
La de primer nivell ja la coneixes: és el context de persistència de l'apartat 19. No es pot desactivar i és la que garanteix la identitat d'objectes.
La de segon nivell és opcional i compartida per totes les transaccions. Necessita un proveïdor (Ehcache, Caffeine, Hazelcast):
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Material { ... }spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactoryQuan activar-la: dades que es llegeixen molt i canvien poc. El catàleg de materials o les categories de BiblioTech són bons candidats; els préstecs, no.
Advertiments que cal tenir presents:
- Coherència: si una altra aplicació modifica la base de dades, la teva memòria cau queda obsoleta i serveix dades velles.
- En clúster: amb diverses instàncies, cadascuna té la seva memòria cau. O fas servir una memòria cau distribuïda o tindràs incoherències.
- Mesura abans: activar la memòria cau de segon nivell "per si de cas" afegeix complexitat sense benefici demostrat. És exactament el que deia 10-07: mesurar abans d'optimitzar.
ddl-auto i per què mai update en producció
ddl-auto i per què mai update en produccióHibernate pot generar l'esquema a partir de les teves entitats. És còmode i perillós.
| Valor | Què fa | Quan fer-lo servir |
|---|---|---|
none |
Res | Producció |
validate |
Comprova que l'esquema coincideix amb les entitats; falla si no | Producció (recomanat) |
update |
Intenta modificar l'esquema per adaptar-lo | Mai en producció |
create |
Esborra i crea l'esquema en arrencar | Proves |
create-drop |
Com create, i esborra en tancar |
Desenvolupament amb H2, proves |
Per què update no es fa servir mai en producció, amb motius concrets:
- Mai no esborra res. Si treus un camp, la columna es queda allà per sempre. Si reanomenes
titolanom, afegeixnomi deixatitol— i no copia les dades. - No versiona res. No hi ha registre de quins canvis es van aplicar ni quan, i no hi ha manera de desfer-los.
- És impredictible. El SQL que genera depèn del dialecte, de la versió d'Hibernate i de l'estat actual de l'esquema.
- No es pot revisar abans. Ningú ha vist l'
ALTER TABLEque s'executarà contra la base de dades de producció. - No pot fer migracions de dades. Dividir
nom_completennomicognomsrequereix lògica que cap eina automàtica no pot inventar.
La configuració correcta per entorn:
# application-dev.yml — H2 en memoria, esquema regenerat cada arrencada
spring:
jpa:
hibernate:
ddl-auto: create-drop# application-prod.yml — l'esquema el gestiona una eina de migracio
spring:
jpa:
hibernate:
ddl-auto: validatevalidate és especialment valuós: si l'esquema real no coincideix amb les entitats, l'aplicació no arrenca, i això és molt millor que descobrir-ho amb un error a les tres de la matinada.
Flyway i Liquibase, esmentats
En producció, l'esquema es gestiona amb migracions versionades: fitxers SQL numerats que s'apliquen en ordre i queden registrats en una taula de control.
-- src/main/resources/db/migration/V1__esquema_inicial.sql
CREATE TABLE materials (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
isbn VARCHAR(20) NOT NULL UNIQUE,
titol VARCHAR(200) NOT NULL,
exemplars_disponibles INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0
);-- V2__afegir_categoria.sql
ALTER TABLE materials ADD COLUMN categoria VARCHAR(50);
UPDATE materials SET categoria = 'GENERAL' WHERE categoria IS NULL;Avantatges: el canvi d'esquema està a git, és revisable, és reproduïble i es pot provar abans. Es desenvolupa a 12-06, juntament amb el desplegament.
- Spring Data JPA
Última peça, i la que més codi elimina.
Amb JPA pur, un repositori són unes vuitanta línies. Amb Spring Data JPA:
package com.nexussoftware.bibliotech.persistencia;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
// I ja esta. Sense implementacio.
}Això ja et dóna save, findById, findAll, deleteById, count, existsById, paginació i ordenació.
Mètodes derivats del nom
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
List<Prestec> findByEstat(EstatPrestec estat);
List<Prestec> findByEmpleatCorreu(String correu);
List<Prestec> findByDataVencimentBeforeAndDataDevolucioIsNull(LocalDate data);
long countByEmpleatAndEstat(Empleat empleat, EstatPrestec estat);
Optional<Prestec> findFirstByMaterialIsbnOrderByDataPrestecDesc(String isbn);
boolean existsByMaterialIsbnAndEstat(String isbn, EstatPrestec estat);
}Spring Data analitza el nom del mètode i genera la consulta. Les paraules clau habituals:
| Paraula clau | JPQL generat |
|---|---|
findBy, readBy, getBy |
SELECT ... |
And, Or |
AND, OR |
Between, LessThan, GreaterThan, Before, After |
Comparacions |
IsNull, IsNotNull |
IS NULL |
Like, Containing, StartingWith |
LIKE |
In, NotIn |
IN |
OrderBy...Asc/Desc |
ORDER BY |
countBy, existsBy, deleteBy |
Agregació, existència, esborrat |
First, Top3 |
LIMIT |
Consell pràctic: els noms derivats són fantàstics fins que deixen de ser-ho. findByDataVencimentBeforeAndDataDevolucioIsNullAndEstatNot és il·legible. Quan el nom passi de tres condicions, fes servir @Query.
@Query
@Query("""
SELECT p FROM Prestec p
JOIN FETCH p.material m
JOIN FETCH p.empleat e
WHERE p.dataVenciment < :data AND p.dataDevolucio IS NULL
ORDER BY p.dataVenciment
""")
List<Prestec> vencutsAmbDetalls(@Param("data") LocalDate data);
@Modifying
@Transactional
@Query("UPDATE Prestec p SET p.estat = :estat WHERE p.id = :id")
int actualitzarEstat(@Param("id") Long id, @Param("estat") EstatPrestec estat);
@Query(value = "SELECT * FROM prestecs WHERE data_prestec > ?1", nativeQuery = true)
List<Prestec> nativaDes(LocalDate des);Paginació
Pageable pagina = PageRequest.of(0, 20, Sort.by("dataVenciment").descending());
Page<Prestec> resultat = repositori.findByEstat(EstatPrestec.ACTIU, pagina);
resultat.getContent(); // els 20 d'aquesta pagina
resultat.getTotalElements(); // total (executa un COUNT addicional)
resultat.getTotalPages();
resultat.hasNext();Si no necessites el total, Slice<T> evita el COUNT i és més ràpid.
Com funciona: proxies dinàmics un altre cop
PrestecRepository és una interfície sense implementació. Qui executa findByEstat?
A l'arrencada, Spring Data:
- Escaneja les interfícies que estenen
Repository. - Per a cadascuna, crea una implementació al vol amb
Proxy.newProxyInstance— el mateix de 10-03. - L'
InvocationHandlerrep cada crida, mira si el mètode té@Query(i fa servir aquest JPQL) o analitza el seu nom (i genera el JPQL), l'executa amb l'EntityManageri adapta el resultat.
graph LR
A["gestor.repositori<br/>.findByEstat(ACTIU)"] --> B["Proxy JDK<br/>(Spring Data)"]
B --> C["Analitza el nom<br/>o llegeix @Query"]
C --> D["Genera JPQL"]
D --> E["EntityManager"]
E --> F["Hibernate -> SQL"]
F --> G[("H2")]
És literalment el que vas escriure a 10-03: un proxy sobre una interfície que interpreta les metadades del mètode i actua. La diferència és la sofisticació de l'anàlisi, no el mecanisme.
I hi ha una conseqüència pràctica: el repositori es pot substituir per un simulat a les proves sense cap dificultat, perquè és una interfície. És exactament el que farà 11-06.
- BiblioTech: de CSV a H2
El resultat complet de la migració.
// domini/Material.java — ja no pot ser sealed record: es una entitat
package com.nexussoftware.bibliotech.domini;
import jakarta.persistence.*;
@Entity
@Table(name = "materials")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // una taula per a tota la jerarquia
@DiscriminatorColumn(name = "tipus", discriminatorType = DiscriminatorType.STRING, length = 20)
public abstract class Material {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String isbn;
@Column(nullable = false, length = 200)
private String titol;
@Column(name = "exemplars_disponibles", nullable = false)
private int exemplarsDisponibles;
@Version
private Long version;
protected Material() { }
protected Material(String isbn, String titol, int exemplars) {
this.isbn = isbn;
this.titol = titol;
this.exemplarsDisponibles = exemplars;
}
public void prestarUnExemplar() {
if (exemplarsDisponibles <= 0) throw new SenseExemplarsException(isbn);
exemplarsDisponibles--;
}
public void retornarUnExemplar() { exemplarsDisponibles++; }
// getters...
}
@Entity @DiscriminatorValue("LLIBRE")
public class Llibre extends Material {
@Column(length = 120) private String autor;
protected Llibre() { }
public Llibre(String isbn, String titol, int exemplars, String autor) {
super(isbn, titol, exemplars);
this.autor = autor;
}
}
@Entity @DiscriminatorValue("REVISTA")
public class Revista extends Material {
private Integer numero;
protected Revista() { }
}
@Entity @DiscriminatorValue("DVD")
public class Dvd extends Material {
@Column(name = "durada_minuts") private Integer duradaMinuts;
protected Dvd() { }
}Aquí es paga un dels preus reals de l'ORM, i cal dir-ho amb claredat: la jerarquia sealed de 10-06 desapareix. Una entitat JPA no pot ser sealed ni record ni final. Es perden els switch exhaustius verificats pel compilador i cal substituir-los per polimorfisme o per comprovacions amb instanceof. És un compromís conscient: es guanya persistència real, consultes i integritat; es perd part de l'expressivitat del model de tipus. Un disseny alternatiu —i freqüent en projectes que valoren molt el domini— manté el domini pur i fa servir entitats JPA separades amb un mapatge entre tots dos. Aquesta discussió pertany a 12-02.
Les tres estratègies d'herència, perquè sàpigues què vas triar:
| Estratègia | Com | Avantatges | Inconvenients |
|---|---|---|---|
SINGLE_TABLE (per defecte) |
Una taula, columna discriminadora | Ràpida, sense JOIN |
Columnes de subclasses anul·lables |
JOINED |
Una taula per classe, unides per PK | Normalitzada, sense nuls | JOIN a cada consulta |
TABLE_PER_CLASS |
Taula completa per subclasse | Sense JOIN per a consultes concretes |
UNION en consultar la superclasse |
Els repositoris:
public interface MaterialRepository extends JpaRepository<Material, Long> {
Optional<Material> findByIsbn(String isbn);
List<Material> findByTitolContainingIgnoreCase(String fragment);
List<Material> findByExemplarsDisponiblesGreaterThan(int minim);
}
public interface EmpleatRepository extends JpaRepository<Empleat, Long> {
Optional<Empleat> findByCorreu(String correu);
}
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
@EntityGraph(attributePaths = { "material", "empleat" })
List<Prestec> findByEstat(EstatPrestec estat);
long countByEmpleatAndEstat(Empleat empleat, EstatPrestec estat);
@Query("""
SELECT p FROM Prestec p JOIN FETCH p.material JOIN FETCH p.empleat
WHERE p.dataVenciment < :data AND p.dataDevolucio IS NULL
""")
List<Prestec> vencuts(@Param("data") LocalDate data);
}Dades inicials per a desenvolupament, a src/main/resources/data.sql:
INSERT INTO empleats (correu, nom) VALUES
('[email protected]', 'Marta Ruiz'),
('[email protected]', 'Diego Alonso'),
('[email protected]', 'Nuria Vidal');
INSERT INTO materials (tipus, isbn, titol, exemplars_disponibles, version, autor) VALUES
('LLIBRE', '978-0000000001', 'Java Eficac', 3, 0, 'J. Bloch'),
('LLIBRE', '978-0000000002', 'Patrons de Disseny', 2, 0, 'GoF'),
('LLIBRE', '978-0000000003', 'Refactoritzacio', 1, 0, 'M. Fowler');Execució:
mvn spring-boot:run -Dspring-boot.run.profiles=dev
# Consola web d'H2: http://localhost:8080/h2-console
# JDBC URL: jdbc:h2:mem:bibliotech | Usuari: sa | Sense contrasenyaL'abans i el després:
| Aspecte | CSV (mòdul 7) | JPA sobre H2 |
|---|---|---|
| Format | Text pla | Base de dades relacional |
| Consultes | Carregar tot i filtrar en memòria | SQL amb índexs |
| Transaccions | Cap | ACID |
| Integritat referencial | Cap | Claus foranes |
| Concurrència | Dos processos el corrompen | Bloqueig optimista amb @Version |
| Escriptura | EscripturaAtomica a mà |
Gestionada pel motor |
| Codi d'accés | LectorCsv + EscriptorCsv + mapatge |
Tres interfícies sense implementació |
| Consultes noves | Programar el filtratge | Un mètode més a la interfície |
| Escalabilitat | Milers de files | Milions |
- Errors comuns i consells
Error: javax.persistence en comptes de jakarta.persistence. L'error de copia i enganxa número u avui. Spring Boot 3 i Hibernate 6 fan servir jakarta.
Error: deixar @Enumerated per defecte (ORDINAL). Reordenar un enum corromp silenciosament totes les dades històriques. Sempre EnumType.STRING.
Error: deixar @ManyToOne en EAGER. És el valor per defecte i és dolent. Posa LAZY explícitament a totes les associacions i porta el que necessitis amb JOIN FETCH.
Error: el problema N+1 sense adonar-se'n. Un bucle sobre entitats accedint a una relació mandrosa. Activa show-sql en desenvolupament i compta les consultes.
Error: spring.jpa.open-in-view=true. És el valor per defecte de Spring Boot i amaga l'N+1 fins a producció. Posa'l a false.
Error: modificar només el costat invers d'una relació. empleat.getPrestecs().add(p) sense p.setEmpleat(empleat) no persisteix res. Escriu mètodes de conveniència.
Error: ignorar el retorn de merge. merge(e) retorna una altra instància; l'original continua separada i els seus canvis es perden.
Error: CascadeType.ALL sense pensar-hi. Esborrar un material podria esborrar el seu historial complet de préstecs.
Error: ddl-auto=update en producció. No esborra, no versiona, no migra dades i ningú revisa el SQL que executarà. validate més Flyway.
Error: concatenar valors en una consulta. És injecció SQL. Paràmetres sempre, sense excepcions.
Error: entitats final, record o amb mètodes final. Hibernate necessita generar subclasses proxy. No compilarà el model o fallarà la càrrega mandrosa.
Error: equals i hashCode basats en l'id generat. Abans de persistir, l'id és null; després canvia, i una entitat ficada en un HashSet deixa de trobar-se. Fes servir una clau de negoci (l'isbn) o el patró recomanat d'Hibernate.
Consell: activa show-sql mentre aprens. Veure el SQL generat és la meitat de l'aprenentatge, i t'ensenya a detectar l'N+1 a l'instant.
Consell: marca readOnly = true als mètodes de consulta. Desactiva la comprovació de canvis i estalvia feina real.
Consell: fes servir DTO (record) per al que surt del servei. Retornar entitats gestionades fora de la transacció és la causa directa de la LazyInitializationException, i a més acobla la teva API interna a l'esquema.
Consell: @Version a tota entitat que es modifiqui concurrentment. Costa una anotació i evita actualitzacions perdudes.
Consell: no ho facis tot amb l'ORM. Per a informes i agregacions, SQL natiu o JdbcTemplate són millors eines. És legítim i professional.
Consell: getReference quan només necessitis la referència per assignar una FK. Estalvia un SELECT per operació.
- Exercicis
Exercici 1: mapejar l'entitat Reserva
BiblioTech té reserves: quan no hi ha exemplars, un empleat reserva i se l'avisa en retornar-se'n un. Aquest és el record del mòdul 10:
public record Reserva(Long id, String isbn, String correuEmpleat,
LocalDate dataSolicitud, LocalDate dataCaducitat,
Optional<Instant> instantAvis, EstatReserva estat) { }
public enum EstatReserva { PENDENT, AVISADA, COMPLETADA, CADUCADA }Converteix-lo en una entitat JPA que compleixi:
- Taula
reservesamb clau primària autogenerada. - Relacions
@ManyToOnemandroses aMaterialiEmpleat, no cadenes. - L'enum desat de forma segura.
instantAvisanul·lable a la base de dades però exposat com aOptional.- Bloqueig optimista.
- Un índex sobre
(material_id, estat)per a la consulta de pendents. - Un mètode de domini
avisar(Clock rellotge)que canviï l'estat i registri l'instant, amb validació. - El repositori Spring Data amb: pendents d'un material ordenades per antiguitat, caducades abans d'una data, i comptar pendents d'un empleat.
Exercici 2: diagnosticar i arreglar un N+1
Aquest servei funciona bé amb els 5 préstecs de desenvolupament i triga 9 segons en producció amb 800.
@Service
public class ServeiInformes {
private final PrestecRepository repositori;
@Transactional(readOnly = true)
public List<String> informeVencuts(LocalDate data) {
List<Prestec> vencuts = repositori.findByDataVencimentBefore(data);
return vencuts.stream()
.map(p -> String.format("%s | %s | %s | %d dies",
p.getMaterial().getTitol(),
p.getMaterial().getCategories().stream()
.map(Categoria::getNom)
.collect(Collectors.joining(", ")),
p.getEmpleat().getNom(),
ChronoUnit.DAYS.between(p.getDataVenciment(), data)))
.toList();
}
}Es demana:
- Calcula quantes consultes SQL s'executen amb 800 préstecs, desglossades.
- Explica exactament per què.
- Proposa tres solucions diferents amb el seu codi, indicant quantes consultes queden en cadascuna.
- Digues quina triaries i per què.
Exercici 3: l'exemplar disputat
Marta Ruiz i Diego Alonso intenten prestar simultàniament l'últim exemplar de "Refactorització" (978-0000000003, 1 exemplar).
@Service
public class GestorPrestecs {
@Transactional
public Prestec prestar(String isbn, String correu) {
Material material = materialRepository.findByIsbn(isbn).orElseThrow();
Empleat empleat = empleatRepository.findByCorreu(correu).orElseThrow();
if (material.getExemplarsDisponibles() <= 0) {
throw new SenseExemplarsException(isbn);
}
material.setExemplarsDisponibles(material.getExemplarsDisponibles() - 1);
Prestec prestec = new Prestec(material, empleat, LocalDate.now(rellotge), 15);
return prestecRepository.save(prestec);
}
}Es demana:
- Descriu la seqüència exacta d'esdeveniments que produeix dos préstecs amb un sol exemplar.
- Explica per què
synchronized(08-04) no resol això en un desplegament real. - Resol-ho amb bloqueig optimista, incloent-hi la gestió de l'excepció amb reintent.
- Resol-ho amb bloqueig pessimista.
- Compara'ls tots dos en una taula i recomana'n un.
Solucions
Solució 1
package com.nexussoftware.bibliotech.domini;
import jakarta.persistence.*;
import java.time.*;
import java.util.Optional;
@Entity
@Table(name = "reserves",
indexes = @Index(name = "idx_reserves_material_estat", // (6)
columnList = "material_id, estat"))
public class Reserva {
@Id // (1)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // (2)
@JoinColumn(name = "material_id", nullable = false)
private Material material;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // (2)
@JoinColumn(name = "empleat_id", nullable = false)
private Empleat empleat;
@Column(name = "data_solicitud", nullable = false)
private LocalDate dataSolicitud;
@Column(name = "data_caducitat", nullable = false)
private LocalDate dataCaducitat;
@Column(name = "instant_avis") // (4) anul.lable
private Instant instantAvis;
@Enumerated(EnumType.STRING) // (3) MAI ORDINAL
@Column(nullable = false, length = 20)
private EstatReserva estat;
@Version // (5)
private Long version;
protected Reserva() { } // exigit per JPA
public Reserva(Material material, Empleat empleat, LocalDate solicitud, int diesValidesa) {
this.material = material;
this.empleat = empleat;
this.dataSolicitud = solicitud;
this.dataCaducitat = solicitud.plusDays(diesValidesa);
this.estat = EstatReserva.PENDENT;
}
// (7) comportament de domini amb validacio
public void avisar(Clock rellotge) {
if (estat != EstatReserva.PENDENT) {
throw new EstatReservaInvalidException(
"Nomes es pot avisar una reserva PENDENT; estat actual: " + estat);
}
this.estat = EstatReserva.AVISADA;
this.instantAvis = Instant.now(rellotge); // Clock injectat (10-05)
}
public void completar() {
if (estat != EstatReserva.AVISADA) {
throw new EstatReservaInvalidException("Nomes es completa una reserva AVISADA");
}
this.estat = EstatReserva.COMPLETADA;
}
public boolean haCaducat(LocalDate avui) {
return estat == EstatReserva.PENDENT && dataCaducitat.isBefore(avui);
}
// (4) el camp es anul.lable; l'API publica retorna Optional
public Optional<Instant> getInstantAvis() {
return Optional.ofNullable(instantAvis);
}
public Long getId() { return id; }
public Material getMaterial() { return material; }
public Empleat getEmpleat() { return empleat; }
public EstatReserva getEstat() { return estat; }
public LocalDate getDataSolicitud() { return dataSolicitud; }
public LocalDate getDataCaducitat() { return dataCaducitat; }
}Repositori (8):
public interface ReservaRepository extends JpaRepository<Reserva, Long> {
// Pendents d'un material, la mes antiga primer (cua FIFO de reserves)
@EntityGraph(attributePaths = { "empleat" }) // evita N+1 en llegir el nom
List<Reserva> findByMaterialIsbnAndEstatOrderByDataSolicitudAsc(
String isbn, EstatReserva estat);
// Caducades
List<Reserva> findByEstatAndDataCaducitatBefore(EstatReserva estat, LocalDate data);
// Comptar pendents d'un empleat
long countByEmpleatCorreuAndEstat(String correu, EstatReserva estat);
}Decisions destacables:
record→ classe: obligatori, perquè unrecordésfinali sense constructor buit.- Relacions en comptes de cadenes:
MaterialiEmpleaten comptes d'isbnicorreuEmpleat. Dóna integritat referencial (no es pot reservar un material inexistent) i navegació. El preu és haver de gestionar la mandra. Optionalfora, camp anul·lable dins: es conserva la garantia de 10-04 sense barallar-se amb JPA.- El comportament viu a l'entitat:
avisar,completarihaCaducatvaliden les transicions. L'entitat no és una bossa de dades. @EntityGrapha la primera consulta: se sap que es llegirà el nom de l'empleat, així que es porta d'una vegada.
Solució 2
1. Quantes consultes.
| Concepte | Consultes |
|---|---|
findByDataVencimentBefore |
1 |
p.getMaterial() — proxy mandrós, 800 vegades |
800 |
p.getMaterial().getCategories() — col·lecció mandrosa, 800 vegades |
800 |
p.getEmpleat() — proxy mandrós, 800 vegades |
800 |
| Total | 2.401 |
Amb 3-4 ms de latència per consulta, entre 7 i 10 segons. Coincideix amb el símptoma.
2. Per què. Les tres associacions són mandroses. La consulta inicial porta només les files de prestecs, amb proxies a material i empleat i una col·lecció mandrosa a categories. Cada accés dins del map dispara el seu propi SELECT. En desenvolupament, amb 5 préstecs, són 16 consultes i ningú ho nota.
Solució A — JOIN FETCH (1 consulta si es limita al que és escalar, 2 amb la col·lecció):
public interface PrestecRepository extends JpaRepository<Prestec, Long> {
@Query("""
SELECT DISTINCT p FROM Prestec p
JOIN FETCH p.material m
LEFT JOIN FETCH m.categories
JOIN FETCH p.empleat
WHERE p.dataVenciment < :data
""")
List<Prestec> vencutsComplets(@Param("data") LocalDate data);
}DISTINCT és necessari perquè el JOIN FETCH de la col·lecció de categories duplica files de préstec. 1 consulta. Limitació: no es pot paginar eficientment amb un JOIN FETCH de col·lecció.
Solució B — @EntityGraph (declarativa, mateix resultat):
@EntityGraph(attributePaths = { "material", "material.categories", "empleat" })
List<Prestec> findByDataVencimentBefore(LocalDate data);Avantatge: no es toca el codi del servei i el mateix mètode pot tenir un altre graf en una altra consulta. 1-2 consultes.
Solució C — projecció a DTO (la més eficient):
public record LiniaInforme(String titolMaterial, String nomEmpleat,
LocalDate dataVenciment) {
public String formatar(LocalDate referencia) {
return "%s | %s | %d dies".formatted(titolMaterial, nomEmpleat,
ChronoUnit.DAYS.between(dataVenciment, referencia));
}
}@Query("""
SELECT new com.nexussoftware.bibliotech.domini.LiniaInforme(
m.titol, e.nom, p.dataVenciment)
FROM Prestec p JOIN p.material m JOIN p.empleat e
WHERE p.dataVenciment < :data
ORDER BY p.dataVenciment
""")
List<LiniaInforme> liniesVencuts(@Param("data") LocalDate data);1 consulta, i només 3 columnes en comptes de totes les de tres taules. Sense entitats gestionades, sense comprovació de canvis, sense risc d'N+1 posterior. Per a les categories caldria una consulta addicional o STRING_AGG en SQL natiu.
Comparació:
| Solució | Consultes | Dades transferides | Flexibilitat | Risc de reincidir |
|---|---|---|---|---|
A: JOIN FETCH |
1-2 | Totes les columnes | Mitjana | Mitjà |
B: @EntityGraph |
1-2 | Totes les columnes | Alta | Mitjà |
| C: DTO | 1 | Només el necessari | Baixa | Cap |
4. Quina triar. Per a un informe de només lectura, la C. Raons: porta només el necessari, no hi ha entitats gestionades ni comprovació de canvis, i —el més important— és estructuralment impossible que reaparegui l'N+1, perquè no hi ha proxies que puguin disparar-se. Amb A i B, algú que demà afegeixi un p.getMaterial().getEditorial().getPais() al format torna a tenir el problema.
Si el mètode hagués de retornar entitats per modificar-les després, la B, per ser declarativa i reutilitzable.
Solució 3
1. La seqüència.
| Moment | Transacció de Marta | Transacció de Diego | BD |
|---|---|---|---|
| t1 | SELECT material → disponibles = 1 |
1 | |
| t2 | SELECT material → disponibles = 1 |
1 | |
| t3 | comprovació 1 > 0 → OK |
1 | |
| t4 | comprovació 1 > 0 → OK |
1 | |
| t5 | UPDATE disponibles = 0 |
0 | |
| t6 | INSERT prestec |
||
| t7 | COMMIT | 0 | |
| t8 | UPDATE disponibles = 0 |
0 | |
| t9 | INSERT prestec |
||
| t10 | COMMIT | 0, amb 2 préstecs |
És una actualització perduda: l'UPDATE de Diego es va calcular a partir d'un valor obsolet. Amb READ_COMMITTED, cap de les dues transaccions no veu res anòmal.
2. Per què synchronized no serveix. synchronized sincronitza fils dins d'una mateixa JVM. En un desplegament real hi ha diverses instàncies de BiblioTech darrere d'un balancejador (12-06), i Marta i Diego poden estar atesos per processos diferents, en màquines diferents. Un monitor de la JVM A no bloqueja res a la JVM B. A més, encara que hi hagués una sola instància, serialitzar tots els préstecs de tots els materials per un únic pany seria un coll d'ampolla innecessari. La coordinació ha d'estar on és la dada compartida: a la base de dades.
3. Bloqueig optimista.
@Entity
public class Material {
@Version
private Long version; // unica addicio al model
public void prestarUnExemplar() { // logica DINS de l'entitat
if (exemplarsDisponibles <= 0) throw new SenseExemplarsException(isbn);
exemplarsDisponibles--;
}
}@Service
public class GestorPrestecs {
private static final Logger log = LoggerFactory.getLogger(GestorPrestecs.class);
private final GestorPrestecs self; // perque el reintent passi pel proxy (11-02)
@Transactional
public Prestec prestar(String isbn, String correu) {
Material material = materialRepository.findByIsbn(isbn)
.orElseThrow(() -> new MaterialNoTrobatException(isbn));
Empleat empleat = empleatRepository.findByCorreu(correu)
.orElseThrow(() -> new EmpleatNoTrobatException(correu));
material.prestarUnExemplar(); // valida i decrementa
Prestec prestec = new Prestec(material, empleat, LocalDate.now(rellotge), 15);
return prestecRepository.save(prestec);
} // COMMIT: update materials ... where id=? and version=?
// Reintent: cada intent es una transaccio NOVA
public Resultat<Prestec> prestarAmbReintent(String isbn, String correu) {
for (int intent = 1; intent <= 3; intent++) {
try {
return Resultat.exit(self.prestar(isbn, correu)); // passa pel proxy
} catch (OptimisticLockingFailureException e) {
log.warn("Conflicte a {} (intent {}/3)", isbn, intent);
} catch (SenseExemplarsException e) {
return Resultat.error("No queden exemplars de " + isbn);
}
}
return Resultat.error("El material s'esta modificant; torna-ho a provar.");
}
}Què passa ara a t8: l'UPDATE de Diego porta where id=1 and version=0, però la fila ja té version=1. Afecta 0 files, Hibernate llança OptimisticLockException i Spring la tradueix a OptimisticLockingFailureException. El reintent torna a llegir, veu disponibles=0 i retorna un error de negoci correcte.
Resultat<T> és el tipus genèric de 10-01, i el bucle de reintents és el teu ProxyReintents de 10-03 — que en un projecte real seria @Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 3).
4. Bloqueig pessimista.
public interface MaterialRepository extends JpaRepository<Material, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Material m WHERE m.isbn = :isbn")
Optional<Material> cercarPerPrestar(@Param("isbn") String isbn);
}@Transactional
public Prestec prestar(String isbn, String correu) {
// SELECT ... FOR UPDATE: bloqueja la fila fins al COMMIT
Material material = materialRepository.cercarPerPrestar(isbn)
.orElseThrow(() -> new MaterialNoTrobatException(isbn));
// Diego espera aqui fins que Marta confirmi; despres llegeix disponibles = 0
material.prestarUnExemplar(); // llanca SenseExemplarsException correctament
return prestecRepository.save(new Prestec(material, empleat, LocalDate.now(rellotge), 15));
}Convé afegir un temps màxim d'espera per no bloquejar indefinidament:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))5. Comparació i recomanació.
| Criteri | Optimista (@Version) |
Pessimista (FOR UPDATE) |
|---|---|---|
| Cost sense conflicte | Nul | Bloqueig a cada operació |
| Concurrència admesa | Alta | Baixa: es serialitzen |
| Què passa en conflicte | Excepció i reintent | Espera |
| Risc d'interbloqueig | Cap | Sí, si es bloquegen diverses files |
| Risc d'espera indefinida | No | Sí, sense timeout |
| Complexitat del codi | Gestió de l'excepció | Cap d'aparent |
| Funciona en diverses instàncies | Sí | Sí |
| Bo per a | Conflictes poc freqüents | Conflictes freqüents i cars |
Recomanació: bloqueig optimista. A BiblioTech, la probabilitat que dues persones prestin el mateix exemplar al mateix segon és baixíssima, i el bloqueig pessimista faria pagar el cost de la serialització a les milers d'operacions que mai no xoquen. @Version costa una anotació, no penalitza el cas normal i funciona igual amb una instància que amb vint.
El bloqueig pessimista es reserva per a operacions on el conflicte sigui la norma i la feina perduda sigui cara: per exemple, un procés nocturn que recalcula totes les multes i no vol reintentar cinc mil vegades.
Conclusió
Els CSV de BiblioTech han desaparegut. Hi ha una base de dades de veritat a sota, i saps exactament què hi està passant.
Entens el desajust objecte-relacional que un ORM existeix per gestionar: identitat doble, herència que en SQL no existeix, navegació que en Java és gratis i en SQL costa consultes, granularitat, col·leccions i tipus. I saps què guanyes i què perds enfront de JDBC directe —que vas veure en vint línies, amb el seu mapatge columna a columna, la seva conversió de tipus i la seva gestió de recursos— amb la regla professional que se'n deriva: ORM per al CRUD del domini, SQL directe per a informes i processos massius, i tots dos convivint sense conflicte.
Distingeixes JPA d'Hibernate: especificació (jakarta.persistence) enfront d'implementació, amb la recomanació de programar contra l'especificació i l'advertiment de versions que avui causa més errors de compilació que cap altre —jakarta, mai javax.
Saps mapejar entitats: @Entity, @Table, @Id, les estratègies de @GeneratedValue amb el motiu real pel qual SEQUENCE supera IDENTITY (permet agrupar insercions), @Column amb els seus atributs, i els requisits que imposa JPA —constructor sense arguments, res final— que expliquen per què un record no pot ser una entitat. Coneixes la regla absoluta d'@Enumerated(EnumType.STRING), perquè ORDINAL és el valor per defecte i reordenar un enum corromp silenciosament totes les dades històriques. Mapeges LocalDate i Instant de forma nativa amb la mateixa distinció de 10-05, escrius conversors amb AttributeConverter per a objectes de valor com Isbn, i fas servir @Embeddable per tenir objectes petits en Java sense fragmentar l'esquema.
Modeles relacions completes amb els seus quatre tipus, saps que el costat propietari és el que té la clau forana i que tocar només el costat invers no persisteix res —d'aquí els mètodes de conveniència—, i domines la decisió que més impacte té en el rendiment: FetchType. Saps que els valors per defecte són incoherents, que @ManyToOne ve en EAGER i que la regla professional és posar LAZY explícitament a tot i demanar el que necessitis a la consulta. Coneixes les dues conseqüències: la LazyInitializationException, amb les seves quatre solucions ordenades i el consell de deixar open-in-view a false perquè el problema aparegui a la teva màquina i no en producció; i el problema N+1, que converteix una consulta en 501 i que és invisible amb cinc files de prova, amb les seves solucions —JOIN FETCH, @EntityGraph, @BatchSize i, la més eficient per a informes, la projecció a DTO, on els record troben per fi el seu lloc—. I saps que passar a EAGER no arregla res: ho empitjora.
Entens el context de persistència, que és el cor conceptual de JPA: una memòria cau de primer nivell que garanteix que la mateixa fila és el mateix objecte, amb els quatre estats d'una entitat i les seves transicions. I amb això la comprovació de canvis automàtica, que sorprèn tothom: modifiques una entitat gestionada, no crides res, i en confirmar apareix un UPDATE. Saps com funciona (instantània i comparació), les seves quatre conseqüències —inclòs que un canvi accidental es persisteix— i per què readOnly = true la desactiva i estalvia feina real. Coneixes flush, que reordena les operacions i explica la meitat dels comportaments estranys d'Hibernate, i la diferència entre persist i merge que provoca el bug d'"he ignorat el valor de retorn".
Escrius consultes: JPQL sobre entitats i atributs, consultes amb nom validades a l'arrencada, l'API de criteris per al que és dinàmic, SQL natiu quan toca, i projeccions a record en una sola línia. I tens interioritzada la regla que no admet excepcions: tot valor extern va com a paràmetre, perquè concatenar és injecció SQL —amb la llista blanca com a única sortida quan el que és dinàmic és un nom de columna—.
Manejes transaccions amb base de dades real: ACID, @Transactional amb les seves dues regles heretades d'11-02, readOnly, propagació amb el cas legítim de REQUIRES_NEW i el seu cost en connexions, i aïllament amb els tres fenòmens explicats sobre BiblioTech i el consell de gairebé mai tocar-lo. I resols el cas de l'exemplar disputat amb bloqueig optimista amb @Version, entenent per què synchronized del mòdul 8 no serveix quan hi ha diverses instàncies: la coordinació ha d'estar on és la dada compartida. Amb reintent sobre Resultat<T>, que és el teu genèric de 10-01 i el teu ProxyReintents de 10-03 fets producció.
Coneixes les memòries cau de primer i segon nivell i quan la segona val la pena —dades que es llegeixen molt i canvien poc, mesurat abans—; i saps per què ddl-auto=update no es fa servir mai en producció: no esborra, no versiona, no migra dades i ningú revisa l'ALTER TABLE que executarà. validate més migracions versionades amb Flyway, que arriben a 12-06.
I has vist Spring Data JPA convertir vuitanta línies de repositori en una interfície buida, amb mètodes derivats del nom, @Query per al que no cap en un nom, i paginació. Amb el mecanisme al descobert: Proxy.newProxyInstance sobre una interfície, exactament el de 10-03, analitzant metadades del mètode i generant la consulta.
BiblioTech té ara entitats reals, relacions amb integritat referencial, transaccions ACID, bloqueig optimista i consultes amb índexs sobre H2. Arrenca amb mvn spring-boot:run i el seu esquema es crea sol.
I continua sense tenir ni una sola prova automàtica.
Aquest és ara, amb diferència, el deute més greu. Acabes de fer una migració enorme: has canviat el model de domini, has convertit record en classes, has substituït la persistència sencera i has introduït concurrència optimista amb reintents. Com saps que el càlcul de multes continua donant el mateix resultat? Que el límit de tres préstecs per empleat es continua respectant? Que un préstec que venç en diumenge continua avisant el dilluns? L'única resposta honesta avui és: arrencant l'aplicació i mirant.
I hi ha una cosa pitjor. Aquell Clock injectable que vas introduir a 10-05, que va passar per Spring a 11-02 com un @Bean i que en aquesta lliçó ha aparegut a cada LocalDate.now(rellotge) i cada Instant.now(rellotge), existeix exclusivament per poder provar el codi sense esperar quinze dies. Porta dos mòduls esperant que algú l'aprofiti.
A la propera lliçó s'aprofita. Veuràs què és una prova automàtica i per què el cost de no tenir-ne ja l'estàs pagant; coneixeràs la piràmide de proves i JUnit 5 complet, des de @Test i el patró Preparar-Actuar-Comprovar fins a les proves parametritzades que verifiquen dotze casos del càlcul de multes en sis línies; compararàs les assercions de JUnit amb les d'AssertJ; i descobriràs què fa que un disseny sigui provable — i per què tot el que has fet als dos últims mòduls (injecció per constructor, interfícies a les fronteres, el Clock injectat, el domini lliure d'anotacions) apuntava exactament allà.
BiblioTech tindrà la seva primera xarxa de seguretat.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
