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
- Per què un
recordno pot ser una entitat @Entityi@Table@Idi les quatre estratègies de@GeneratedValue@Columni el control de la columna- Tipus de dades i el seu mapatge
- Enumerats:
STRINGenfront d'ORDINAL - Camps derivats i
@Transient - Objectes incrustats:
@Embeddablei@Embedded - Auditoria automàtica
- Bloqueig optimista amb
@Version equalsihashCodeen entitats JPA- Entitats i DTOs: la separació es manté
- Errors Comuns i Consells
- Exercicis
- Per què un
record no pot ser una entitat
record no pot ser una entitatDes 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.
@Entity i @Table
@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
nameexplícitament. Per defecte Spring Boot aplicaCamelCaseToUnderscoresNamingStrategy(FitxaTecnica→fitxa_tecnica): funciona, però deixa el nom físic a mercè d'una estratègia configurable. indexesiuniqueConstraintsnomés actuen quan Hibernate genera l'esquema. Ambddl-auto: validatesó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
nomno substitueix la validació de negoci:EstacioServicecontinuarà comprovantexisteixPerNomper retornar un409ambProblemDetailllegible (03-06) en lloc de deixar escapar unaDataIntegrityViolationException. La restricció és l'última línia de defensa, la que cobreix les condicions de cursa.
@Id i les quatre estratègies de @GeneratedValue
@Id i les quatre estratègies de @GeneratedValueTota 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.
@Column i el control de la columna
@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:
lengthper defecte és 255. Deixar-ho així converteix cada text en unvarchar(255)sense criteri. Lamatriculade CicloUrbana ésRB-0142: set caràcters, ilength = 10és generós i descriptiu.nullable = falseés documentació executable, i va de la mà de@NotNull(03-04): la validació produeix un400llegible, la restricció de columna és la garantia última d'integritat.updatable = falseencaixa amb la matrícula: s'assigna en donar d'alta la bicicleta i no canvia mai, així que Hibernate l'exclou delsUPDATE.unique = truea@Columnenfront deuniqueConstraintsa@Table: la primera genera un nom de restricció aleatori (UK_a7f3b2...) que acabarà apareixent als missatges d'error; amb@Tablela nomenes i a més pots cobrir diverses columnes.
- 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.3Un 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.
- Enumerats:
STRING enfront d'ORDINAL
STRING enfront d'ORDINALEstatBicicleta ve del mòdul 1:
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:
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.
- Camps derivats i
@Transient
@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.
- Objectes incrustats:
@Embeddable i @Embedded
@Embeddable i @EmbeddedEstacio 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.
}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.
- 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:
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.
- Bloqueig optimista amb
@Version
@VersionA 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.
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.
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ó.
equals i hashCode en entitats JPA
equals i hashCode en entitats JPAAquest 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 trobaFer 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è:
hashCodeconstant. 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 idnull) mai no són iguals entre si, només amb si mateixes perthis == o. Són dues estacions diferents encara sense desar, i aquesta és la semàntica correcta.Hibernate.getClass()en lloc degetClass(): amb càrrega mandrosa l'objecte pot ser un proxy d'una subclasse generada, i comparargetClass()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.
- 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:
- Falta
@Table(name = "lloguers"). Sense ell, el nom físic depèn de l'estratègia de nomenclatura. Afegeix-lo, amb els seus índexs perusuari_idibicicleta_id. @EnumeratedsenseEnumType.STRING. Es desa l'ordinal. ReordenarEstatLloguercorrompria tot l'històric de lloguers de Ribalta.double importTotal. Error d'arrodoniment acumulat a les factures. Ha de serBigDecimalamb@Column(precision = 8, scale = 2).- 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. equals/hashCodesobre camps mutables. ElhashCodecanvia quan es calcula l'import en finalitzar el lloguer, i l'entitat es perd en qualsevolHashSet.
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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
