In JPA, una chiave primaria composta è un identificatore formato da più colonne. Non tutte le tabelle hanno un singolo campo sufficiente a garantire l'unicità di ogni riga; in questi casi si usano chiavi composte.
Le chiavi composte rappresentano uno degli aspetti più delicati della persistenza con JPA. A differenza delle chiavi primarie semplici, che possono essere gestite con un singolo campo @Id, le chiavi composte richiedono una progettazione attenta e una comprensione profonda di come JPA mappa gli identificatori sulle strutture del database.
Il concetto di chiave composta deriva direttamente dalla teoria dei database relazionali, dove l'unicità di una riga può dipendere dalla combinazione di più attributi. Questo approccio è particolarmente rilevante quando si lavora con modelli di dati normalizzati o con database legacy che seguono convenzioni di design diverse da quelle moderne basate su chiavi surrogate auto-generate.
Le chiavi composte sono comuni in diversi scenari, soprattutto quando un singolo campo non basta a garantire l’unicità:
-
Tabelle join tra entità: in molte relazioni many-to-many con attributi aggiuntivi, la chiave naturale è la combinazione delle due entità correlate. Ad esempio, una tabella che registra le iscrizioni agli esami potrebbe avere
student_id + exam_id + exam_dateper permettere allo stesso studente di sostenere lo stesso esame in date diverse. -
Tabelle legacy con chiavi multiple già definite: molti sistemi esistenti utilizzano chiavi composte come standard. Quando si integra JPA in applicazioni legacy, spesso non è possibile modificare la struttura del database, rendendo l’uso di chiavi composte inevitabile.
-
Sistemi che richiedono identificatori logici basati su più attributi: in alcuni domini, l’unicità deriva naturalmente dalla combinazione di più caratteristiche. Un esempio classico è una tabella di versioni di documenti, dove la chiave potrebbe essere
document_id + version_number. -
Viste o tabelle derivate: anche le viste database possono richiedere chiavi composte, soprattutto se aggregano dati da più tabelle o derivano colonne chiave da più fonti. In questi casi, la chiave composta serve a identificare in modo univoco ogni riga della vista senza dover aggiungere colonne surrogate.
Rappresentare chiavi composte in JPA
Quando si progetta un database relazionale con JPA e Hibernate, capita spesso di dover gestire chiavi primarie composte: chiavi formate da più colonne invece di una sola. La scelta tra @IdClass e @EmbeddedId non è banale, perché incide sia sulla leggibilità del codice, sia sul modello concettuale dell’entità.
In sostanza, entrambe le strategie permettono di dire a JPA che una tabella ha più colonne che insieme identificano univocamente una riga, ma lo fanno in modi diversi, con vantaggi e svantaggi legati alla visibilità dei campi, alla gestione delle relazioni e alla semantica della chiave.
1. @IdClass: chiavi “visibili” direttamente nell’entità
Con @IdClass, ogni campo della chiave primaria è dichiarato come proprietà separata nell’entità. La classe esterna (IdClass) serve solo come supporto per la serializzazione e per implementare equals e hashCode. Questo approccio mantiene la chiave “trasparente”, nel senso che i singoli campi sono accessibili direttamente tramite getter e setter standard.
@Entity
@IdClass(SearchContentId.class)
public class SearchContent {
@Id
private Long id;
@Id
private String type;
private String title;
}
Classe ID:
public class SearchContentId implements Serializable {
private Long id;
private String type;
public SearchContentId() {}
public SearchContentId(Long id, String type) {
this.id = id;
this.type = type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SearchContentId that = (SearchContentId) o;
return Objects.equals(id, that.id) && Objects.equals(type, that.type);
}
@Override
public int hashCode() {
return Objects.hash(id, type);
}
}
Quando conviene usarlo:
-
La chiave è artificiale, ad esempio un ID numerico + un codice di tipo, senza significato di dominio.
-
Vuoi poter lavorare direttamente sui singoli campi senza passare da un oggetto intermedio.
Vantaggi:
-
Codice semplice e diretto, i campi sono normali proprietà dell’entità.
-
Query JPQL intuitive:
WHERE e.id = :id AND e.type = :type. -
Buono quando il numero di campi della chiave non è elevato.
Svantaggi:
-
Aumenta il numero di proprietà nell’entità.
-
Relazioni con altre entità più complesse da gestire, perché le foreign key devono replicare tutti i campi.
-
Può diventare ripetitivo se la chiave è composta da molti campi.
-
Hibernate richiede che i nomi, tipi e numero di campi combacino esattamente tra entità e IdClass, altrimenti fallisce all’avvio.
In sostanza, @IdClass è “meccanico”, ottimo per chiavi composte tecniche, senza significato concettuale nel modello.
2. @EmbeddedId: chiavi come oggetto unico
Con @EmbeddedId, la chiave primaria è rappresentata come un oggetto incorporato nell’entità, che contiene tutti i campi della chiave. Questo approccio incapsula la complessità e rende il modello più ordinato, perché la chiave diventa un concetto coerente, un singolo oggetto con significato di dominio.
@Entity
public class SearchContent {
@EmbeddedId
private SearchContentId id;
private String title;
public SearchContentId getId() { return id; }
public void setId(SearchContentId id) { this.id = id; }
}
Classe ID:
@Embeddable
public class SearchContentId implements Serializable {
private Long id;
private String type;
public SearchContentId() {}
public SearchContentId(Long id, String type) {
this.id = id;
this.type = type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SearchContentId that = (SearchContentId) o;
return Objects.equals(id, that.id) && Objects.equals(type, that.type);
}
@Override
public int hashCode() {
return Objects.hash(id, type);
}
}
Quando conviene usarlo:
-
La chiave ha significato logico o naturale nel dominio, ad esempio un indirizzo, una data + tipo di evento, o un codice composto significativo.
-
Vuoi incapsulare tutto in un oggetto unico, favorendo il principio di incapsulamento e il riuso del concetto.
Vantaggi:
-
Modello più coerente e leggibile.
-
Relazioni più facili: si passa l’intero oggetto chiave invece dei singoli campi.
-
La chiave diventa un concetto di dominio riutilizzabile.
-
Favorisce codice orientato agli oggetti, ordinato e manutenibile.
Svantaggi:
-
Accesso ai singoli campi più verboso:
entity.getId().getId(). -
Query JPQL leggermente più complesse:
WHERE e.id.id = :id AND e.id.type = :type. -
Necessita di una classe annotata
@Embeddable.
In pratica, @EmbeddedId è più “semantico”: usalo quando la chiave ha senso come entità concettuale e vuoi modellare l’identità in modo ordinato.
Riepilogo confronto
Se ci pensi, la differenza principale è tra visibilità e coesione.
-
@IdClass espone i campi singolarmente, il che è comodo per query semplici e chiavi tecniche, ma può rendere il modello più dispersivo se la chiave cresce.
-
@EmbeddedId li incapsula in un singolo oggetto, il che rende il codice più elegante e concettualmente corretto, soprattutto se la chiave rappresenta qualcosa di significativo nel dominio.
Un modo per pensarci: se la chiave è solo un “etichetta tecnica” → IdClass. Se la chiave rappresenta un concetto unico con significato nel business → EmbeddedId.
| Caratteristica | @IdClass | @EmbeddedId |
|---|---|---|
| Accesso ai campi | Diretto (entity.id) | Verboso (entity.id.id) |
| Significato dominio | Tecnico, artificiale | Semantico, concettuale |
| Complessità entità | Più proprietà, più ripetitivo | Singolo oggetto, più ordinato |
| Query JPQL | Semplici | Leggermente più complesse |
| Relazioni | Più complicate | Più semplici da gestire |
| Riuso chiave | Limitato | Elevato, concetto riutilizzabile |
| Principio OOP | Debole | Forte (incapsulamento) |
Gestire le relazioni con chiavi composte
Quando un'entità con chiave composta è coinvolta in relazioni, la configurazione richiede attenzione particolare. JPA deve sapere esattamente come mappare tutti i componenti della chiave nelle foreign key.
Con @IdClass:
@Entity
public class Enrollment {
@ManyToOne
@JoinColumns({
@JoinColumn(name = "content_id", referencedColumnName = "id"),
@JoinColumn(name = "content_type", referencedColumnName = "type")
})
private SearchContent content;
}
Con @EmbeddedId:
@Entity
public class Enrollment {
@ManyToOne
@JoinColumns({
@JoinColumn(name = "content_id", referencedColumnName = "id"),
@JoinColumn(name = "content_type", referencedColumnName = "type")
})
private SearchContent content;
}
In entrambi i casi, @JoinColumns deve specificare tutte le colonne che compongono la foreign key, mappandole ai rispettivi componenti della chiave primaria.
Best practice
Implementare sempre equals() e hashCode() nella classe della chiave. Questi metodi sono fondamentali per il corretto funzionamento delle collezioni Java e per il confronto di identità degli oggetti da parte di Hibernate. Senza una corretta implementazione, si verificano bug sottili come entità duplicate nelle collezioni o problemi nel caricamento lazy.
Scegliere uno solo tra @IdClass e @EmbeddedId per ogni entità. Mescolare i due approcci genera ambiguità che Hibernate non può risolvere, causando errori all'avvio dell'applicazione.
Quando l'entità con chiave composta è coinvolta in relazioni, usare @JoinColumns con tutti i campi della chiave. Dimenticare anche un solo campo della chiave nella definizione del join causa errori di mapping o query SQL malformate.
Evitare duplicazioni di colonne nella stessa entità (insertable=false, updatable=false se necessario). Se una colonna appare sia nella chiave che come campo separato, bisogna esplicitamente disabilitare le operazioni di scrittura su una delle due mappature per evitare conflitti.
Preferire chiavi surrogate quando possibile. Le chiavi composte aggiungono complessità; se il dominio lo permette, una singola chiave auto-generata semplifica notevolmente il codice. Le chiavi composte dovrebbero essere usate quando rappresentano veramente un concetto di business o quando si lavora con database legacy.
Documentare la scelta tra @IdClass e @EmbeddedId. La decisione ha impatto su tutto il codice che interagisce con l'entità, quindi è importante che il team comprenda la rationale dietro la scelta.
Perché usare chiavi composte
Garantire l'unicità quando un singolo campo non basta: in molti scenari reali, l'unicità di un record deriva dalla combinazione di più attributi. Forzare una chiave surrogata quando esiste già una chiave naturale composta può portare a duplicazioni logiche nel database.
Modellare correttamente tabelle di mapping o legacy: quando si integra JPA con sistemi esistenti, rispettare la struttura originale del database evita migrazioni costose e rischiose. Le chiavi composte permettono di lavorare con lo schema esistente senza modifiche.
Creare identificatori logici derivati da più attributi: alcune entità hanno naturalmente identità composite. Ad esempio, una misurazione scientifica potrebbe essere identificata univocamente dalla combinazione di sensor_id, timestamp e measurement_type.
Le chiavi composte non sono solo un requisito tecnico: rappresentano anche la logica di dominio, aiutando a modellare dati complessi in maniera coerente. Quando ben progettate, riflettono le regole di business e garantiscono l'integrità referenziale a livello di database, aggiungendo un ulteriore livello di protezione contro inconsistenze nei dati.
La scelta di usare chiavi composte dovrebbe sempre essere guidata dal dominio e dalla struttura esistente del database, non da preferenze puramente tecniche. Comprendere quando e come usarle è segno di maturità nella progettazione di applicazioni enterprise: daje.