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

  1. El desajust objecte-relacional
  2. Què es guanya i què es perd enfront de JDBC
  3. JDBC en vint línies: la línia base
  4. JPA enfront d'Hibernate: especificació i implementació
  5. Posar en marxa JPA amb H2 a BiblioTech
  6. La primera entitat: @Entity, @Table, @Id
  7. @GeneratedValue i les seves estratègies
  8. @Column i el mapatge de tipus
  9. @Enumerated: per què STRING i mai ORDINAL
  10. LocalDate, Instant i els conversors propis
  11. @Embeddable i @Embedded
  12. Relacions: els quatre tipus
  13. Costat propietari i mappedBy
  14. FetchType: càrrega mandrosa enfront d'ansiosa
  15. La LazyInitializationException
  16. El problema N+1
  17. Resoldre'l: JOIN FETCH i @EntityGraph
  18. CascadeType i orphanRemoval
  19. El context de persistència i l'EntityManager
  20. Els quatre estats d'una entitat
  21. La comprovació de canvis automàtica
  22. flush i l'ordre de les operacions
  23. Les operacions: persist, merge, find, getReference, remove
  24. Consultes: JPQL
  25. Consultes amb nom, API de criteris i SQL natiu
  26. Injecció SQL: per què mai no es concatena
  27. Transaccions: @Transactional amb base de dades real
  28. Propagació i aïllament
  29. Bloqueig optimista amb @Version
  30. Memòries cau de primer i segon nivell
  31. ddl-auto i per què mai update en producció
  32. Spring Data JPA
  33. BiblioTech: de CSV a H2
  34. Errors comuns i consells
  35. Exercicis

  1. 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.

  1. 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, JdbcTemplate o jOOQ) per a informes, agregacions complexes i processos massius.

Totes dues coses conviuen perfectament al mateix projecte, i això és el que fa BiblioTech.

  1. 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à:

  1. SQL escrit a mà per a cada consulta.
  2. Mapatge columna a columna, amb getLong, getString, getDate.
  3. Conversió de tipus: java.sql.Date a LocalDate, null a Optional.empty().
  4. Gestió de recursos: tres try-with-resources imbricats.
  5. Traducció d'excepcions: SQLException a la jerarquia del mòdul 6.
  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:

List<Prestec> prestecs = repositori.findByEmpleatCorreu(correuEmpleat);

I ni tan sols cal escriure aquesta línia, com veuràs a l'apartat 32.

  1. 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.* a jakarta.*. Spring Boot 3 i Hibernate 6 fan servir jakarta.persistence. Si copies un exemple amb import 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.

  1. 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 15

Amb això ja està. Executa:

mvn spring-boot:run -Dspring-boot.run.profiles=dev

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

  1. La primera entitat: @Entity, @Table, @Id

Convertim 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.

  1. @GeneratedValue i les seves estratègies

Qui 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.

  1. @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:

@Column(name = "import_multa", precision = 10, scale = 2)
private BigDecimal importMulta;

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.

  1. @Enumerated: per què STRING i mai ORDINAL

BiblioTech té tres enums des del mòdul 4: Gravetat, TipusMaterial i EstatPrestec. El seu mapatge té un parany amb conseqüències greus.

public enum EstatPrestec { ACTIU, VENCUT, RETORNAT }
// 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:

public enum EstatPrestec { ACTIU, RENOVAT, VENCUT, RETORNAT }
//                           0       1        2        3

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.

  1. LocalDate, Instant i els conversors propis

Bones 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 UTC

La 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.

  1. @Embeddable i @Embedded

Un 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.

  1. Relacions: els quatre tipus

El model de BiblioTech:

  • Un Prestec pertany a un Material i a un Empleat.
  • Un Material té molts Prestec.
  • Un Empleat té molts Prestec i moltes Reserva.
  • Un Material té moltes Categoria i una Categoria té molts Material.

@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

  1. Costat propietari i mappedBy

Aquest é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 mappedBy i é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.

  1. FetchType: càrrega mandrosa enfront d'ansiosa

Quan 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.LAZY explícitament a TOTES les associacions. I quan necessitis les dades relacionades, demana-les explícitament a la consulta amb JOIN FETCH o @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.

  1. La LazyInitializationException

La 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());   // BUM
org.hibernate.LazyInitializationException: could not initialize proxy
[com.nexussoftware.bibliotech.domini.Material#7] - no Session

Què ha passat, pas a pas:

  1. Dins de la transacció, Hibernate va carregar el préstec i va posar un proxy al camp material.
  2. La transacció va acabar i el context de persistència es va tancar.
  3. En cridar getTitol(), el proxy intenta llançar el SELECT... 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:

spring:
  jpa:
    open-in-view: false     # que els problemes es vegin en desenvolupament

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ó.

  1. 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 vegades

501 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:

  1. spring.jpa.show-sql=true i comptar les consultes al log.
  2. Estadístiques d'Hibernate: spring.jpa.properties.hibernate.generate_statistics=true.
  3. En producció: mètriques i traces distribuïdes (12-07).

  1. Resoldre'l: JOIN FETCH i @EntityGraph

Dues 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.

  1. CascadeType i orphanRemoval

Les 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).

  1. El context de persistència i l'EntityManager

