Chiavi composte in JPA: guida completa

LT
Luca Terribili
Autore

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_date per 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.

Ecco un esempio completo e minimale. Il dominio è una tabella di iscrizioni, dove la chiave primaria è la coppia studente-corso e il resto è un dato attributo:

@Entity
@Table(name = "enrollment")
@IdClass(EnrollmentId.class)
public class Enrollment {

    @Id
    @ManyToOne
    @JoinColumn(name = "student_id")
    private Student student;

    @Id
    @ManyToOne
    @JoinColumn(name = "course_id")
    private Course course;

    private BigDecimal finalGrade;

    protected Enrollment() {
    }

    public Enrollment(Student student, Course course) {
        this.student = student;
        this.course = course;
    }
}

La classe della chiave è un POJO che deve implementare Serializable e equals/hashCode su tutti i campi della chiave:

public class EnrollmentId implements Serializable {

    private Long studentId;
    private Long courseId;

    public EnrollmentId() {
    }

    public EnrollmentId(Long studentId, Long courseId) {
        this.studentId = studentId;
        this.courseId = courseId;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof EnrollmentId other)) {
            return false;
        }
        return Objects.equals(studentId, other.studentId)
                && Objects.equals(courseId, other.courseId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}

Quando conviene usarlo:

  • Vuoi lavorare direttamente sui singoli campi senza passare da un oggetto intermedio.
  • La chiave è composta da due o tre campi semplici e non ha una vita propria nel dominio.
  • Ti interessa scrivere query JPQL leggibili: WHERE e.student.id = :studentId AND e.course.id = :courseId.

Vantaggi:

  • Codice semplice e diretto, i campi sono normali proprietà dell’entità.
  • Query JPQL intuitive, perché i campi sono esposti direttamente sull’entità.
  • Non serve alcuna classe @Embeddable.

Svantaggi:

  • La classe IdClass non è un’entità: non ha relazioni, non ha ID, non viene gestita da Hibernate. Può solo contenere i campi della chiave.
  • Se la stessa chiave serve in più entità, va duplicata.
  • Le query JPQL su tabelle con molteplici entità che condividono la chiave richiedono alias espliciti per non creare ambiguità.

Una precisazione che spesso si legge male nei manuali: @IdClass non è “per chiavi artificiali” e @EmbeddedId non è “per chiavi di dominio”. Entrambe le strategie sono valide in qualunque scenario e la scelta non dipende dal significato della chiave. @EmbeddedId con una chiave puramente tecnica e @IdClass con una chiave di dominio sono entrambe scelte legittime e si trovano nella stessa codebase.

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. L’entità ha una sola proprietà che rappresenta l’identità. Ecco un esempio completo, su un dominio diverso dal precedente per mostrare che i due approcci non sono alternative obbligatorie per lo stesso caso:

@Entity
@Table(name = "search_result")
public class SearchResult {

    @EmbeddedId
    private SearchResultId id;

    private String title;

    private String snippet;

    protected SearchResult() {
    }

    public SearchResult(SearchResultId id, String title, String snippet) {
        this.id = id;
        this.title = title;
        this.snippet = snippet;
    }
}

La classe della chiave è annotata @Embeddable e i nomi delle colonne si dichiarano lì, così da definire il mapping una volta sola:

@Embeddable
public class SearchResultId implements Serializable {

    @Column(name = "entity_id")
    private Long entityId;

    @Column(name = "entity_type", length = 40)
    private String entityType;

    public SearchResultId() {
    }

    public SearchResultId(Long entityId, String entityType) {
        this.entityId = entityId;
        this.entityType = entityType;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof SearchResultId other)) {
            return false;
        }
        return Objects.equals(entityId, other.entityId)
                && Objects.equals(entityType, other.entityType);
    }

    @Override
    public int hashCode() {
        return Objects.hash(entityId, entityType);
    }
}

Quando conviene usarlo:

  • La chiave viene riutilizzata da più entità, quindi ha senso averla come tipo próprio.
  • Vuoi incapsulare l’identità in un oggetto coerente con il dominio.
  • Ti accetti un accesso leggermente più verboso ai singoli campi.

Vantaggi:

  • La chiave diventa un tipo riutilizzabile, non un oggetto anonimo.
  • I mapping delle colonne si definiscono una volta sola nella classe @Embeddable.
  • Le relazioni si mappano in modo più pulito, perché si lavora sull’oggetto chiave intero.

Svantaggi:

  • Accesso ai singoli campi più verboso: entity.getId().getEntityId().
  • Query JPQL con un livello di navigazione in più: WHERE e.id.entityId = :entityId.
  • Introdotta una classe in più per ogni chiave.

Riepilogo confronto

La differenza principale è come la chiave viene vista dall’esterno: @IdClass la dissolve nell’entità, @EmbeddedId la tiene come oggetto.

  • @IdClass espone i campi singolarmente, il che è comodo per query semplici, ma la chiave non è un tipo riutilizzabile.
  • @EmbeddedId la incapsula in un oggetto, il che costa un livello di navigazione in più ma rende la chiave un concetto riutilizzabile.

Un modo per pensarci: se consumerai quasi sempre la chiave per intero, o se compare in più entità, @EmbeddedId. Se consumerai quasi sempre i singoli campi, @IdClass.

