JavaRush /Курси /Hibernate deep-dive /Складені ключі в JPA

Складені ключі в JPA

Hibernate deep-dive
Рівень 15 , Лекція 3
Відкрита

1. Складений ключ: сенс і причини

Ми вже розмежували технічний PK і business-id, але цього все ще замало: іноді сам запис визначається не одним полем, а комбінацією значень. Якщо ви звикли, що в кожній таблиці є «одна колонка id — і крапка», складений ключ спочатку здається дивною примхою людей, які люблять усе ускладнювати. Але в реальних даних бувають сутності, для яких немає сенсу вигадувати окремий surrogate id, тому що рядок і так унікальний за комбінацією природних атрибутів. Це як адреса: один «дім» ідентифікується не одним числом, а набором «місто + вулиця + будинок».

Складений ключ (composite key) означає, що первинний ключ запису — це кілька полів одночасно. Наприклад, для знімка залишків на складі логічно сказати: «знімок ідентифікується товаром і датою знімка». Якщо у вас є product_id=10 і snapshot_date=2026-03-18, то другого такого рядка бути не повинно — інакше це вже не «знімок», а якась паралельна реальність.

Під час виконання в Hibernate/JPA цей вибір напряму впливає на поведінку системи. Persistence context зберігає identity map (ми це вже обговорювали), і ключем там є поєднання «тип entity + значення id». Якщо id складений, то й «ключ» у identity map стає складеним об’єктом, який має поводитися передбачувано: коректні equals()/hashCode(), стабільність значень, відсутність полів, що змінюються.

Щоб краще це уявити, зручно подивитися на таку схему:

flowchart TD
    A["Об’єкт сутності (InventorySnapshot)"] --> B["Значення @Id"]
    B --> C["Ключ identity map у Persistence Context"]
    C --> D["Hibernate розуміє: це той самий запис?"]

Якщо @Id — це Long, запитань небагато. Якщо @Id — це об’єкт із двох полів, запитань стає більше, і на них треба відповісти в коді.

2. Приклад: InventorySnapshot — товар і дата

Щоб складений ключ не залишався абстрактною теорією, прив’яжемо його до нашої лабораторії Commerce Persistence Lab. У проєкті є сутність InventorySnapshot із модуля advanced-lab: вона зберігає історію залишків для товару. Ключова ідея тут така: для одного товару на одну дату має існувати максимум один знімок — інакше ми не зрозуміємо, який із них «правильний».

Тобто ідентичність запису можна виразити так:

  • productId — який товар
  • snapshotDate — на яку дату знімок

І це справді виглядає як чесний primary key. Ми не вигадуємо штучний snapshot_id, який сам по собі нічого не означає. Ми кажемо: «знімок — це (товар, дата)». Саме так бізнес мислить про цей об’єкт.

Не плутайте складений ключ із @NaturalId. Natural id — це унікальне бізнес-поле або набір полів для пошуку, але воно не зобов’язане бути первинним ключем. Складений ключ — це вже рівень схеми та ідентичності запису. У практичному коді це відчувається одразу: findById(...) більше не приймає одне число. Він очікує цілий об’єкт-ключ.

3. Composite key у JPA: @EmbeddedId і @IdClass

JPA не дозволяє просто написати: «у мене два @Id, і все само запрацює». Формально вона дозволяє кілька @Id, але обов’язково просить виділити клас ідентифікатора, який описує складений ключ. Для цього є два основні варіанти: @EmbeddedId і @IdClass. Обидва розв’язують одне завдання, але роблять це по-різному, а отже, по-різному впливають на читабельність, API та мапінг.

Нижче — компактна таблиця, щоб ви одразу бачили, що саме порівнюємо. Не намагайтеся завчити її напам’ять: вона потрібна для того, щоб мозок побачив реальні відмінності, а не «дві однакові анотації».

