JavaRush /Курсы /Hibernate deep-dive /Идентичность entity: три ключа

Идентичность entity: три ключа

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

1. Несколько идентичностей entity

Если вы раньше писали обычные Java-классы или DTO, то «идентичность» обычно воспринималась просто: объект — это объект, два разных экземпляра — значит два разных объекта. В ORM-мире это ощущение начинает предательски подводить, потому что entity — это одновременно и Java-объект в памяти, и представление строки в таблице, и доменная сущность (товар, клиент, заказ), которую бизнес «узнаёт» не по ссылке в JVM.

Давайте очень честно зафиксируем проблему простым вопросом: когда мы говорим “это один и тот же Product” — что именно мы имеем в виду?

В Java можно иметь два разных объекта:

  • они лежат в разных местах памяти;
  • у них одинаковые значения полей;
  • они могут быть прочитаны из одной и той же строки БД, но в разные моменты времени и в разных транзакциях;
  • один из них может быть вообще proxy-объектом Hibernate (то есть «переодетым» Product).

И вот тут начинаются «тонкие и дорогие баги»: коллекции, helper-методы, orphanRemoval, неожиданные дубликаты, невозможность удалить элемент из Set, странное поведение contains(). Мы не будем пока чинить всё это (это лекции 2–4), но сейчас важно поставить фундамент: entity живёт сразу в нескольких “пространствах”, и у каждого пространства своя идентичность.

2. Оси идентичности: ссылка, БД, бизнес-ключ

Чтобы дальше не путаться, полезно держать в голове три «оси», по которым можно узнавать объект. Это не философия, а практическая карта местности: она объясняет, почему один и тот же доменный объект может вести себя как “разный” — и наоборот.

Соберём это в компактную таблицу: не как справочник на все случаи жизни, а как “карту местности”, чтобы не потеряться.

Ось идентичности Что сравниваем на самом деле Когда “работает хорошо” Где ломает интуицию
Ссылочная идентичность (Java) один и тот же объект в памяти (==) внутри одного куска кода, когда вы точно держите ту же ссылку как только объект перечитан заново или пришёл из другой транзакции
Идентичность строки БД одна и та же запись таблицы (обычно один и тот же id) когда id уже существует и мы сравниваем “какая строка?” у новых объектов id ещё нет; плюс proxy и “разные экземпляры одной строки”
Бизнес-идентичность (бизнес-ключ) доменный уникальный признак (sku, email, orderNumber) когда ключ реально уникален и стабилен в домене если ключ не уникален или может меняться — вы сами себе устроили ловушку

В нашем проекте Commerce Persistence Lab эти бизнес-ключи буквально лежат на поверхности:

  • Product.sku — товар узнают по SKU, а не по его “красивому” имени.
  • Customer.email — клиент узнаётся по email (в учебном домене считаем его уникальным).
  • PurchaseOrder.orderNumber — заказ в бизнес-общении называют номером, не id.

Эта таблица — не про то, «как правильно писать equals». Она про то, что именно вы считаете “тем же самым”. А уже в следующей лекции мы начнём превращать это в правила для кода.

3. Ссылочная идентичность: == и разные объекты

Важно начать с самой «скучной» базы, иначе дальше будет ощущение, что Hibernate «сломал Java». Нет, Java сама по себе так устроена: два экземпляра класса — это два объекта, даже если внутри у них одинаковые поля. И Java абсолютно не обязана считать их “равными” — пока вы явно не сказали обратное через equals().

Мини-демо на голой Java, без аннотаций и Hibernate, чтобы снять иллюзию, что «это всё из-за JPA»:

import java.util.Objects;

class ProductDraft {
    // SKU — это доменный признак (для примера), но тут мы НЕ реализуем equals/hashCode
    private final String sku;

    ProductDraft(String sku) {
        // Просто сохраняем значение: это обычный Java-объект без какой-либо ORM-магии
        this.sku = sku;
    }
}

public class Demo {
    public static void main(String[] args) {
        // Два разных экземпляра с одинаковыми данными внутри
        ProductDraft a = new ProductDraft("SKU-1");
        ProductDraft b = new ProductDraft("SKU-1");

        // Ссылочная идентичность: это разные объекты в памяти
        System.out.println(a == b);      // false

        // equals() по умолчанию (унаследован от Object) тоже сравнивает ссылку
        System.out.println(a.equals(b)); // false
    }
}

Оба объекта “про один SKU”, но ссылочно это разные сущности. Метод equals() по умолчанию унаследован от Object и тоже сравнивает ссылки (по сути, ведёт себя как ==). Для DTO вы часто генерируете equals() по полям — и на этом месте обычно появляется опасная привычка: «ну и для entity так же сделаю». Не спешите, Hibernate потом покажет вам, где у этой идеи спрятаны грабли.

4. Строка БД и persistence context

