1. Proxy и их влияние на equals()
Если вы до сих пор думаете, что proxy — это какая-то экзотика «для тех, кто читает исходники Hibernate по ночам», у меня для вас хорошие новости: вы уже с ними живёте. Просто они обычно приходят в код тихо, как кот в 3 ночи: без объявления войны, но с очень конкретными последствиями для вашего сна и equals().
Proxy появляются в двух самых «будничных» местах нашего Commerce Persistence Lab. Во-первых, когда Hibernate лениво грузит to-one связи (например, PurchaseOrder.customer или OrderItem.product). Во-вторых, когда вы сами просите ссылку без загрузки через EntityManager.getReference() (или Spring Data getReferenceById()), потому что вам нужен только идентификатор или вы хотите отложить чтение.
Мы уже разделили сущности на два bucket’а: Product, Customer и PurchaseOrder узнаём по стабильному business key, а OrderItem и link-entity — по стратегии «равны только после появления id». Теперь не меняем этот ответ, а смотрим, что с ним делает proxy-runtime.
И вот в этот момент начинается магия: вы вроде сравниваете «два Product», а один из них — на самом деле специальный подкласс Product, созданный Hibernate. В Java это нормальная ситуация (наследование), но в equals() многие пишут строгую проверку класса через getClass()… и внезапно получают false там, где доменно ожидают true.
2. Как устроен Hibernate-proxy
Чтобы дальше не гадать по кофейной гуще, давайте договоримся о минимальной ментальной модели proxy. Не будем уходить в байткод, ByteBuddy и прочую «красоту», от которой иногда хочется вернуться к CSV-файлам. Нам нужно понять ровно столько, чтобы грамотно написать equals()/hashCode().
Hibernate-proxy — это объект-заглушка, который выглядит как entity, имеет её идентификатор, но не содержит остальных данных до тех пор, пока вы не попытаетесь к ним обратиться. Для этого proxy хранит внутри «ленивый инициализатор»: у него есть связь с текущей Session/EntityManager, и он знает, какой SELECT нужно выполнить, если вы захотите прочитать «настоящие» поля.
На картинке это можно представлять так:
flowchart TD
A["Ваш код order.getCustomer()"] --> B["Customer proxy (id известен, данные ещё нет)"]
B -->|первый доступ к полю| C["SELECT ... FROM customer WHERE id=?"]
C --> D["Инициализированный Customer (поля загружены)"]
Ключевой момент для equality: proxy почти всегда является подклассом вашей entity. То есть proxy instanceof Customer будет true, но proxy.getClass() == Customer.class — очень часто false.
3. Ловушка getClass() в equals()
В чистой Java строгая проверка класса в equals() выглядит вполне «правильно»: она делает сравнение симметричным и защищает от странных ситуаций с наследованием. Поэтому новичок пишет getClass() != other.getClass() и чувствует себя инженером. Hibernate в этот момент тоже чувствует себя инженером, но в противоположную сторону.
Посмотрим на типичную «строгую» реализацию, которая ломается из-за proxy:
import java.util.Objects;
public boolean equals(Object other) {
// Строгая проверка класса: "должны быть ровно одного runtime-класса"
// На Hibernate-proxy это часто ломается, потому что proxy — подкласс вашей entity.
if (other == null || getClass() != other.getClass()) {
return false;
}
// После getClass() можно безопасно приводить тип (но это всё ещё не proxy-safe).
Product p = (Product) other;
// Сравнение по бизнес-полю: выглядит логично, но проблема тут именно в getClass().
return Objects.equals(getSku(), p.getSku());
}
Теперь сделаем маленькое наблюдение в нашем проекте (в стиле «посмотрим на реальность, а не на наши надежды»). Важно: пример ниже — про диагностику. Он полезен даже без полного понимания деталей.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void showProxyClasses(EntityManager em, Long productId) {
// find() возвращает "настоящий" управляемый объект (обычно без proxy-обёртки)
var managed = em.find(Product.class, productId);
// getReference() возвращает proxy-ссылку: id известен, поля могут быть не загружены
var proxy = em.getReference(Product.class, productId);
// У managed и proxy будут разные runtime-классы
System.out.println(managed.getClass()); // class com.example.commerce.catalog.entity.Product
System.out.println(proxy.getClass()); // class com.example.commerce.catalog.entity.Product$HibernateProxy$...
// Если equals() использует getClass(), то managed и proxy часто окажутся "не равны"
System.out.println(managed.equals(proxy)); // false (если equals() проверяет getClass())
}
И вот тут самый неприятный момент: с точки зрения домена это один и тот же товар, с точки зрения идентификатора БД — тоже один и тот же row, но equals() возвращает false, потому что proxy — другой runtime-класс.
И это не «Hibernate сломался». Это мы применили «чисто Java-правило» в месте, где runtime специально использует подклассы как инструмент lazy loading.
4. instanceof для proxy-safe проверки типа
После прошлого раздела хочется схватить getClass() и торжественно удалить. Иногда — заслуженно. Но давайте сделаем это осознанно. instanceof — это мягкая проверка типа: она отвечает на вопрос «это хотя бы Product?», а не «это ровно Product без примесей».
Почему это хорошо для Hibernate? Потому что proxy — подкласс entity. Следовательно, proxy instanceof Product возвращает true, и мы не отвергаем объект только из-за того, что Hibernate решил завернуть его в ленивую обёртку.
Минимально proxy-safe проверка типа выглядит так:
import java.util.Objects;
public boolean equals(Object other) {
// Мягкая проверка типа: proxy (как подкласс) её проходит
if (!(other instanceof Product)) {
return false;
}
// Приведение типа безопасно после instanceof
Product p = (Product) other;
// Важно помнить: instanceof также "пускает" реальные наследники Product, если они существуют
return Objects.equals(getSku(), p.getSku());
}
Здесь instanceof пропускает proxy. И это действительно решает главную проблему getClass().
Но у instanceof есть нюанс, о котором важно знать заранее: он «разрешает» сравнение с наследниками. В обычной Java-иерархии это может привести к странностям, если у вас есть реальные подклассы, которые добавляют смысл (например, SpecialProduct extends Product). Для core-сущностей нашего проекта (Product, Customer, PurchaseOrder, OrderItem) это не то, что мы хотим стимулировать, поэтому чаще используют более строгий, но всё ещё proxy-safe подход — про него следующий раздел.
Чтобы не держать это в голове как набор абстрактных слов, полезно увидеть разницу в таблице:
| Проверка типа в equals() | Proxy переживает? | Строгость | Типичный исход |
|---|---|---|---|
| getClass() == other.getClass() | нет | максимальная | ломается на proxy |
| other instanceof Product | да | мягкая | работает с proxy, но «пускает наследников» |
| Hibernate.getClass(this) == Hibernate.getClass(other) | да | строгая | работает с proxy и не путается в типах |
5. Hibernate.getClass() как строгая альтернатива instanceof
Когда вы хотите одновременно два свойства — «proxy не ломает сравнение» и «я всё-таки сравниваю объекты одного сущностного типа, без сюрпризов наследования» — у Hibernate есть очень практичная утилита: Hibernate.getClass(entity).
Эта штука делает важную работу: если объект — proxy, она возвращает класс “настоящей” entity, а не класс proxy-подкласса. Нам здесь важен именно этот эффект: proxy-aware проверить тип и не спутать proxy с другой сущностью. Не надо воспринимать Hibernate.getClass(...) как серебряную пулю для всего lazy-runtime: это helper для проверки типа, а не обещание «теперь никаких SQL и side effects вообще никогда».
Ключевая строчка выглядит так:
import org.hibernate.Hibernate;
// внутри equals(...)
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
После этой проверки вы уже сравниваете ту основу идентичности, которую выбрали раньше: либо business key, либо id у bucket’а «равны только после появления id».
6. Getter’ы внутри equals(): корректность vs лишний SQL
Теперь к той части, где ORM начинает шутить особенно тонко. Допустим, вы решили сравнивать Product по бизнес-ключу sku. Вроде логично: sku уникален, стабилен, его знают интеграции, и он доменно важнее, чем id. Но в мире proxy у этой идеи есть две неожиданные стороны.
Первая сторона — если в equals() вы обращаетесь к полю напрямую, а не через getter, вы рискуете получить «пустое» значение на неинициализированном proxy. Proxy — не обычный объект, он может содержать поля в дефолтном состоянии до инициализации. Тогда this.sku может оказаться null, хотя в БД sku есть.
Вторая сторона — если вы обращаетесь к getter’у, proxy может решить: «О! Нужны данные — значит, пора грузить!» и выполнить SELECT. То есть обычный equals() внезапно становится операцией с I/O. И иногда это приводит к сюрпризам уровня «почему у меня contains() в HashSet вызывает SQL-запросы?».
Чтобы увидеть это глазами, достаточно вспомнить простую вещь: proxy точно знает id, но не обязан знать остальные поля.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void proxyInitializationDemo(EntityManager em, Long productId) {
// Получаем proxy: в этот момент объект ещё может быть не инициализирован
Product proxy = em.getReference(Product.class, productId);
// id обычно доступен без SELECT (Hibernate хранит его прямо в proxy)
System.out.println(proxy.getId()); // 1
// Доступ к бизнес-полю может потребовать инициализацию и вызвать SELECT
System.out.println(proxy.getSku()); // может вызвать SELECT, чтобы инициализировать proxy
}
Именно поэтому proxy не переопределяет ту стратегию, которую вы уже выбрали, а показывает её цену. Для id-based bucket это обычно дешевле: proxy знает id сам и не обязан для этого тащить остальные поля. Для business-key bucket цена выше: equals() и hashCode() могут стать триггером загрузки, если ключ доступен только после инициализации proxy.
Это не означает, что бизнес-ключ плох. Это означает, что Product.sku, Customer.email и PurchaseOrder.orderNumber остаются рабочей основой equality, но уже не выглядят «бесплатной» операцией в proxy-сценариях. Поэтому дальше удобно разбирать оба bucket’а отдельно: сначала id-based, потом business-key.
7. Equality для entity с generated id
В нашем проекте много сущностей, у которых идентичность в БД выражается generated id: OrderItem, ProductCategoryAssignment, CustomerAddress, InventoryItem и другие. Для них business key либо отсутствует, либо не является настолько «железным», чтобы строить на нём equality. Поэтому нам нужен шаблон, который не разваливается на proxy и не стреляет себе в ногу на id == null.
Для них мы ничего не переизобретаем: просто делаем proxy-safe ту id-based стратегию, которую уже выбрали.
Ниже — практичный вариант, который хорошо переживает и proxy, и id == null, и не делает «двух новых сущностей равными»:
import org.hibernate.Hibernate;
import java.util.Objects;
@Override
public final boolean equals(Object o) {
// Быстрый путь для сравнения ссылки
if (this == o) return true;
// Proxy-safe и достаточно строгая проверка типа
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
OrderItem other = (OrderItem) o;
// До появления id сущность уникальна только как объект в памяти,
// поэтому равенство по id включаем только если id уже задан.
return id != null && Objects.equals(id, other.id);
}
А hashCode() — стабильный и не завязан на generated id:
@Override
public final int hashCode() {
// Стабильный hashCode: не зависит от generated id и не меняется после persist()/flush()
return 31;
}
Этот подход иногда называют «id-based equality after persistence»: до появления id сущность уникальна только как объект в памяти (то есть this == other), после появления id — как строка в БД.
И да, это ровно тот случай, когда hashCode() кажется «слишком простым». Но именно за это мы его и любим: он не меняется, не зависит от mutable полей, и не превращает HashSet в квест.
8. Equality для entity с бизнес-ключом
Теперь к тем сущностям, где канон уже выбран по домену: Product.sku, Customer.email, PurchaseOrder.orderNumber. Proxy не отменяет этот выбор. Он только добавляет два требования: проверку типа нужно сделать proxy-safe, а цену сравнения — видеть честно.
Если ключ читается через getter, raw proxy может инициализироваться и сходить в БД. Это та самая цена business-key equality. Но сама основа сравнения не меняется: Product всё так же узнаётся по sku, а не внезапно по generated id.
Например, для Product proxy-safe версия будет выглядеть так:
import org.hibernate.Hibernate;
import java.util.Objects;
@Override
public final boolean equals(Object o) {
// Быстрый путь: тот же объект
if (this == o) return true;
// Proxy-safe проверка типа
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Product other = (Product) o;
// Основа идентичности не меняется: Product узнаём по sku.
// Важно понимать цену: getter на raw proxy может инициировать загрузку.
return Objects.equals(getSku(), other.getSku());
}
@Override
public final int hashCode() {
// Hash строим по тому же стабильному ключу.
// Если объект сам является raw proxy, такая операция тоже уже не "бесплатна".
return Objects.hash(getSku());
}
Здесь важно видеть не новый рецепт, а старую договорённость под другим освещением. Product всё так же узнаётся по sku; Customer — по email; PurchaseOrder — по orderNumber. Proxy-safe частью здесь является проверка типа через Hibernate.getClass(...). А риск лишней инициализации никуда не исчезает: если бизнес-ключ доступен только после загрузки proxy, Hibernate может сходить в БД.
Поэтому business-key equality работает хорошо там, где ключ действительно стабилен и вы не делаете вид, что equals()/hashCode() — чистая математика без runtime-стоимости.
9. Демо: find() vs getReference() и equals()
Чтобы это не осталось теорией уровня «поверьте мне, я видел продакшен», давайте соберём маленький сценарий на тех же инструментах, которые мы уже использовали в курсе: EntityManager.find(), EntityManager.getReference() и контролируемая граница persistence context через clear().
Идея такая: сначала получаем полноценный объект, потом очищаем persistence context (получаем detached), затем берём proxy-ссылку и сравниваем. Если equals() написан через getClass() — будет сюрприз. Если proxy-safe — всё предсказуемо.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void compareDetachedAndProxy(EntityManager em, Long productId) {
// Сначала получаем управляемую сущность
Product detached = em.find(Product.class, productId);
// Очищаем persistence context: объект становится detached
em.clear(); // detached теперь реально detached
// Затем получаем proxy на тот же id
Product proxy = em.getReference(Product.class, productId);
// При proxy-safe equals это сравнение должно быть предсказуемым
System.out.println(detached.equals(proxy)); // true при proxy-safe equals; у business-key bucket это сравнение может инициировать proxy
}
Если вы прямо сейчас думаете: «Но зачем сравнивать detached и proxy?» — это как раз тот случай, который в реальных приложениях возникает незаметно. Например, вы сохраняете entity в каком-то слое, передаёте её дальше (DTO-мэппинг, другой сервис, другой use case), а потом сравниваете с объектом, который пришёл из другой транзакции. Или кладёте в коллекцию, где equals() вызывается внутри remove().
И тут вы внезапно узнаёте, что ваш «строгий» getClass() был не строгим, а просто грустным.
10. Типичные ошибки при proxy-safe equals()
В этой теме ошибки особенно коварные: код компилируется, иногда даже «вроде работает», а потом начинает ломаться в самых неподходящих местах — в коллекциях, helper-методах, orphan removal, логировании. Поэтому полезно заранее знать, как выглядят классические грабли, чтобы не наступать на них с разбега.
Ошибка №1: использовать getClass() в equals() для entity, которые могут быть proxy.
Это выглядит «правильно» по учебнику Java, но ломается о реальность Hibernate. Итог обычно выглядит как «у меня два объекта одного товара, но они не равны», а дальше начинают разваливаться Set.remove(), contains() и вся логика, которая полагается на equality.
Ошибка №2: сравнивать proxy по полям через прямой доступ к полю, а не через getter.
На неинициализированном proxy поля могут быть в дефолтном состоянии, и вы получаете сравнение null с null, которое вообще не отражает данных в БД. Getter, в отличие от прямого чтения поля, даёт proxy шанс корректно обработать доступ (вплоть до инициализации). Но за это вы платите риском лишнего SQL — поэтому такой подход нужно использовать осторожно.
Ошибка №3: строить equality по бизнес-ключу и не замечать, что equals() стал триггером SQL.
Это тот самый момент, когда невинный set.contains(entity) неожиданно превращается в запрос к базе. На небольшом датасете вы этого не заметите, а на реальном — получите «почему у нас N+1 на операции, где мы вообще не читали связь?». В deep-dive курсе мы как раз учимся видеть такие вещи по SQL trace.
Ошибка №4: писать id-based equality как Objects.equals(id, other.id) без проверки id != null.
Так две новые сущности с id == null становятся равными, а HashSet начинает «склеивать» разные объекты. Это особенно разрушительно для child/link-entity (например, OrderItem или ProductCategoryAssignment), потому что именно они чаще всего живут в коллекциях и именно на них завязаны helper-методы.
Ошибка №5: делать hashCode() зависимым от generated id или mutable поля.
Generated id появляется слишком поздно (после persist() и/или flush), а mutable поле может поменяться в любой момент. В обоих случаях объект, который уже лежит в hash-based коллекции, становится «невидимым» для неё. Это не баг HashSet, это договор: хеш должен быть стабильным. В нашем Hibernate-контексте «стабильный» почти всегда означает «не завязан на то, что может меняться в жизненном цикле entity».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