Caratteristica @IdClass @EmbeddedId
Accesso ai campi Diretto (entity.student) Verboso (entity.id.studentId)
Query JPQL Semplici Un livello di navigazione in più
Chiave riutilizzabile No, va duplicata Sì, è un tipo proprio
Complessità nell'entità Più proprietà Una sola proprietà di identità
Classi da scrivere La classe ID La classe ID, più un @Embeddable

La scelta fra i due non è dettata dal significato della chiave, ma dall’uso che ne fai nel codice.

Gestire le relazioni con chiavi composte

Quando un'entità con chiave composta è coinvolta in relazioni, la configurazione richiede attenzione particolare. Il punto da distinguere è se la chiave primaria dell'entità proprietaria coincide con quella dell'entità referenziata, perché i due casi richiedono annotazioni diverse.

Caso 1: la chiave dell'entità proprietaria è un'altra

È il caso più comune. Nell’esempio della sezione precedente Enrollment ha come chiave (student_id, course_id) e ognuno dei due campi è a sua volta una foreign key. La relazione si mappa con un @JoinColumn per ciascun campo, dichiarando la colonna locale: non serve referencedColumnName, perché ogni entità ha una sola colonna di chiave.

@Id
@ManyToOne
@JoinColumn(name = "student_id")
private Student student;

@Id
@ManyToOne
@JoinColumn(name = "course_id")
private Course course;

Sul lato inverso basta il mappedBy, che punta al nome della proprietà nell’altra entità:

@Entity
public class Course {

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

    private String title;

    @OneToMany(mappedBy = "course")
    private List<Enrollment> enrollments = new ArrayList<>();
}

Caso 2: la chiave primaria è un oggetto incorporato

Se l’entità proprietaria usa @EmbeddedId e la relazione è con l’entità la cui chiave è la stessa, il modo idiomatico è @MapsId: Hibernate deriva la chiave esterna dalla relazione, quindi non serve riscriverla a mano.

@Entity
@Table(name = "search_bookmark")
public class SearchBookmark {

    @EmbeddedId
    private SearchResultId id;

    @MapsId("id")
    private SearchResult result;

    private Instant createdAt;

    protected SearchBookmark() {
    }

    public SearchBookmark(SearchResult result, Instant createdAt) {
        this.result = result;
        this.id = result.getId();
        this.createdAt = createdAt;
    }
}

Caso 3: serve @JoinColumns

@JoinColumns con più @JoinColumn diventa necessario solo in un caso specifico: quando l’entità proprietaria ha una chiave primaria diversa dalla chiave dell’entità referenziata, ma deve comunque appoggiarsi a essa tramite una foreign key composta. Per esempio, se Enrollment avesse un id surrogato proprio e volesse comunque mantenere il riferimento a SearchResult:

@Entity
public class Enrollment {

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

    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "content_id", referencedColumnName = "entity_id"),
            @JoinColumn(name = "content_type", referencedColumnName = "entity_type")
        })
    private SearchResult content;
}

In questo caso @JoinColumns deve elencare tutte le colonne che compongono la chiave referenziata, nello stesso ordine in cui sono definite nella classe @Embeddable. Dimenticare una sola colonna non produce un errore evidente: produce SQL malformate o valori null a runtime.

Vale la pena notare che, nei primi due casi, il codice non usa @JoinColumns affatto. È una precisazione che sparisce spesso, perché esempi copiati da contesti diversi vengono mescolati insieme.

Repository e test

Per poter verificare il mapping serve un repository con la chiave composta come tipo del parametro di identificatore:

public interface EnrollmentRepository extends JpaRepository<Enrollment, EnrollmentId> {

    List<Enrollment> findByStudentId(Long studentId);
}

Il test verifica che la chiave composta venga gestita correttamente in scrittura e in lettura, e soprattutto che equals/hashCode sulla classe ID funzionino:

@DataJpaTest
class EnrollmentRepositoryTest {

    @Autowired
    private TestEntityManager em;
    @Autowired
    private EnrollmentRepository repository;

    @Test
    void salvaERileggaTramiteChiaveComposta() {
        Student student = em.persist(new Student("Mario Rossi"));
        Course course = em.persist(new Course("Basi di dati"));

        em.persist(new Enrollment(student, course));
        em.flush();
        em.clear();

        EnrollmentId id = new EnrollmentId(student.getId(), course.getId());

        Optional<Enrollment> found = repository.findById(id);

        assertThat(found).isPresent();
        assertThat(found.get().getStudent().getName()).isEqualTo("Mario Rossi");
    }

    @Test
    void chiaviDiverseRestituisconoEntitaDistinte() {
        EnrollmentId a = new EnrollmentId(1L, 2L);
        EnrollmentId b = new EnrollmentId(2L, 1L);

        assertThat(a).isNotEqualTo(b);
        assertThat(a.hashCode()).isNotEqualTo(b.hashCode());
    }
}

Il secondo test non è banale: senza un equals/hashCode corretto sulla classe ID, i due oggetti sarebbero indistinguibili e una HashSet o una HashMap potrebbe trattarli come la stessa chiave. È il motivo per cui quei due metodi non sono opzionali.

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.

Non usare @JoinColumns se non serve. È necessario solo quando la chiave primaria dell'entità proprietaria è diversa dalla chiave referenziata. Quando le due coincidono, @JoinColumn o @MapsId sono più semplici e meno soggetti a errori.

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 la logica di dominio. Quando ben progettate riflettono le regole di business e garantiscono l'integrità referenziale a livello di database. La scelta di usarle dovrebbe essere guidata dal dominio e dalla struttura esistente, non da preferenze puramente tecniche: daje.