Теперь переходим к ORM-части. Самая неприятная для начинающих мысль звучит так: одна и та же строка таблицы может быть представлена разными Java-объектами. Причём это нормально. И более того — это неизбежно, как только вы живёте дольше одной транзакции.

Внутри одной транзакции persistence context работает как identity map: Hibernate старается, чтобы один id соответствовал одному managed-объекту. Поэтому в рамках одной транзакции вы часто видите “магическое” поведение: два раза прочитал — получил тот же объект.

Вот минимальный сервисный пример в стиле нашего проекта:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductIdentityDemoService {

    // EntityManager — это вход в persistence context текущей транзакции
    private final EntityManager em;

    public ProductIdentityDemoService(EntityManager em) {
        this.em = em;
    }

    @Transactional
    public void demoSameTx(Long productId) {
        // Первый find: Hibernate грузит entity и кладёт managed-объект в 1st-level cache
        var p1 = em.find(Product.class, productId);

        // Второй find в той же транзакции: вернётся та же ссылка из persistence context
        var p2 = em.find(Product.class, productId);

        // Внутри одной транзакции это часто true (но это не “доменная идентичность”)
        System.out.println(p1 == p2); // true
    }
}

Почему true? Потому что второй find() попадает в first-level cache: Hibernate уже держит managed-объект для этой строки и возвращает ссылку на него же.

Но как только мы выходим за границу одной транзакции (а в реальной жизни мы почти всегда выходим, потому что HTTP-запросы, сервисные операции и т.д.), ситуация становится противоположной: вы снова читаете ту же строку, но получаете новый Java-экземпляр.

Даже если это “тот же товар” в БД, == уже станет false, потому что это другая ссылка.

Мысленно это удобно рисовать вот так:

flowchart TD
    %% Одна строка в БД может быть представлена разными объектами в разных транзакциях
    DB["product row id=10, sku=SKU-10"]
    TX1["Транзакция #1 Persistence Context #1"]
    TX2["Транзакция #2 Persistence Context #2"]
    P1["Java object Product@A"]
    P2["Java object Product@B"]

    DB --> TX1 --> P1
    DB --> TX2 --> P2

Одна строка — два объекта. И это не баг. Это просто следствие того, что persistence context живёт внутри транзакции, а не “вечен”.

Практический смысл очень простой: если вы в коде пытаетесь узнать “тот же ли это товар” через ==, вы проверяете не товар, а случайный факт: “а это одна и та же ссылка прямо сейчас?”. Это иногда совпадает с доменной реальностью (в рамках одного контекста), но как критерий идентичности товара — бесполезно.

5. id у новых сущностей: null

На этом месте многие говорят: “Окей, не буду сравнивать ссылки. Буду сравнивать id — ведь это же идентификатор строки”. И в целом мысль логичная… пока вы не вспомните одну вещь из дня про состояния сущностей: у нового объекта id ещё нет.

До момента, когда сущность станет “строкой в БД” (а не просто объектом в памяти), её db-идентичность не существует. Поэтому во всех местах, где вы работаете с новыми объектами (создание заказа, добавление позиций, создание ProductCategoryAssignment), сравнение “по id” может оказаться сравнением “по null”.

Наглядный минимальный пример: создаём сущность (ещё transient), и id закономерно пустой:

import java.math.BigDecimal;

public class CreateProductDemo {

    public static void main(String[] args) {
        // Создаём новый объект в памяти: это ещё не “строка в БД”
        Product p = new Product("SKU-NEW", "Coffee mug");

        // До persist/flush id обычно не задан (часто null)
        System.out.println(p.getId()); // null
    }
}

Здесь важна не конкретная модель конструктора (в реальном entity конструкторы могут быть другими), а факт: до сохранения в БД поле id обычно null. Значит, попытка “узнавать сущность” по id до персиста — это попытка узнавать людей по номеру паспорта, который им ещё не выдали. Иногда вы угадали, но это не стратегия, а удача.

И вот почему сегодняшняя лекция (про идентичность) идёт до лекций про equals() и hashCode(). Если вы начнёте писать equality “по id” не понимая этого нюанса, вы получите очень странные эффекты в коллекциях: два разных новых объекта с id == null могут внезапно “стать одним и тем же” с точки зрения вашей логики сравнения. И это уже прямой мост к багам в Set и helper-методах (следующие лекции дня).

6. Бизнес-ключ и доменная идентичность

Теперь самое важное понятие сегодняшней лекции: бизнес-ключ (business key). Это такой признак, по которому домен узнаёт объект как “тот же самый”, даже если объект перечитан, сериализован, детачнут, получен в другом сервисе и вообще пережил все приключения героя из RPG.

Критерий прост и жесток: бизнес-ключ должен быть уникальным и стабильным.

В нашем домене эти признаки выглядят естественно:

  • товар узнаётся по sku;
  • клиент — по email;
  • заказ — по orderNumber.

