CicloUrbana ja té lampisteria: un DataSource, un pool HikariCP dimensionat i dos motors a punt —H2 en memòria per desenvolupar i PostgreSQL 16 en Docker—. Però la base de dades està completament buida, perquè Hibernate no té res a mapejar. Aquesta lliçó omple aquest buit: converteix el model de domini de la xarxa de Ribalta en entitats JPA, les classes que Hibernate sap traduir a taules i files.

És una lliçó de decisions. Cada anotació que hi posis condiciona l'esquema físic, el rendiment de les consultes i la facilitat amb què el model podrà evolucionar d'aquí a dos anys. Triar malament l'estratègia d'identificadors penalitza cada inserció; desar imports en double produeix factures incorrectes que ningú no detecta fins que un ciutadà reclama; mapejar un enumerat per la seva posició converteix una simple reordenació del codi en una corrupció silenciosa de dades. Fixarem cadascuna d'aquestes decisions amb la seva raó.

Contingut

  1. Per què un record no pot ser una entitat
  2. @Entity i @Table
  3. @Id i les quatre estratègies de @GeneratedValue
  4. @Column i el control de la columna
  5. Tipus de dades i el seu mapatge
  6. Enumerats: STRING enfront d'ORDINAL
  7. Camps derivats i @Transient
  8. Objectes incrustats: @Embeddable i @Embedded
  9. Auditoria automàtica
  10. Bloqueig optimista amb @Version
  11. equals i hashCode en entitats JPA
  12. Entitats i DTOs: la separació es manté
  13. Errors Comuns i Consells
  14. Exercicis

  1. Per què un record no pot ser una entitat

Des de 01-03 el domini de CicloUrbana fa servir record:

public record Estacio(Long id, String nom, String adreca,
                      int capacitat, double latitud, double longitud) { }

Ha estat una decisió excel·lent per al mòdul 3: immutabilitat, equals/hashCode de franc i zero codi repetitiu. Però un record no pot ser una entitat JPA, i no per un caprici de l'especificació:

Requisit de JPA Un record el compleix Per què JPA el necessita
Constructor sense arguments No Hibernate instancia l'entitat buida i després omple els camps
Camps mutables No (són final) El dirty checking i la càrrega mandrosa reescriuen camps
La classe no pot ser final No (els record ho són) Hibernate genera subclasses proxy per al LAZY
Identitat per clau primària Discrepa L'equals d'un record compara tots els camps

Els tres primers són tècnics i n'hi ha prou per tancar la discussió. El quart és conceptual i més profund: un record és un objecte de valor, definit pel conjunt dels seus camps; una entitat és un objecte amb identitat, i l'estació 1 continua sent l'estació 1 encara que canviï de nom, adreça i capacitat.

La regla que adopta el curs: record per a DTOs i objectes de valor; classes mutables per a entitats. Els record de com.ciclourbana.comu.dto es queden exactament on són (03-05); el que canvia és el domini.

L'entitat resultant:

package com.ciclourbana.estacions;

import jakarta.persistence.*;

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

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

    private String nom;
    private String adreca;
    private int capacitat;

    protected Estacio() { }   // requerit per JPA; no fer-lo servir des del domini

    public Estacio(String nom, String adreca, int capacitat) {
        this.nom = nom;
        this.adreca = adreca;
        this.capacitat = capacitat;
    }

    public Long getId() { return id; }
    public String getNom() { return nom; }
    public void setNom(String nom) { this.nom = nom; }
}

Dos detalls amb intenció: el constructor sense arguments és protected, no public —JPA només exigeix que sigui visible per a la subclasse proxy, i així el codi de negoci no pot crear estacions a mig construir—; i no hi ha setId, perquè l'identificador l'assigna la base de dades i permetre canviar-lo des de fora obre la porta a corrompre la identitat d'una fila.

  1. @Entity i @Table

@Entity marca la classe com a gestionable per JPA. Amb això n'hi ha prou: si no dius res, la taula es dirà com la classe.

@Table dona control sobre el mapatge físic:

@Entity
@Table(name = "estacions", schema = "public",
    uniqueConstraints = @UniqueConstraint(name = "uk_estacions_nom",
                                          columnNames = "nom"),
    indexes = {
        @Index(name = "idx_estacions_activa", columnList = "activa"),
        @Index(name = "idx_estacions_ubicacio", columnList = "latitud, longitud")
    })
public class Estacio { /* ... */ }
Atribut Per a què serveix
name Nom de la taula. Declara'l sempre
schema Esquema de la base de dades
uniqueConstraints Restriccions d'unicitat d'una o diverses columnes
indexes Índexs que es crearan amb l'esquema