Критерій @EmbeddedId @IdClass
Де лежить id у сутності В одному полі-об’єкті id Поля id лежать прямо в сутності
Як виглядає findById findById(new Key(...)) Те саме, але сутність «плоска»
На що схоже за стилем Об’єкт-значення (ми вже працювали з @Embeddable) «Плоска сутність + окремий клас для ключа»
Де зазвичай простіше зробити акуратно Коли ключ логічно сприймається як єдине значення Коли зручно тримати поля ключа прямо в сутності
Де частіше ловлять дивні баги Якщо забувають equals()/hashCode() у класі id Якщо плутають відповідність полів між сутністю та key-класом

Далі ми пройдемо обидва варіанти на прикладі InventorySnapshot, щоб ви бачили різницю не за розповіддю, а за кодом.

4. Правила складеного id-класу

Перш ніж писати анотації, важливо домовитися про правила гри. Composite key — це випадок, коли маленький клас раптово стає основою identity map у Hibernate. Тому вимоги до id-класу — це не «формальність для галочки», а умова коректної роботи.

У JPA клас ідентифікатора має бути публічним, мати конструктор без аргументів (Hibernate/JPA створюватимуть його через рефлексію), реалізовувати Serializable і мати коректні equals()/hashCode(). У нашому курсі це звучить знайомо: у темі про equals/hashCode ми вже обговорювали, що «рівність» — це не декоративна тема, а джерело реальних багів у колекціях і в ORM.

Ще одна важлива думка: поля складеного ключа мають бути стабільними. Якщо ви включили в ключ поле, яке може змінюватися, ви майже гарантовано потрапите в ситуацію «зміна ключа = зміна ідентичності запису». Це як намагатися змінити номер паспорта людині й очікувати, що вона залишиться тією ж людиною. Формально — ні, а на практиці ще й проблемно.

5. Варіант 1: @EmbeddedId

@EmbeddedId — це найбільш «value-object» спосіб описати складений ключ. Він логічно продовжує рівень 14 про @Embeddable: ми вже вміємо вбудовувати значення, тож тепер вбудовуємо й ідентифікатор. У результаті сутність отримує одне поле id, усередині якого лежать частини ключа. Плюс такого підходу в тому, що ключ можна сприймати як єдиний об’єкт: передати в метод, покласти в змінну, вивести в лог.

На практиці це часто читається природніше, тому що ви явно бачите: InventorySnapshot ідентифікується не «двома випадковими колонками», а об’єктом InventorySnapshotId. Але й відповідальність тут вища: цей об’єкт має бути бездоганним з погляду equality, бо Hibernate спиратиметься саме на нього.

InventorySnapshotId як @Embeddable

Почнімо з id-класу. У нашій предметній області він складається з productId і snapshotDate. Зверніть увагу на Serializable, no-arg constructor і equals/hashCode. Так, це нудно. Так, це потрібно. Це як ремінь безпеки: не для краси.

import jakarta.persistence.Embeddable;

import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;

@Embeddable
public class InventorySnapshotId implements Serializable {
    // Частина складеного ключа: ідентифікатор товару
    private Long productId;

    // Частина складеного ключа: дата, на яку зафіксовано знімок
    private LocalDate snapshotDate;

    // Потрібен JPA/Hibernate, щоб створювати об’єкт через рефлексію
    protected InventorySnapshotId() { }

    // Зручний конструктор для прикладного коду (сервісів/тестів)
    public InventorySnapshotId(Long productId, LocalDate snapshotDate) {
        this.productId = productId;
        this.snapshotDate = snapshotDate;
    }
}

Тепер додамо equals()/hashCode(). Я винесу їх окремим маленьким блоком, щоб не перетворювати приклад на простирадло.

@Override
public boolean equals(Object o) {
    if (this == o) return true;

    // Порівнюємо саме за типом і значеннями полів ключа
    if (!(o instanceof InventorySnapshotId that)) return false;

    // Рівність ключів = рівність обох частин (товар + дата)
    return Objects.equals(productId, that.productId)
            && Objects.equals(snapshotDate, that.snapshotDate);
}