Сравним два поля товара, чтобы почувствовать разницу:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id
    private Long id;

    @Column(nullable = false, unique = true)
    private String sku;   // хороший кандидат на бизнес-ключ: уникальный и (в идеале) неизменяемый

    @Column(nullable = false)
    private String name;  // плохой кандидат: имя легко меняется и не гарантирует уникальность
}

Идея здесь такая: name — это то, что удобно показывать пользователю, а sku — это то, что “узнаёт” товар как сущность. Имя может измениться (“Кружка” → “Кружка большая”), и это не должно превращать товар в “другого” товара. А SKU в рамках курса мы считаем неизменяемым.

Похожая логика и у клиента:

  • имя и фамилия могут исправляться (и это нормально),
  • email — в нашем учебном домене уникален и используется как идентификатор клиента.

А у заказа номер заказа вообще “официальное имя” заказа в бизнес-процессах. Люди редко говорят “заказ с id=912”, но часто говорят “заказ №PO-2026-000123”.

Есть важная практическая деталь: бизнес-ключ хорош только тогда, когда он зафиксирован как уникальный не только в Java, но и в схеме БД. Поэтому в миграциях Flyway для проекта естественно иметь ограничения уникальности. В минимальном виде это выглядит так:

-- Фиксируем бизнес-уникальность на уровне БД, чтобы это было правилом, а не “надеждой”
alter table product
    add constraint uk_product_sku unique (sku);

Без такого ограничения бизнес-ключ превращается в “надежду”. А надежда — плохой инструмент для persistence layer. Hibernate очень хорош в SQL, но очень плохо умеет “надеяться”.

Мини-итог: что это даст дальше

Сейчас хочется сразу прыгнуть в equals() и написать “правильную” реализацию. Но если сделать это слишком рано, вы будете лечить симптом, не понимая диагноза. Сегодняшняя цель — чтобы у вас в голове появилась картинка: у entity есть как минимум три “лица”.

В коде вы можете случайно сравнивать:

  • ссылки (и тогда сравниваете “один ли это объект в памяти”);
  • id (и тогда сравниваете “одна ли это строка БД”, но только если id уже существует);
  • бизнес-ключ (и тогда сравниваете “одна ли это доменная сущность”, если ключ стабилен и уникален).

Дальше по плану дня мы будем превращать эту картинку в правила: как выбрать основу для equals()/hashCode(), почему “равенство по всем полям” — ловушка, как proxy ломает getClass(), и почему Set первым начинает сигналить, что ваш equality — не инженерный, а оптимистичный.

7. Типичные ошибки в понимании идентичности entity

Ошибка №1: думать, что “одинаковые поля” означают “одна и та же сущность”.
Это DTO-интуиция, и она часто полезна для “снимков данных”. Но entity — не снимок, а объект с жизненным циклом, который отражает строку БД и доменную сущность. Два объекта могут иметь одинаковые поля сейчас, но быть разными сущностями. И наоборот: один и тот же товар может поменять имя, цену, статус — и остаться тем же товаром.

Ошибка №2: сравнивать entity через == и надеяться, что Hibernate “всё равно поймёт”.
== проверяет только ссылку. Внутри одной транзакции вы иногда будете получать “правильный” ответ случайно, потому что persistence context возвращает тот же managed-объект. Но как только вы пересечёте границу транзакции, == перестанет иметь хоть какой-то доменный смысл.

Ошибка №3: считать, что id всегда существует и всегда подходит как “паспорт сущности”.
У новых объектов id часто null. А значит, если вы начинаете строить логику идентичности вокруг id слишком рано, вы неизбежно столкнётесь с тем, что “разные новые объекты одинаковы” (потому что у обоих null). Это особенно больно проявляется в коллекциях и helper-методах, но корень проблемы — именно неверная модель идентичности.

Ошибка №4: объявить бизнес-ключом поле, которое может меняться или не гарантирует уникальность.
“Имя товара” или “фамилия клиента” звучат по-человечески, но они не уникальны и легко меняются. Если вы включаете такие поля в понятие идентичности, то любая правка текста превращает для вашей системы “появился новый объект”, а старый как будто исчез. В реальной системе это не просто баг — это финансовая магия (обычно плохая).

Ошибка №5: не закрепить бизнес-ключ на уровне схемы БД.
Если в таблице нет уникального ограничения, то даже самый красивый Java-код может оказаться бессильным: база позволит создать два товара с одним SKU. И тогда “бизнес-ключ” перестанет быть ключом, а станет поводом для вечернего чтения логов и философских размышлений о смысле жизни транзакций.

1
Задача
Hibernate deep-dive, 13 уровень, 0 лекция
Недоступна
Одна строка БД и разные Java-объекты
Одна строка БД и разные Java-объекты
1
Задача
Hibernate deep-dive, 13 уровень, 0 лекция
Недоступна
Новая сущность без идентичности строки БД
Новая сущность без идентичности строки БД
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