Tres advertiments:

  • Declara sempre name explícitament. Per defecte Spring Boot aplica CamelCaseToUnderscoresNamingStrategy (FitxaTecnica → fitxa_tecnica): funciona, però deixa el nom físic a mercè d'una estratègia configurable.
  • indexes i uniqueConstraints només actuen quan Hibernate genera l'esquema. Amb ddl-auto: validate són pura documentació —els índexs reals els crearà Flyway a 04-08—, però convé declarar-los per tenir entitat i esquema descrits al mateix lloc.
  • La unicitat de nom no substitueix la validació de negoci: EstacioService continuarà comprovant existeixPerNom per retornar un 409 amb ProblemDetail llegible (03-06) en lloc de deixar escapar una DataIntegrityViolationException. La restricció és l'última línia de defensa, la que cobreix les condicions de cursa.

  1. @Id i les quatre estratègies de @GeneratedValue

Tota entitat necessita una clau primària marcada amb @Id. @GeneratedValue indica qui produeix el valor.

Estratègia Com funciona Avantatge Inconvenient Motor
AUTO Hibernate tria per tu No cal decidir Impredictible entre versions i motors Tots
IDENTITY Columna autoincremental de la BD Simple, sense objectes extra Desactiva la inserció per lots MySQL, PostgreSQL (serial)
SEQUENCE Objecte seqüència de la BD Permet lots i reserva de blocs Requereix suport de seqüències PostgreSQL, Oracle, H2
TABLE Una taula que desa comptadors Portable a qualsevol motor Lenta, contenció i bloqueigs Tots

Amb PostgreSQL, la resposta és SEQUENCE amb allocationSize, i el motiu és concret i mesurable. Amb IDENTITY, Hibernate no coneix l'id fins després de l'INSERT, i com que el necessita per registrar l'entitat al context de persistència, està obligat a executar l'INSERT immediatament, saltant-se l'escriptura diferida: això deshabilita per complet la inserció per lots, i inserir 1.000 bicicletes són 1.000 viatges d'anada i tornada. Amb SEQUENCE i allocationSize = 50, en canvi, Hibernate demana un valor a la seqüència i reserva en memòria els 50 següents, així que assigna ids sense consultar res i agrupa els INSERT en lots:

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

Amb spring.jpa.properties.hibernate.jdbc.batch_size: 20 (que ja vam fixar a 04-02), inserir 1.000 bicicletes passa de ~1.000 viatges a ~50. Al catàleg nocturn de bicicletes de Ribalta la diferència és de minuts a segons.

Regla crítica: l'allocationSize de l'anotació ha de coincidir amb l'INCREMENT BY de la seqüència real. Si la seqüència es va crear amb INCREMENT BY 1 i declares allocationSize = 50, Hibernate assignarà ids que ja existeixen o que col·lidiran. A 04-08 crearem les seqüències a Flyway amb l'increment correcte (CREATE SEQUENCE estacions_id_seq START WITH 1 INCREMENT BY 50;).

Un darrer avís sobre AUTO: amb Hibernate 6 sobre PostgreSQL sol acabar en SEQUENCE, però delega al proveïdor una decisió que afecta el rendiment i que pot canviar en actualitzar. Declara'l tu.

  1. @Column i el control de la columna

@Column descriu la columna física. Sense ella, el nom es deriva del camp i la resta són valors per defecte.

@Column(name = "nom", nullable = false, length = 80, unique = true)
private String nom;

@Column(name = "capacitat", nullable = false)
private int capacitat;

@Column(name = "matricula", nullable = false, length = 10, updatable = false)
private String matricula;

@Column(name = "import_total", precision = 8, scale = 2)
private BigDecimal importTotal;
Atribut Què controla DDL generat
name Nom físic nom
nullable Admet nuls not null
length Longitud de text varchar(80)
precision / scale Dígits totals i decimals numeric(8,2)
unique Unicitat d'una sola columna unique
updatable S'inclou als UPDATE Només afecta el SQL, no el DDL
insertable S'inclou als INSERT Només afecta el SQL

Quatre observacions pràctiques:

  • length per defecte és 255. Deixar-ho així converteix cada text en un varchar(255) sense criteri. La matricula de CicloUrbana és RB-0142: set caràcters, i length = 10 és generós i descriptiu.
  • nullable = false és documentació executable, i va de la mà de @NotNull (03-04): la validació produeix un 400 llegible, la restricció de columna és la garantia última d'integritat.
  • updatable = false encaixa amb la matrícula: s'assigna en donar d'alta la bicicleta i no canvia mai, així que Hibernate l'exclou dels UPDATE.
  • unique = true a @Column enfront de uniqueConstraints a @Table: la primera genera un nom de restricció aleatori (UK_a7f3b2...) que acabarà apareixent als missatges d'error; amb @Table la nomenes i a més pots cobrir diverses columnes.

  1. Tipus de dades i el seu mapatge