@Override
public int hashCode() {
    // hashCode має залежати від тих самих полів, що й equals()
    return Objects.hash(productId, snapshotDate);
}

Тут ми свідомо порівнюємо обидва поля. І саме так і має бути: два ключі рівні, якщо збіглися товар і дата.

Сутність InventorySnapshot з @EmbeddedId

Тепер сама сутність. З @EmbeddedId усе виглядає дуже прямо: є поле id, є поле quantity. Для навчального прикладу цього достатньо, і ми поки не ускладнюємо зв’язок із Product.

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "inventory_snapshot")
public class InventorySnapshot {

    // Уся ідентичність запису живе тут, як єдиний об’єкт-значення
    @EmbeddedId
    private InventorySnapshotId id;

    // Звичайний атрибут, не частина ідентичності
    private Integer quantity;

    // Потрібен JPA/Hibernate
    protected InventorySnapshot() { }
}

І додамо мініконструктор, щоб зручно створювати знімки в сервісі. Тримаємо його коротким.

public InventorySnapshot(InventorySnapshotId id, Integer quantity) {
    this.id = id;
    this.quantity = quantity;
}

Зверніть увагу на важливу річ: зі складеним ключем ви майже завжди зобов’язані мати id повністю сформованим до persist(). Немає «потім згенеруємо». Це не SEQUENCE, не UUID і не IDENTITY. Це assigned id, тільки складений.

Репозиторій Spring Data

У репозиторії змінюється буквально одна річ: другий generic-параметр у JpaRepository тепер — не Long, а InventorySnapshotId.

import org.springframework.data.jpa.repository.JpaRepository;

// Важливо: тип id — це окремий клас (composite key), а не Long
public interface InventorySnapshotRepository
        extends JpaRepository<InventorySnapshot, InventorySnapshotId> {
}

І це вже впливає на весь код, який викликає репозиторій: findById тепер приймає об’єкт ключа.

Використання: зберегти і прочитати snapshot

Покажемо мінісценарій на прикладі сервісного методу. Я навмисно зроблю його максимально «на пальцях»: сформували id, створили сутність, зберегли, прочитали за id.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

@Service
public class InventorySnapshotService {
    private final InventorySnapshotRepository repository;

    public InventorySnapshotService(InventorySnapshotRepository repository) {
        // Впроваджуємо репозиторій, який працює зі складеним ключем
        this.repository = repository;
    }

    @Transactional
    public void saveSnapshot(long productId, LocalDate date, int qty) {
        // Складений ключ має бути повністю сформований до save/persist
        var id = new InventorySnapshotId(productId, date);

        // Зберігаємо знімок: identity = (productId, date)
        repository.save(new InventorySnapshot(id, qty));
    }
}

Тут знову спливає наша модель із початку курсу: транзакція задає unit of work, persistence context знає, що таке керована сутність, а id — це те, що пов’язує об’єкт і запис. За складеного ключа ви просто зобов’язані ставитися до id як до повноцінного об’єкта моделі, а не як до «числа десь там».

6. Варіант 2: @IdClass

@IdClass — це інший стиль тієї самої моделі. Для InventorySnapshot у проєкті він лишається лише альтернативою для порівняння, а не новим базовим варіантом: робочим варіантом ми вважаємо @EmbeddedId. Але @IdClass усе одно важливо вміти читати, бо він регулярно трапляється в legacy і в чужому коді. Його часто обирають ті, хто хоче бачити поля ключа прямо в сутності, без обгортки id. З погляду читання коду це іноді справді зручніше: відкрили сутність і одразу бачите productId та snapshotDate. Але JPA все одно просить окремий key-клас, бо десь має існувати «тип id» для findById().

Важливо пам’ятати: за @IdClass поля id у key-класі мають збігатися за назвами й типами з полями @Id в сутності. І саме тут починаються «тихі» помилки: описка в назві, різний тип (Long vs long), і ви ловите дивності вже на старті застосунку.

Key-class для @IdClass

Спочатку робимо окремий клас ключа. Він схожий на InventorySnapshotId, але без @Embeddable (тут він не потрібен). Вимоги ті самі: Serializable, no-arg constructor, equals/hashCode.

import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;

public class InventorySnapshotKey implements Serializable {
    // Має збігатися за назвою і типом з полем @Id у сутності
    private Long productId;

    // Має збігатися за назвою і типом з полем @Id у сутності
    private LocalDate snapshotDate;

    // Потрібен JPA/Hibernate
    public InventorySnapshotKey() { }

    // Зручний конструктор для прикладного коду
    public InventorySnapshotKey(Long productId, LocalDate snapshotDate) {
        this.productId = productId;
        this.snapshotDate = snapshotDate;
    }
}

І знову додаємо equality. Не тому, що «так прийнято», а тому, що це впливає на ідентичність.

@Override
public boolean equals(Object o) {
    if (this == o) return true;

    // Ключі порівнюємо за значеннями: це впливає на identity map у persistence context
    if (!(o instanceof InventorySnapshotKey that)) return false;

    return Objects.equals(productId, that.productId)
            && Objects.equals(snapshotDate, that.snapshotDate);
}

@Override
public int hashCode() {
    // Ті самі поля, що й у equals(), інакше будуть «привиди» в HashMap/Set
    return Objects.hash(productId, snapshotDate);
}

Сутність з @IdClass

Тепер сутність. Тут ключові поля лежать прямо на ній і позначаються @Id. А зверху висить @IdClass(InventorySnapshotKey.class).

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;

import java.time.LocalDate;

@Entity
@Table(name = "inventory_snapshot")
@IdClass(InventorySnapshotKey.class) // Кажемо JPA: «мій id описує цей клас»
public class InventorySnapshotFlat {

    // Частина ідентичності (PK)
    @Id
    private Long productId;

    // Частина ідентичності (PK)
    @Id
    private LocalDate snapshotDate;

    // Звичайний атрибут, не частина PK
    private Integer quantity;
}

Плюс @IdClass у тому, що вам не потрібно заходити через snapshot.getId().getProductId() — достатньо snapshot.getProductId(). Мінус у тому, що ключ ніби «розмазаний» по сутності, і ви постійно маєте пам’ятати: ці два поля особливі, це не просто атрибути, а ідентичність.

Репозиторій

Репозиторій виглядає майже так само, як у @EmbeddedId, тільки key-клас інший і сутність інша.

import org.springframework.data.jpa.repository.JpaRepository;

public interface InventorySnapshotFlatRepository
        extends JpaRepository<InventorySnapshotFlat, InventorySnapshotKey> {
}

Так, findById усе одно хоче InventorySnapshotKey. Тобто з погляду API споживача ви не виграєте «одне поле замість об’єкта». Ви виграєте читабельність самої сутності.

7. Вибір: @EmbeddedId чи @IdClass

Коли ви вперше бачите два способи розв’язати одне й те саме завдання, хочеться спитати: «а який правильний?». Для JPA загалом універсального переможця немає, але для нашого проєкту вибір можна зафіксувати жорсткіше: InventorySnapshot лишаємо на @EmbeddedId. У цієї сутності ключ справді живе як одне значення (productId, snapshotDate), і такий варіант краще читається в сервісах, тестах і findById(). @IdClass залишається поруч як нормальний JPA-інструмент, який треба вміти читати й оцінювати, але не як другий паралельний знімок тієї самої сутності.

Якщо складений ключ сприймається як єдиний смисловий об’єкт, @EmbeddedId зазвичай читається простіше. У InventorySnapshot це схоже на правду: «ідентифікатор знімка» — це окрема річ (товар, дата). Плюс @EmbeddedId добре поєднується з тим, що ми вже обговорювали: об’єкти-значення та @Embeddable. Ви отримуєте єдиний об’єкт, який можна передати в findById().

Якщо ж ви хочете максимально «плоску» сутність і надаєте перевагу тому, щоб бачити частини id прямо на ній, @IdClass може бути зручнішим. Але за цей комфорт ви платите дисципліною: назви й типи мають збігатися, і будь-яка дрібна помилка перетворюється на неприємний баг під час запуску.

Є ще один практичний чинник: як ви мапитимете зв’язок на Product. Для @EmbeddedId типовий шлях — @MapsId, і він доволі природний. Для @IdClass зв’язок теж можливий, але часто призводить до незграбнішого мапінгу (наприклад, дублювання колонок або insertable=false, updatable=false). У межах цієї лекції ми не підемо в складніші варіанти, але важливо знати: вибір анотації впливає не лише на красу, а й на можливості мапінгу.

8. Flyway-міграція для складеного ключа

Дуже легко написати анотації й забути, що зрештою все впирається в таблицю. Але у нас за правилами проєкту схема живе тільки через Flyway, і це добра дисципліна: ви завжди бачите, що реально зберігається в базі. Для складеного ключа це особливо корисно, бо первинний ключ у БД теж стає складеним.

Приклад мінімальної міграції (скорочений), щоб у нас з’явилася таблиця inventory_snapshot. Тут первинний ключ — (product_id, snapshot_date):

create table inventory_snapshot (
  product_id    bigint not null,  -- частина складеного PK: товар
  snapshot_date date   not null,  -- частина складеного PK: дата знімка
  quantity      integer not null, -- атрибут знімка
  primary key (product_id, snapshot_date) -- складений первинний ключ
);

Якщо ви додаєте зв’язок на Product, то в реальному проєкті додали б і зовнішній ключ на таблицю товарів. Але зараз нам важливо зафіксувати тільки принцип: складений ключ — це не лише «Java-об’єкт», а ще й реальна складена конструкція на боці БД.

9. Типові помилки під час роботи з @EmbeddedId і @IdClass

Помилка № 1: забули equals() / hashCode() у класі id.
Це одна з найдорожчих помилок, тому що вона може проявлятися дивно: десь findById не знаходить запис, десь persistence context починає поводитися непередбачувано, десь тести стають нестабільними без видимої причини. Composite id бере участь в ідентичності, тому коректна рівність — не опція, а обов’язкова частина контракту.

Помилка № 2: зробили id-клас змінним і потім почали змінювати його поля.
У складеному ключі поля мають бути стабільними. Якщо ви змінили snapshotDate у InventorySnapshotId, ви фактично «перейменували» первинний ключ запису. В об’єктній моделі це виглядає як «трохи підправив дату», а у світі БД це вже «інший рядок». Hibernate такого життя не любить, і ви теж не полюбите, коли побачите результат у SQL та у винятках.

Помилка № 3: за @IdClass не збіглися назви або типи полів між сутністю та key-класом.
Це класика: у сутності private Long productId;, а в key-класі private long productId; або поле назвали productID (з великою літерою D) і думаєте, що «ну Java ж усе зрозуміє». Java зрозуміє, а JPA — ні. І ви отримаєте проблеми, які виглядають так, ніби «Hibernate зламався», хоча насправді зламалася дисципліна відповідності.

Помилка № 4: намагаються частково «генерувати» складений ключ.
Іноді хочеться: «нехай productId візьметься зі зв’язку на Product, а дату я не ставитиму — вона потім якось проставиться». Для composite id це майже завжди погана ідея. Складений ключ має бути повністю визначений до persist(). Інакше ви отримаєте або виняток, або запис із неочікуваним ключем, або купу неявної логіки, яку потім складно пояснити під час перегляду коду.

Помилка № 5: обирають composite key просто тому, що «так ближче до реляційної моделі».
Складений ключ виправданий лише тоді, коли він справді виражає природну ідентичність запису. Якщо в сутності є власний життєвий цикл, на неї посилатимуться інші таблиці, і ви не хочете тягати два поля як зовнішній ключ усюди, то технічний surrogate id може бути простішим. Composite key — це інструмент, а не стиль життя.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