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. И тогда “бизнес-ключ” перестанет быть ключом, а станет поводом для вечернего чтения логов и философских размышлений о смысле жизни транзакций.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