Tipus Java Tipus SQL (PostgreSQL) Ús a CicloUrbana Notes
String varchar(n) nom, adreca, matricula Fixa length
int / Integer integer capacitat, nivellBateria El primitiu no admet nuls
long / Long bigint Identificadors Fes servir Long per a l'@Id
BigDecimal numeric(p,s) importTotal, preuMinut Obligatori per als diners
double double precision Mai per a imports Només magnituds físiques
boolean boolean activa Amb nullable = false
LocalDate date dataAlta d'un usuari Sense hora ni zona
LocalDateTime timestamp Ús local sense zona Ambigu entre zones
Instant timestamp with time zone inici, fi d'un lloguer Recomanat
Duration bigint (nanosegons) Durada de lloguer Mapatge automàtic a Hibernate 6
enum varchar amb STRING EstatBicicleta Mai ORDINAL
byte[] + @Lob bytea Foto d'una incidència Carrega'l mandrosament
UUID uuid Claus públiques Natiu a PostgreSQL

Per què BigDecimal i mai double per als diners. No és purisme: double és coma flotant binària i no pot representar exactament valors decimals com 0,1. El resultat:

System.out.println(0.1 + 0.2);                                   // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3

Un lloguer de CicloUrbana a 0,15 €/minut durant 47 minuts ha de donar exactament 7,05 €. Amb double, després d'acumular milers de lloguers els cèntims es desvien i el descuadrament comptable apareix a final de mes sense cap culpable identificable. Amb BigDecimal i numeric(8,2), el valor és exacte d'extrem a extrem.

Dues precaucions en fer-lo servir: construeix sempre des d'un String, perquè new BigDecimal(0.1) arrossega l'error del double; i compara amb compareTo, no amb equals, ja que equals considera diferents 2.50 i 2.5 per diferir en escala.

Per què Instant per a les marques de temps. Instant és un punt absolut a la línia del temps, independent de zona horària, i Hibernate 6 el mapeja a timestamp with time zone. Un lloguer iniciat a les 02:30 de l'últim diumenge d'octubre —quan a Ribalta el rellotge endarrereix una hora— passa en un instant únic i inequívoc. Amb LocalDateTime aquella hora existeix dues vegades i la durada del lloguer pot sortir negativa.

Per a byte[], declara sempre @Lob @Basic(fetch = FetchType.LAZY): sense LAZY portaries diversos megabytes cada cop que es llegeix una incidència. Tot i així, la recomanació general és desar els binaris en un magatzem d'objectes i a la base de dades només la seva URL.

  1. Enumerats: STRING enfront d'ORDINAL

EstatBicicleta ve del mòdul 1:

public enum EstatBicicleta {
    DISPONIBLE, EN_US, MANTENIMENT, RETIRADA
}

I a l'entitat:

@Enumerated(EnumType.STRING)
@Column(name = "estat", nullable = false, length = 20)
private EstatBicicleta estat = EstatBicicleta.DISPONIBLE;
Mode Què desa Consulta directa en SQL Davant d'una reordenació
ORDINAL (per defecte) La posició: 0, 1, 2 Il·legible Corrupció silenciosa
STRING El nom: DISPONIBLE Llegible Segur

El perill d'ORDINAL és que el valor per defecte de JPA és justament el perillós. Imagina que d'aquí a un any algú afegeix un estat nou respectant l'ordre alfabètic:

public enum EstatBicicleta {
    AVARIADA, DISPONIBLE, EN_US, MANTENIMENT, RETIRADA
}

Totes les files amb estat = 0, que significaven DISPONIBLE, passen a significar AVARIADA. Sense error, sense excepció i sense avís: centenars de bicicletes de Ribalta apareixen avariades d'un dia per l'altre, i no hi ha manera de recuperar la dada original perquè el 0 no diu què significava.

@Enumerated(EnumType.STRING) en tots els enumerats, sense excepció. El cost —uns bytes per fila— és irrellevant davant de dades llegibles, consultables (WHERE estat = 'DISPONIBLE') i immunes a la reordenació. Afegeix a més una restricció CHECK a l'esquema (04-08) perquè la base de dades rebutgi valors desconeguts.

  1. Camps derivats i @Transient

@Transient marca un camp que no es persisteix. És la contrapartida al fet que, per defecte, JPA mapeja tots els camps.

@Transient
private int bicicletesDisponibles;

@Transient
public boolean esGairebePlena() {
    return bicicletesDisponibles >= capacitat * 0.9;
}

Casos legítims: valors calculats en temps d'execució, memòries cau locals o dades que arriben d'un altre servei. Compte amb l'import: jakarta.persistence.Transient, no java.beans.Transient ni la paraula clau transient de Java.