Aquí 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:

  1. És una memòria cau de primer nivell. Si demanes dues vegades la mateixa entitat a la mateixa transacció, la segona no genera consulta.
  2. Garanteix identitat. Dins d'una transacció, la mateixa fila és sempre el mateix objecte Java: a == b és true. Això resol el problema d'identitat de l'apartat 1.
  3. 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.

  1. 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).

  1. 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.
update prestecs set data_venciment=?, version=? where id=? and version=?

Com? El mecanisme s'anomena dirty checking i funciona així:

  1. En carregar l'entitat, Hibernate desa una instantània de l'estat de tots els seus camps.
  2. En fer flush (normalment en confirmar), compara l'estat actual amb la instantània.
  3. Per a cada camp diferent, genera l'UPDATE corresponent.

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().

  1. flush i l'ordre de les operacions

flush é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:

  1. En confirmar la transacció (sempre).
  2. Abans d'executar una consulta que es pogués veure afectada pels canvis pendents.
  3. 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í:

  1. INSERT d'entitats, en ordre de persist
  2. UPDATE
  3. Esborrat d'elements de col·leccions
  4. Inserció d'elements de col·leccions
  5. DELETE d'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'isbn

La 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.

  1. Les operacions: 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);   // false

La 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 FK

L'ú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).

  1. 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 < :data

Exemples 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

  1. 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.

  1. 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:

' OR '1'='1

I la consulta es converteix en:

SELECT p FROM Prestec p WHERE p.empleat.correu = '' OR '1'='1'

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.

  1. Transaccions: @Transactional amb base de dades real

Ara 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.

  1. 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 ha 0. 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.

  1. Bloqueig optimista amb @Version

El 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ó:

update materials set exemplars_disponibles=0, version=6 where id=1 and version=5

Si un altre ja va actualitzar, la fila té version=6 i la condició no troba cap fila. Hibernate ho detecta i llança:

jakarta.persistence.OptimisticLockException:
Row was updated or deleted by another transaction

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.

  1. 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.JCacheRegionFactory

Quan 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:

  1. Coherència: si una altra aplicació modifica la base de dades, la teva memòria cau queda obsoleta i serveix dades velles.
  2. 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.
  3. 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.

  1. 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:

  1. Mai no esborra res. Si treus un camp, la columna es queda allà per sempre. Si reanomenes titol a nom, afegeix nom i deixa titol — i no copia les dades.
  2. No versiona res. No hi ha registre de quins canvis es van aplicar ni quan, i no hi ha manera de desfer-los.
  3. És impredictible. El SQL que genera depèn del dialecte, de la versió d'Hibernate i de l'estat actual de l'esquema.
  4. No es pot revisar abans. Ningú ha vist l'ALTER TABLE que s'executarà contra la base de dades de producció.
  5. No pot fer migracions de dades. Dividir nom_complet en nom i cognoms requereix 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: validate

validate é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.

  1. 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ó

Page<Prestec> findByEstat(EstatPrestec estat, Pageable paginacio);
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:

  1. Escaneja les interfícies que estenen Repository.
  2. Per a cadascuna, crea una implementació al vol amb Proxy.newProxyInstance — el mateix de 10-03.
  3. L'InvocationHandler rep 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'EntityManager i 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.

  1. 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 contrasenya

L'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

  1. 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ó.

  1. 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:

  1. Taula reserves amb clau primària autogenerada.
  2. Relacions @ManyToOne mandroses a Material i Empleat, no cadenes.
  3. L'enum desat de forma segura.
  4. instantAvis anul·lable a la base de dades però exposat com a Optional.
  5. Bloqueig optimista.
  6. Un índex sobre (material_id, estat) per a la consulta de pendents.
  7. Un mètode de domini avisar(Clock rellotge) que canviï l'estat i registri l'instant, amb validació.
  8. 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:

  1. Calcula quantes consultes SQL s'executen amb 800 préstecs, desglossades.
  2. Explica exactament per què.
  3. Proposa tres solucions diferents amb el seu codi, indicant quantes consultes queden en cadascuna.
  4. 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:

  1. Descriu la seqüència exacta d'esdeveniments que produeix dos préstecs amb un sol exemplar.
  2. Explica per què synchronized (08-04) no resol això en un desplegament real.
  3. Resol-ho amb bloqueig optimista, incloent-hi la gestió de l'excepció amb reintent.
  4. Resol-ho amb bloqueig pessimista.
  5. 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è un record és final i sense constructor buit.
  • Relacions en comptes de cadenes: Material i Empleat en comptes d'isbn i correuEmpleat. Dóna integritat referencial (no es pot reservar un material inexistent) i navegació. El preu és haver de gestionar la mandra.
  • Optional fora, camp anul·lable dins: es conserva la garantia de 10-04 sense barallar-se amb JPA.
  • El comportament viu a l'entitat: avisar, completar i haCaducat validen les transicions. L'entitat no és una bossa de dades.
  • @EntityGraph a 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

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats