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

Составные ключи в JPA

Hibernate deep-dive
15 уровень, 3 лекция
Открыта

1. Составной ключ: смысл и причины

Technical PK и business-id мы уже развели, но этого всё ещё мало: иногда сама строка по-честному определяется не одним полем, а комбинацией значений. Если вы привыкли, что у каждой таблицы есть «одна колонка id и точка», составной ключ сначала выглядит как странная прихоть людей, которые любят усложнять. Но в реальных данных иногда есть сущности, у которых нет смысла придумывать отдельный surrogate id, потому что строка и так уникальна по комбинации естественных атрибутов. Это как адрес: один «дом» идентифицируется не одним числом, а набором “город + улица + дом”.

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

В Hibernate/JPA этот выбор напрямую влияет на runtime-поведение. Persistence context хранит identity map (мы это обсуждали раньше), и ключом там является сочетание “тип entity + значение id”. Если id сложный, то и “ключ” в identity map становится сложным объектом, который обязан вести себя предсказуемо: корректные equals()/hashCode(), стабильность значений, отсутствие «плавающих» полей.

Чтобы зафиксировать картину, удобно представить так:

flowchart TD
    A["Entity instance (InventorySnapshot)"] --> B["@Id value"]
    B --> C["Persistence Context identity map key"]
    C --> D["Hibernate понимает: это та же самая строка?"]

Если @Id — это Long, вопросов мало. Если @Id — это объект из двух полей, вопросов становится больше, и мы должны на них ответить в коде.

2. Пример: InventorySnapshot — товар и дата

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

То есть идентичность строки можно выразить как:

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

И это выглядит очень честно как primary key. Мы не придумываем искусственный snapshot_id, который сам по себе ничего не значит. Мы говорим: “снимок — это (товар, дата)”. Ровно так, как бизнес мыслит об этом объекте.

Важно при этом не перепутать составной ключ с @NaturalId. Natural id — это уникальное бизнес-поле (или набор полей) для поиска, но оно не обязано быть primary key. Составной ключ — это уже уровень схемы и идентичности строки. В практическом коде это чувствуется сразу: findById(...) больше не принимает одно число. Он просит целый объект-ключ.

3. Composite key в JPA: @EmbeddedId и @IdClass

JPA не позволяет просто написать “у меня два @Id и всё само”. Формально оно позволяет несколько @Id, но обязательно просит выделить класс идентификатора, который описывает составной ключ. Для этого есть два основных варианта: @EmbeddedId и @IdClass. Оба решают одну задачу, но делают это разным способом, и в дальнейшем по-разному влияют на читаемость, API и маппинг.

Ниже — компактная таблица, чтобы вы сразу видели, что сравниваем. Не пытайтесь выучить её наизусть: она нужна, чтобы мозг понял, что различия реальные, а не “две одинаковые аннотации”.

Критерий @EmbeddedId @IdClass
Где лежит id в entity В одном поле-объекте id Поля id лежат прямо в entity
Как выглядит findById findById(new Key(...)) То же самое, но entity “плоская”
Что похоже по стилю Value object (мы уже делали @Embeddable) “Плоская entity + отдельный класс для ключа”
Где чаще проще делать аккуратно Когда ключ логично воспринимается как единое значение Когда удобно держать поля ключа прямо на entity
Где чаще ловят странные баги Если забывают equals()/hashCode() на id-классе Если путают соответствие полей между entity и key-классом

Мы дальше пройдём оба варианта на примере InventorySnapshot, чтобы вы видели разницу не по рассказу, а по коду.

4. Правила составного id-класса

Перед тем как писать аннотации, важно договориться о правилах игры. Composite key — это случай, когда маленький класс внезапно становится фундаментом identity map внутри Hibernate. Поэтому требования к id-классу — не «формальность для галочки», а условие корректности.

В JPA id-класс должен быть публичным, иметь конструктор без аргументов (Hibernate/JPA будут создавать его рефлексией), быть Serializable, и иметь корректные equals()/hashCode(). В нашем курсе это звучит знакомо: на дне про equals/hashCode мы уже обсуждали, что “равенство” — не декоративная тема, а источник реальных багов в коллекциях и в ORM.

Ещё одна мысль: поля составного ключа должны быть стабильными. Если вы включили в ключ поле, которое может меняться, вы почти гарантированно попали в ситуацию “изменение ключа = изменение идентичности строки”. Это как пытаться поменять номер паспорта человеку и ожидать, что он останется тем же человеком. Формально — нет, practically — ещё хуже.

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

@EmbeddedId — самый “value-object” способ описать составной ключ. И он логически продолжает уровень 14 про @Embeddable: мы уже умеем встраивать значения, теперь встраиваем идентификатор. В результате entity получает одно поле 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);
}

Здесь мы сознательно сравниваем оба поля. И это выглядит ровно так, как мы ожидаем: два ключа равны, если совпал товар и дата.

Entity InventorySnapshot с @EmbeddedId

Теперь сама entity. С @EmbeddedId это выглядит очень прямо: есть поле id, есть поле quantity. Для учебного примера этого достаточно, и мы пока не усложняем связь с Product.

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

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

    // Вся идентичность строки живёт здесь, как единый value-object
    @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> {
}

И это уже влияет на весь calling code: findById теперь принимает объект ключа.

Использование: сохранить и прочитать snapshot

Покажем мини-сценарий из сервисного метода. Я нарочно сделаю его максимально “на пальцах”: сформировали id, создали entity, сохранили, прочитали по 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) {
        // Внедряем репозиторий, который работает с composite-id
        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 знает, что такое managed-entity, а id — это то, что связывает объект и строку. При composite key вы просто обязаны относиться к id как к полноценному объекту модели, а не как к “числу где-то там”.

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

@IdClass — другой стиль той же модели. Для InventorySnapshot в проекте он остаётся именно альтернативой для сравнения, а не новым baseline: рабочим вариантом мы считаем @EmbeddedId. Но @IdClass всё равно важно уметь читать, потому что он регулярно встречается в legacy и в чужом коде. Он нравится людям, которые хотят видеть поля ключа прямо на entity, без обёртки id. С точки зрения чтения кода это иногда действительно удобнее: открыл entity и сразу видишь productId и snapshotDate. Но JPA всё равно просит отдельный key-класс, потому что где-то должен существовать “тип id” для findById().

Важно помнить: при @IdClass поля id в key-классе должны совпадать по именам и типам с полями @Id на entity. И это место, где начинаются “тихие” ошибки: опечатка в имени, разный тип (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-полем в entity
    private Long productId;

    // Должно совпасть по имени и типу с @Id-полем в entity
    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);
}

Entity с @IdClass

Теперь entity. Здесь ключевые поля лежат прямо на ней и помечаются @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(). Минус в том, что ключ как бы “размазан” по entity, и вы постоянно должны помнить: эти два поля — особенные, это не просто атрибуты, это identity.

Репозиторий

Репозиторий выглядит почти так же, как в @EmbeddedId, только key-класс другой и entity другая.

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

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

Да, findById всё равно хочет InventorySnapshotKey. То есть с точки зрения API потребителя вы не выигрываете “одно поле вместо объекта”. Вы выигрываете читаемость полей внутри самой entity.

7. Выбор: @EmbeddedId или @IdClass

Когда вы впервые встречаете два способа сделать одно и то же, хочется спросить: “а какой правильный?”. Для JPA в целом универсального победителя нет, но для нашего проекта выбор можно зафиксировать жёстче: InventorySnapshot оставляем на @EmbeddedId. У этой сущности ключ реально живёт как одно значение (productId, snapshotDate), и такой вариант лучше читается в сервисах, тестах и findById(). @IdClass остаётся рядом как нормальный JPA-инструмент, который надо уметь читать и оценивать, но не как второй параллельный snapshot той же сущности.

Если composite key воспринимается как единый смысловой объект, @EmbeddedId обычно читается проще. У InventorySnapshot это похоже на правду: “идентификатор снимка” — это отдельная штука (товар, дата). Плюс @EmbeddedId приятно сочетается с тем, что мы уже обсуждали: value objects и @Embeddable. Вы получаете единый объект, который можно передать в findById().

Если же вы хотите максимально “плоскую” entity и предпочитаете видеть части id прямо на ней, @IdClass может быть комфортнее. Но за комфорт вы платите дисциплиной: имена и типы должны совпасть, и любая мелкая ошибка превращается в неприятный запускной баг.

Есть и ещё один практический фактор: как вы будете маппить связь на Product. Для @EmbeddedId типичный путь — @MapsId, и он довольно естественный. Для @IdClass связь тоже возможна, но часто приводит к более “скрипучему” маппингу (например, дублирование колонок или insertable=false, updatable=false). В рамках этой лекции мы не будем уходить в сложные варианты, но важно знать: выбор аннотации влияет не только на красоту, но и на возможности маппинга.

8. Flyway-миграция под composite key

Очень легко написать аннотации и забыть, что в конце концов всё упирается в таблицу. Но у нас по правилам проекта схема живёт только через Flyway, и это хорошая дисциплина: вы всегда видите, что реально хранится в базе. Для composite key это особенно полезно, потому что primary key в БД тоже становится составным.

Пример минимальной миграции (укороченной), чтобы у нас появилась таблица 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 не совпали имена/типы полей между entity и key-классом.
Это классика: в entity private Long productId;, а в key-классе private long productId; или поле назвали productID (с большой буквы D) и думаете, что “ну Java же всё поймёт”. Java поймёт, а JPA — нет. И вы получите проблемы, которые выглядят как “Hibernate сломался”, хотя на самом деле сломалась дисциплина соответствия.

Ошибка №4: пытаются частично “генерировать” составной ключ.
Иногда хочется: “пусть productId возьмётся из ссылки на Product, а дату я не буду ставить, она потом как-нибудь проставится”. Для composite id это почти всегда плохая идея. Составной ключ должен быть полностью определён до persist(). Иначе вы получаете либо исключение, либо запись с неожиданным ключом, либо кучу неявной логики, которую потом сложно объяснить на code review.

Ошибка №5: выбирают composite key просто потому, что “так ближе к реляционной модели”.
Составной ключ оправдан, когда он реально выражает естественную идентичность строки. Если у сущности есть собственный жизненный цикл, на неё будут ссылаться другие таблицы, и вы не хотите таскать два поля как внешний ключ повсюду, то технический surrogate id может быть проще. Composite key — инструмент, а не стиль жизни.

1
Задача
Hibernate deep-dive, 15 уровень, 3 лекция
Недоступна
InventorySnapshot с @EmbeddedId
InventorySnapshot с @EmbeddedId
1
Задача
Hibernate deep-dive, 15 уровень, 3 лекция
Недоступна
WarehouseBalance с @IdClass
WarehouseBalance с @IdClass
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