Existeix també @Formula, específica d'Hibernate, que calcula un camp amb una subconsulta SQL incrustada a l'entitat —per exemple, comptar les bicicletes disponibles de l'estació—. És temptadora, però té tres inconvenients seriosos: fica SQL d'un motor concret dins del domini, s'executa en cada càrrega encara que ningú no faci servir el camp, i no és utilitzable des de JPQL. A CicloUrbana preferim calcular aquests agregats en una consulta explícita amb projecció (04-06), on el cost és visible i es paga només quan cal.

  1. Objectes incrustats: @Embeddable i @Embedded

Estacio té latitud i longitud, dos camps que sempre viatgen junts i que ja a 03-05 van donar lloc a UbicacioResponse. Un incrustat permet agrupar-los en un objecte sense crear una taula:

package com.ciclourbana.comu;

@Embeddable
public class Ubicacio {

    @Column(name = "latitud", nullable = false, precision = 9, scale = 6)
    private BigDecimal latitud;

    @Column(name = "longitud", nullable = false, precision = 9, scale = 6)
    private BigDecimal longitud;

    protected Ubicacio() { }

    public Ubicacio(BigDecimal latitud, BigDecimal longitud) {
        this.latitud = latitud;
        this.longitud = longitud;
    }

    public BigDecimal getLatitud() { return latitud; }
    public BigDecimal getLongitud() { return longitud; }
    // equals/hashCode comparant tots dos camps amb compareTo: és un objecte de VALOR.
}
@Embedded
private Ubicacio ubicacio;

Les columnes continuen vivint a la taula estacions: no hi ha JOIN ni taula nova, només una agrupació al model Java. Què hi guanya CicloUrbana: el comportament va amb les dades (distanciaA(Ubicacio altra) és un mètode d'Ubicacio, no una utilitat estàtica solta); la restricció @CoordenadesValides de 03-04 s'aplica a un objecte en lloc de a dos camps solts; la classe és reutilitzable, perquè Bicicleta també tindrà una Ubicacio amb la seva última posició GPS; i és un objecte de valor de manual, sense identitat pròpia, per la qual cosa aquí equals sí que compara camps, al contrari que en una entitat.

Quan una entitat necessita dos incrustats del mateix tipus, les columnes xocarien. @AttributeOverride les reanomena:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "latitud",
        column = @Column(name = "latitud_recollida", precision = 9, scale = 6)),
    @AttributeOverride(name = "longitud",
        column = @Column(name = "longitud_recollida", precision = 9, scale = 6))
})
private Ubicacio ubicacioRecollida;

// El segon incrustat és idèntic, reanomenant a latitud_lliurament i longitud_lliurament:
@Embedded
@AttributeOverrides({ /* ... */ })
private Ubicacio ubicacioLliurament;

Un Lloguer pot així registrar on es va recollir i on es va deixar la bicicleta, reutilitzant la mateixa classe.

  1. Auditoria automàtica

Saber quan es va crear i quan es va modificar per última vegada cada fila és una necessitat universal. Spring Data ho automatitza.

Primer, activar-ho amb una classe @Configuration anotada amb @EnableJpaAuditing —a CicloUrbana, com.ciclourbana.comu.config.ConfiguracioAuditoria—. Després, una classe base amb els camps comuns:

package com.ciclourbana.comu;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class EntitatAuditable {

    @CreatedDate
    @Column(name = "creat_el", nullable = false, updatable = false)
    private Instant creatEl;

    @LastModifiedDate
    @Column(name = "modificat_el", nullable = false)
    private Instant modificatEl;

    public Instant getCreatEl() { return creatEl; }
    public Instant getModificatEl() { return modificatEl; }
}

I les entitats l'estenen:

@Entity
@Table(name = "estacions")
public class Estacio extends EntitatAuditable { /* ... */ }

Les peces i el seu paper:

Anotació Què fa
@EnableJpaAuditing Activa el mecanisme al context
@MappedSuperclass Classe base els camps de la qual s'hereten sense taula pròpia
@EntityListeners(AuditingEntityListener.class) Enganxa l'interceptor que omple els camps
@CreatedDate S'omple només a l'INSERT
@LastModifiedDate S'actualitza a cada UPDATE

@MappedSuperclass és clau: no crea una taula entitat_auditable. Les seves columnes es copien a la taula de cada entitat filla. És diferent de l'herència d'entitats, que veurem a 04-04.

Per saber qui va fer el canvi existeixen a més @CreatedBy i @LastModifiedBy, que requereixen un AuditorAware<String> amb l'usuari actual. Com que no hi haurà usuari autenticat fins al mòdul 5, queda anotat com a feina futura.

  1. Bloqueig optimista amb @Version

A 03-03 vam resoldre les actualitzacions perdudes amb ETag i If-Match, recolzats en ShallowEtagHeaderFilter. Aquella solució funcionava a la capa HTTP, però calculava l'ETag a partir del cos de la resposta, és a dir, després d'haver fet tota la feina. JPA ofereix una cosa molt millor: bloqueig optimista a la capa de dades.

@Version
@Column(name = "versio", nullable = false)
private Long versio;

Com funciona: en carregar l'estació 1, Hibernate llegeix també versio = 7; en actualitzar-la, genera un UPDATE que porta la versió a la clàusula WHERE i la incrementa.

UPDATE estacions SET nom = ?, capacitat = ?, versio = 8 WHERE id = 1 AND versio = 7;

Si un altre usuari ja l'ha modificada, la seva versió és 8 i l'UPDATE afecta 0 files. Hibernate ho detecta i llança OptimisticLockException, que Spring tradueix a ObjectOptimisticLockingFailureException.

sequenceDiagram
    participant A as Operari A
    participant B as Operari B
    participant BD as PostgreSQL

    A->>BD: llegir estació 1 (versio=7)
    B->>BD: llegir estació 1 (versio=7)
    A->>BD: UPDATE ... WHERE id=1 AND versio=7
    BD-->>A: 1 fila -> ara versio=8
    B->>BD: UPDATE ... WHERE id=1 AND versio=7
    BD-->>B: 0 files
    Note over B: OptimisticLockException -> HTTP 409

Se'n diu «optimista» perquè no bloqueja res: aposta que els conflictes són rars i només els detecta quan passen. Enfront de l'ETag:

Aspecte ETag/If-Match (03-03) @Version (JPA)
On viu Capa HTTP Capa de dades
Què protegeix Una petició HTTP concreta Tota escriptura, vingui d'on vingui
Cost Serialitzar i calcular un hash Una columna bigint
Detecta conflictes entre fils interns No Sí

La conclusió pràctica: @Version substitueix el ShallowEtagHeaderFilter. Es pot continuar generant una capçalera ETag a partir del número de versió —és net i barat—, però la protecció real ja no depèn d'HTTP.

Només falta tancar el cercle al gestor global de 03-06:

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ProblemDetail gestionarConflicteConcurrencia(
        ObjectOptimisticLockingFailureException ex) {

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "El recurs ha estat modificat per un altre usuari. Recarrega-ho i torna-ho a provar.");
    problema.setTitle("Conflicte de concurrència");
    problema.setType(URI.create("https://api.ciclourbana.example/errors/conflicte-concurrencia"));
    problema.setProperty("codi", "CONFLICTE_CONCURRENCIA");
    return problema;
}

El client rep un 409 amb el mateix format RFC 7807 que la resta d'errors de CicloUrbana. A 04-07 compararem aquest bloqueig amb el pessimista, que sí que bloqueja files i és necessari en la cursa per l'última bicicleta d'una estació.

  1. equals i hashCode en entitats JPA

Aquest apartat corregeix un dels errors més estesos. La temptació és deixar que l'IDE generi equals/hashCode amb tots els camps, o només amb l'id. Totes dues opcions fallen.

El problema de l'id generat. Una entitat nova té id = null. Si la fiques en un HashSet i després la deses, l'id passa a valer 42, el seu hashCode canvia i l'objecte queda perdut dins del conjunt: contains retorna false encara que hi sigui, perquè es busca en un cubell diferent.

Set<Estacio> conjunt = new HashSet<>();
Estacio estacio = new Estacio("Universitat", "Campus Sud, accés B", 36);
conjunt.add(estacio);              // hashCode calculat amb id = null
estacioRepositori.save(estacio);   // ara id = 4: el hashCode ha canviat
conjunt.contains(estacio);         // false. L'entitat hi és i no es troba

Fer servir tots els camps tampoc no val, perquè l'equals canviaria cada cop que es modifica un atribut, amb el mateix efecte sobre les col·leccions, i perquè conceptualment és fals: l'estació 1 continua sent la mateixa encara que li canviïn el nom.

La solució recomanada, i la que adopta CicloUrbana:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    // Hibernate.getClass desembolcalla el proxy de la càrrega mandrosa
    if (o == null || !Hibernate.getClass(this).equals(Hibernate.getClass(o))) return false;
    return id != null && id.equals(((Estacio) o).getId());
}

@Override
public int hashCode() {   // constant: estable durant tota la vida de l'objecte
    return getClass().hashCode();
}

Les tres decisions i el seu perquè:

  • hashCode constant. Sembla una heretgia —totes les entitats cauen al mateix cubell—, però el contracte només exigeix que objectes iguals tinguin el mateix hash, i una col·lecció d'entitats en memòria rarament passa d'unes desenes d'elements: el cost és menyspreable davant de la correcció.
  • id != null && ...: dues entitats noves (totes dues amb id null) mai no són iguals entre si, només amb si mateixes per this == o. Són dues estacions diferents encara sense desar, i aquesta és la semàntica correcta.
  • Hibernate.getClass() en lloc de getClass(): amb càrrega mandrosa l'objecte pot ser un proxy d'una subclasse generada, i comparar getClass() faria que una entitat i el seu propi proxy resultessin diferents.

Si prefereixes no dependre d'Hibernate al domini, l'alternativa professional és una clau de negoci immutable: matricula a Bicicleta, correu a Usuari. És estable des de la creació i no depèn de l'id generat. Només serveix si existeix un atribut realment únic i immutable.

  1. Entitats i DTOs: la separació es manté

A 03-05 vam separar domini i contracte amb DTOs. Ara que el domini són entitats JPA, aquesta separació passa de recomanable a imprescindible per tres motius nous: les referències circulars (a 04-04, Estacio tindrà una llista de Bicicleta i cada Bicicleta una referència a la seva Estacio, i serialitzar això és un bucle infinit); la càrrega mandrosa, que amb open-in-view: false llança LazyInitializationException en serialitzar fora de la transacció; i les consultes ocultes que la serialització pot disparar durant la generació del JSON.

El bo és que no cal canviar res: CrearEstacioRequest, EstacioResponse, EstacioDetallResponse i EstacioMapper continuen sent vàlids. Només el mapejador s'adapta al canvi de record a classe:

package com.ciclourbana.estacions;

@Mapper(componentModel = "spring")
public interface EstacioMapper {

    @Mapping(target = "latitud", source = "ubicacio.latitud")
    @Mapping(target = "longitud", source = "ubicacio.longitud")
    EstacioResponse aResponse(Estacio estacio);

    @Mapping(target = "id", ignore = true)
    @Mapping(target = "versio", ignore = true)
    @Mapping(target = "creatEl", ignore = true)
    @Mapping(target = "modificatEl", ignore = true)
    @Mapping(target = "activa", constant = "true")
    @Mapping(target = "ubicacio", source = ".", qualifiedByName = "aUbicacio")
    Estacio aEntitat(CrearEstacioRequest peticio);

    @Named("aUbicacio")
    default Ubicacio aUbicacio(CrearEstacioRequest p) {
        return new Ubicacio(p.latitud(), p.longitud());
    }
}

Els ignore = true són deliberats i mereixen explicació: l'id el genera la seqüència, la versió la gestiona Hibernate i les dates d'auditoria l'AuditingEntityListener. Que MapStruct els toqués seria, en el millor dels casos, inútil, i en el pitjor, una font de conflictes de bloqueig optimista fantasma.

Errors Comuns i Consells

Deixar @Enumerated sense especificar. El valor per defecte és ORDINAL i l'errada és silenciosa i catastròfica. Escriu sempre @Enumerated(EnumType.STRING).

Fer servir double per als imports. Els cèntims es perden i el descuadrament apareix setmanes després sense origen identificable. BigDecimal amb precision/scale, sempre.

Generar equals/hashCode amb l'IDE. Produeix un hashCode que canvia en desar i entitats que es perden als HashSet. Fes servir el patró de l'apartat 11.

Triar IDENTITY per costum. Amb PostgreSQL desactiva la inserció per lots. SEQUENCE amb allocationSize és l'opció correcta, vigilant que l'increment coincideixi amb el de la seqüència real.

Oblidar el constructor sense arguments. L'error és críptic: No default constructor for entity. Si a més afegeixes un constructor amb paràmetres, Java deixa de generar-lo automàticament.

Posar @Transient de java.beans. Compila i no fa res; el camp es persisteix igual. Verifica l'import: jakarta.persistence.Transient.

Consell: @Version a tota entitat que es modifiqui. Costa una columna i preveu tota una família d'errors de concurrència. A CicloUrbana la porten Estacio, Bicicleta i Lloguer.

Consell: revisa el DDL que genera Hibernate. Obre la consola d'H2 i llança SHOW COLUMNS FROM estacions: hi trobaràs varchar(255) on esperaves varchar(80) i descobriràs quina anotació falta.

Consell: no posis lògica de negoci pesada a l'entitat, però tampoc no la deixis anèmica. Mètodes com estacio.teEspaiLliure() o bicicleta.esPotLlogar() pertanyen a l'entitat; orquestrar un lloguer complet pertany al servei.

Exercicis

Exercici 1: convertir Bicicleta en entitat

Converteix la classe Bicicleta de CicloUrbana en una entitat JPA completa. Requisits: taula bicicletes; matrícula única, obligatòria, de 10 caràcters i no modificable; EstatBicicleta com a text; nivell de bateria entre 0 i 100 i no nul; identificador per seqüència amb reserva de 50; bloqueig optimista; auditoria heretada; i índex per estat. No hi incloguis encara la relació amb Estacio.

Exercici 2: trobar cinc errors

Aquesta entitat Lloguer té cinc defectes greus. Troba'ls i corregeix-los.

@Entity
public class Lloguer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated
    private EstatLloguer estat;

    private double importTotal;

    private LocalDateTime inici;

    public Lloguer(Long usuariId, Long bicicletaId) {
        this.usuariId = usuariId;
        this.bicicletaId = bicicletaId;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Lloguer altre)) return false;
        return Objects.equals(id, altre.id)
            && Objects.equals(importTotal, altre.importTotal)
            && Objects.equals(inici, altre.inici);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, importTotal, inici);
    }
}

Exercici 3: bloqueig optimista d'extrem a extrem

Descriu què passa exactament, pas a pas i amb el SQL implicat, quan dos operaris de l'ajuntament de Ribalta actualitzen la capacitat de l'estació «Estació Nord» (versio = 3) simultàniament. Indica què rep cadascun i què hauria de fer el client.

Solucions

Solució 1.

package com.ciclourbana.bicicletes;

import com.ciclourbana.comu.EntitatAuditable;
import jakarta.persistence.*;

@Entity
@Table(
    name = "bicicletes",
    uniqueConstraints = @UniqueConstraint(name = "uk_bicicletes_matricula",
                                          columnNames = "matricula"),
    indexes = @Index(name = "idx_bicicletes_estat", columnList = "estat")
)
public class Bicicleta extends EntitatAuditable {

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

    @Column(name = "matricula", nullable = false, length = 10, updatable = false)
    private String matricula;

    @Enumerated(EnumType.STRING)
    @Column(name = "estat", nullable = false, length = 20)
    private EstatBicicleta estat = EstatBicicleta.DISPONIBLE;

    @Min(0) @Max(100)
    @Column(name = "nivell_bateria", nullable = false)
    private Integer nivellBateria;

    @Version
    @Column(name = "versio", nullable = false)
    private Long versio;

    protected Bicicleta() { }

    public Bicicleta(String matricula, Integer nivellBateria) {
        this.matricula = matricula;
        this.nivellBateria = nivellBateria;
    }

    public boolean esPotLlogar(int llindarBateria) {
        return estat == EstatBicicleta.DISPONIBLE && nivellBateria >= llindarBateria;
    }

    // Accessors de lectura per a tot; setter només a estat i nivellBateria.
    // equals/hashCode amb el patró de l'apartat 11 (id no nul + hash constant).
}

El rang 0-100 es declara amb @Min(0) @Max(100) de Bean Validation (03-04), que Hibernate tradueix a més a una restricció CHECK en generar l'esquema; a 04-08 l'escriurem explícitament al SQL de Flyway. El mètode esPotLlogar rep el llindar des de XarxaProperties (ciclourbana.xarxa.llindar-bateria), configurat a 02-05.

Solució 2. Els cinc defectes:

  1. Falta @Table(name = "lloguers"). Sense ell, el nom físic depèn de l'estratègia de nomenclatura. Afegeix-lo, amb els seus índexs per usuari_id i bicicleta_id.
  2. @Enumerated sense EnumType.STRING. Es desa l'ordinal. Reordenar EstatLloguer corrompria tot l'històric de lloguers de Ribalta.
  3. double importTotal. Error d'arrodoniment acumulat a les factures. Ha de ser BigDecimal amb @Column(precision = 8, scale = 2).
  4. Falta el constructor sense arguments. En declarar-ne un amb paràmetres, Java ja no genera l'implícit, i Hibernate falla en arrencar amb No default constructor for entity.
  5. equals/hashCode sobre camps mutables. El hashCode canvia quan es calcula l'import en finalitzar el lloguer, i l'entitat es perd en qualsevol HashSet.

Defectes menors igualment retretables: IDENTITY en lloc de SEQUENCE, LocalDateTime en comptes d'Instant, i absència de @Version —especialment necessària en un lloguer, que es modifica com a mínim dues vegades—. Versió corregida:

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

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

    @Enumerated(EnumType.STRING)
    @Column(name = "estat", nullable = false, length = 20)
    private EstatLloguer estat;

    @Column(name = "import_total", precision = 8, scale = 2)   // mai double
    private BigDecimal importTotal;

    @Column(name = "inici", nullable = false, updatable = false)
    private Instant inici;                                     // mai LocalDateTime

    @Version @Column(name = "versio", nullable = false)
    private Long versio;

    protected Lloguer() { }   // requerit per JPA
    // equals/hashCode amb el patró de l'apartat 11
}

Solució 3. Tots dos operaris obren la fitxa d'«Estació Nord» i reben versio = 3.

L'operari A envia PUT /api/v1/estacions/2 amb capacitat 34. La seva transacció carrega l'entitat (versio = 3), la modifica i en fer commit Hibernate executa UPDATE estacions SET capacitat=34, versio=4 WHERE id=2 AND versio=3. La base de dades informa d'1 fila afectada, el commit prospera i A rep 200 OK.

L'operari B envia el seu PUT amb capacitat 28 uns segons després, però la seva còpia continua en versio = 3, així que Hibernate executa UPDATE estacions SET capacitat=28, versio=4 WHERE id=2 AND versio=3. Ara la fila té versio = 4, el WHERE no troba res: 0 files afectades. Hibernate compara el recompte esperat amb el real, llança StaleObjectStateException i la transacció fa rollback. Spring la tradueix a ObjectOptimisticLockingFailureException, i el @RestControllerAdvice de 03-06 la converteix en:

{
  "type": "https://api.ciclourbana.example/errors/conflicte-concurrencia",
  "title": "Conflicte de concurrència", "status": 409,
  "detail": "El recurs ha estat modificat per un altre usuari. Recarrega-ho i torna-ho a provar.",
  "codi": "CONFLICTE_CONCURRENCIA", "instance": "/api/v1/estacions/2"
}

Què hauria de fer el client: davant d'un 409, recarregar el recurs, mostrar a l'operari B la versió actual (capacitat 34, posada per A) i demanar-li que confirmi o refaci el seu canvi. El que mai no ha de fer és reintentar automàticament amb les mateixes dades: això sobreescriuria la feina d'A, que és exactament el problema que el mecanisme evita.

Fixa't que la protecció funciona sense capçaleres HTTP. Si el canvi de B arribés per un procés intern, una tasca programada o una consola d'administració, el conflicte es detectaria igual. Aquest és el salt respecte a l'ETag de 03-03.

Conclusió

El domini de CicloUrbana ja viu en taules. Saps per què un record no pot ser entitat —constructor sense arguments, camps mutables, classe no final— i, més important, per què conceptualment un record és un objecte de valor mentre que una entitat té identitat pròpia; per això els DTOs de 03-05 continuen sent record i el domini passa a ser classes mutables. Has mapejat taules amb @Entity i @Table, amb els seus índexs i restriccions d'unicitat anomenades. Has triat SEQUENCE amb allocationSize = 50 sabent exactament què s'hi guanya: inserció per lots, impossible amb IDENTITY. Controles la columna amb @Column i el seu length, nullable, precision/scale i updatable. Tens clar el mapatge de cada tipus, incloses les dues regles que més disgustos eviten: BigDecimal per als diners i Instant per a les marques de temps. I saps que @Enumerated(EnumType.STRING) no és una preferència estètica, sinó l'única manera que reordenar un enumerat no corrompi l'històric de Ribalta.

Has agrupat la latitud i la longitud en una Ubicacio incrustada, reutilitzable a Bicicleta i duplicable a Lloguer amb @AttributeOverride. Tens auditoria automàtica mitjançant @MappedSuperclass, @EntityListeners i @EnableJpaAuditing, sense repetir ni un sol camp. Has estrenat @Version, entenent que el bloqueig optimista protegeix tota escriptura i no només les que passen per HTTP, amb la qual cosa el ShallowEtagHeaderFilter de 03-03 queda jubilat i el 409 es genera des del gestor global de 03-06. I coneixes el patró correcte d'equals/hashCode per a entitats, amb el seu hashCode constant i la seva comparació per id no nul, que evita que les entitats es perdin dins d'un HashSet en desar-les.

Però les entitats estan aïllades. Una Bicicleta no sap a quina estació és; un Lloguer desa usuariId i bicicletaId com a números solts, sense cap garantia que apuntin a res real. El model relacional que vam dibuixar a 04-01 té fletxes, i encara no n'hem traçat cap. La lliçó 04-04, Relacions entre Entitats, les traça: les quatre cardinalitats amb la seva anotació, @ManyToOne i el costat propietari, @OneToMany amb mappedBy i els mètodes que sincronitzen tots dos costats, @OneToOne amb @MapsId, @ManyToMany i per què gairebé sempre convé substituir-lo per una entitat intermèdia. I amb elles arriben els dos problemes que defineixen la feina diària amb un ORM: la LazyInitializationException i el N+1.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

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

Mòdul 3: Construint serveis web RESTful

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

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

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

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats