1. Ідентифікатор глибший, ніж @Id
Коли ви вперше бачите JPA‑сутність, @Id виглядає як нудна обов’язкова наліпка: «Без неї Hibernate не запуститься». Але в поглибленому курсі ця наліпка раптом стає половиною сюжету: від ідентифікатора залежить, як Hibernate впізнає, хто є хто, коли виконує SQL і чому ваші об’єкти іноді поводяться як персонажі серіалу з втратою памʼяті.
На побутовому рівні здається, що ідентифікатор потрібен лише для читання з таблиці: ось є product.id, і за ним ми робимо SELECT. На практиці ідентифікатор — це опорна точка, навколо якої побудована вся внутрішня дисципліна ORM. Саме за @Id Hibernate вирішує, чи можна вважати два об’єкти «тим самим» товаром, як організувати кеш першого рівня, як коректно виконати merge() для detached‑даних (ми вже це проходили) і чому повторний find() в одній транзакції повертає той самий об’єкт.
Ще один важливий момент: ідентифікатор — це не лише історія про базу. Це історія про вашу памʼять, колекції, equals()/hashCode() і навіть про те, як ви пишете сервісні сценарії. Ідентифікатор відповідає на три практичні запитання, які ми сьогодні триматимемо в голові: хто створює значення, коли воно зʼявляється і чи несе воно предметний зміст, чи залишається суто технічним ключем.
2. Види ідентичності: об’єктна, БД, бізнес
Дуже легко заплутатися, бо слово «ідентифікатор» звучить як «щось унікальне», а унікальне, здається, має бути одне. На практиці в застосунку одночасно живуть щонайменше три різні «ідентичності», і якщо їх не розвести, ви рано чи пізно отримаєте дивні баги, які важко пояснити колегам без жестів і нервового сміху.
Перша — об’єктна ідентичність у Java. Це відповідь на запитання: «Це той самий об’єкт у пам’яті?» — і перевіряється найчеснішим оператором ==. Якщо a == b, то це буквально один і той самий екземпляр, просто з двома іменами. Hibernate цю ідентичність підтримує всередині persistence context (identity map), але сам по собі світ JVM узагалі не зобов’язаний думати про базу даних.
Друга — таблична (database) ідентичність. Це первинний ключ рядка в таблиці. У PostgreSQL це зазвичай BIGINT, іноді UUID, іноді — у застарілих схемах — складений ключ. У реляційній моделі «хто ти такий» визначається саме первинним ключем. У рядка може змінитися name, status, price, але якщо його primary key не змінився — це той самий рядок.
Третя — предметна (business) ідентичність. Це те, що людина в предметній області вважає ідентифікатором: sku у товару, email у клієнта, orderNumber у замовлення. Це зручно, читабельно і часто справді унікально. Але предметна ідентичність не зобов’язана збігатися з primary key таблиці і зовсім не зобов’язана збігатися з тим, що зручно Hibernate для внутрішніх цілей.
Щоб не тримати це «на відчуттях», корисно один раз побачити все поруч:
| Рівень | «Хто ти?» | Приклад | Як перевіряємо |
|---|---|---|---|
| Java‑об’єкт | Один і той самий екземпляр у пам’яті | Product@0xA1 | == |
| База даних | Один і той самий рядок таблиці | product.id = 42 | primary key |
| Домен (бізнес) | Один і той самий товар «за змістом» | sku = "SKU-001" | унікальне бізнес‑поле |
Hibernate живе на стику цих світів і робить важливу річ: він намагається так організувати роботу, щоб таблична ідентичність всередині однієї транзакції відповідала об’єктній ідентичності. Тобто «рядок product(id=42)» має бути представлений одним managed‑об’єктом Product, а не двома різними new Product() з однаковим id. І вгадайте, яка деталь потрібна, щоб це стало можливим? Так — ідентифікатор.
3. Persistence Context і identity map
Якби Hibernate був бібліотекарем, то @Id був би читацьким квитком книги. Не назвою, не описом і не кольором обкладинки, а тим номером, за яким бібліотекар точно знає: «Ага, ось ця книга вже в мене на столі, вдруге не тягнемо зі складу». Persistence context (у термінах Hibernate Session) улаштований саме так: він зберігає managed‑сутності й дістає їх за ключем «тип сутності + ідентифікатор».
Це важливо проговорити: одного id=1 недостатньо, бо Product(id=1) і Customer(id=1) — різні сутності. Тому ключ, яким оперує ORM, логічно схожий на EntityKey(entityName, id). Звідси випливає базовий ефект, який ви вже бачили раніше: повторний find() по тому самому id всередині однієї транзакції повертає той самий Java‑об’єкт, без другого SQL‑запиту.
Схематично persistence context можна уявити так:
flowchart LR
%% Ключ: (тип сутності + id). За ним Hibernate знаходить managed-об’єкт у межах однієї транзакції.
subgraph PC["Persistence Context (Session / кеш першого рівня)"]
K1["EntityKey(Product, 42)"] --> P1["Product @0xA1 (managed)"]
K2["EntityKey(Customer, 7)"] --> C1["Customer @0xB3 (managed)"]
end
%% flush синхронізує зміни з Persistence Context до бази.
PC -->|flush| DB[(PostgreSQL)]
Зверніть увагу на важливу думку: persistence context «пам’ятає» сутність не за sku, не за іменем, не за всім набором полів, а саме за ідентифікатором.
Міні‑експеримент у нашому проєкті: один рядок — один об’єкт
У Commerce Persistence Lab такий експеримент зручно робити в сервісі (або в інтеграційному тесті), у транзакції:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductDebugService {
private final EntityManager em;
public ProductDebugService(EntityManager em) { this.em = em; }
@Transactional
public void checkIdentityMap(Long id) {
var p1 = em.find(Product.class, id);
var p2 = em.find(Product.class, id);
System.out.println(p1 == p2); // true
}
}
У SQL‑лозі ви побачите один SELECT на перший find(). На другому find() запиту не буде, бо Hibernate вже тримає managed‑об’єкт у persistence context. І це не «кеш заради прискорення», а фундаментальна гарантія консистентності: якби у вас було два різні об’єкти на один рядок, dirty checking і flush перетворилися б на хаос.
Чому проксі теж «тримається» на @Id
Ми вже торкалися getReference() і proxy‑об’єктів. Важливий нюанс: проксі може існувати без повного завантаження сутності, бо ідентифікатор відомий. Тобто Hibernate може сказати: «Ось тобі об’єкт‑замінник, я знаю його id, а решту підтягну, коли попросиш».
Такий код добре показує ідею (у транзакції, щоб не впасти через lazy‑межу):
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void referenceIdOnly(EntityManager em, Long id) {
// Отримуємо проксі: Hibernate знає ідентифікатор, але не зобов’язаний одразу робити SELECT.
var ref = em.getReference(Product.class, id);
// Важливо: читання id зазвичай не тригерить завантаження, бо id уже відомий ORM.
System.out.println(ref.getId()); // 42 (і часто без SELECT)
}
Ідентифікатор тут — якір. Він дозволяє Hibernate зберігати посилання на сутність і в зв’язках (FK), і в проксі, і в identity map. У цьому сенсі @Id — не «поле таблиці», а «перша цеглинка» всієї runtime‑моделі.
4. Момент появи ідентифікатора
На цьому місці зазвичай починається легка паніка: «Зачекайте… а хіба id не завжди зʼявляється одразу?» На жаль. Або на щастя, якщо ви любите складні системи. Момент появи ідентифікатора залежить від того, як саме він генерується: базою, Hibernate на боці Java або вашим кодом. У наступних лекціях ми детально порівняємо стратегії, а зараз нам важливо зафіксувати загальну модель без назв анотацій.
У сутності в стані transient часто немає id (або він є, але це окремий усвідомлений вибір). Далі ви викликаєте persist(): Hibernate реєструє сутність у persistence context. І ось тут можливі варіанти: іноді id можна отримати одразу, іноді — лише коли ORM реально зробить INSERT (зазвичай під час flush). Іноді ж id взагалі приходить «ззовні» до persist() — наприклад, якщо це assigned id.
Якщо уявити це як часову лінію, вийде приблизно так:
sequenceDiagram
%% Id може з’являтися в різні моменти: до persist(), після persist() або лише на INSERT.
participant App as Ваш код
participant PC as Persistence Context
participant DB as PostgreSQL
App->>App: new Product()
Note over App: "transient: id може бути null"
App->>PC: persist(product)
Note over PC: "сутність стала managed"
PC->>DB: flush
Note over DB: "INSERT: база може видати id під час вставки"
DB-->>PC: id (якщо генерує БД)
Чому нам узагалі важливий цей момент «коли id стає доступним»? Бо id — це не лише «щоб потім знайти». Іноді він потрібен вже зараз усередині бізнес‑операції. Наприклад, ви хочете зв’язати нову сутність з іншою, покласти її в мапу, залогувати «створено об’єкт X з id=…» або просто передати ідентифікатор далі по коду. І якщо ви подумки вважаєте, що id завжди відомий одразу, ви почнете писати код, який «зазвичай працює», а потім ламається при зміні стратегії генерації.
У Commerce Persistence Lab ми спеціально триматимемо SQL‑лог увімкненим і спостережуваним, щоб такі речі були не здогадками, а перевірюваними фактами. Це дуже дисциплінує: замість «здається, id зʼявляється тут» ви дивитеся на момент SQL і розумієте, чому id став ненульовим саме зараз.
5. Технічний id і бізнес‑поля
Іноді хочеться зробити красиво: раз sku унікальний, чому б не зробити його primary key? Людина ж за ним шукає товар, отже це «справжній ідентифікатор». Логіка звучить приємно, доки ви не починаєте жити з цією моделлю рік-два і не розумієте, що в цьому «приємно» є ціна. Hibernate‑курс якраз про ціну рішень, тому зупинимося на цьому спокійно й без релігії.
Технічний id (@Id) — це інструмент ORM і БД, який має бути стабільним, зручним для зв’язків (FK), не надто широким і не надто «розумним». Бізнес‑поле на кшталт sku — це частина доменної моделі. Воно читабельне, важливе для людей, часто бере участь в унікальності та інтеграціях. Але доменне життя складне: sku може мати формат, який бізнес захоче змінити; email може змінюватися; orderNumber може мати префікси, міграції, «переїзд на нову нумерацію з понеділка».
Тому дуже поширений і здоровий підхід: тримати технічний id як primary key, а бізнес‑поле — поруч, з унікальним обмеженням. На прикладі товару це виглядає максимально приземлено:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Product {
@Id
// Технічний id: його задача — бути стабільним PK і ключем identity map.
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false, unique = true)
// Бізнес-ідентифікатор: унікальний для домену, але не зобов’язаний бути PK.
private String sku;
}
Так, тут я вже показав SEQUENCE, але поки сприймайте це як «якийсь генератор». Порівняння стратегій буде в наступній лекції. У цій лекції важливо інше: id і sku розв’язують різні задачі. Якщо ви почнете ставитися до них як до одного й того самого, ви неминуче змішаєте і відповідальність, і архітектурні межі.
У репозиторії це зазвичай читається як «усередині живемо за id, а в бізнес‑сценаріях уміємо шукати за sku»:
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Зручний доменний вхід: пошук за бізнес-полем, при цьому PK лишається технічним.
Optional<Product> findBySku(String sku);
}
Зверніть увагу: наявність findBySku не скасовує сенсу @Id. Hibernate все одно будуватиме свою identity map за primary key. Просто тепер ви додали зручний вхід у доменну модель «по-людськи».
6. Id у коді: equals()/hashCode() і Set
Якби всі проблеми з id обмежувалися вибором колонки в БД, ми б закрили ноутбук і пішли пити чай. Але ORM цікавіший: ідентифікатор впливає на те, як ваші сутності поводяться в колекціях, як ви пишете equals()/hashCode() і навіть на те, які «невинні» операції раптово ламають стан об’єкта.
Ви вже бачили на 13‑му дні, що для entity не можна бездумно робити equality «за всіма полями» і що generated id — незручна опора для hashCode() до збереження. Сьогодні важливо пов’язати це із загальною моделлю: якщо id зʼявляється не одразу, то об’єкт може спочатку жити з id=null, потім отримати id, і якщо ваш hashCode() залежить від id — він зміниться посеред життя об’єкта. А HashSet і HashMap такого не люблять. Вони взагалі не підписувалися на серіал «об’єкт змінив хеш і пішов в іншу кишеню».
Ось міні‑приклад, який у реальному проєкті трапляється частіше, ніж хотілося б:
import java.util.HashSet;
// Уявіть, що нижче це виконується в транзакції, а em — доступний EntityManager.
var set = new HashSet<Product>();
var p = new Product(); // id поки null
set.add(p);
em.persist(p); // після persist/flush id може з’явитися і вплинути на hashCode()
System.out.println(set.contains(p)); // може стати false за невдалого hashCode()
Сенс не в тому, що persist() «псує колекції». Сенс у тому, що ви обрали таку стратегію equality, яка не сумісна з життєвим циклом сутності. І щойно ви починаєте сприймати ідентифікатор як частину runtime‑моделі, ці баги перестають бути містикою: ви буквально бачите, чому колекція поводиться саме так.
Ще один практичний зв’язок — DTO/мапінг. Помилка новачка: взяти вхідний DTO і «залити його в entity повністю», включно з id. У результаті ви самі собі організовуєте або спробу змінити primary key (що зазвичай заборонено й логічно безглуздо), або дивну семантику «я створив нову сутність, але вже з id». У нашому проєкті ми свідомо тримаємо стиль find + mutate, де id використовується як «знайти, кого змінювати», а не як «поле, яке ми редагуємо».
І ось тут хороша інженерна звичка: id — це річ, яку ви майже ніколи не «змінюєте». Ви її або отримуєте від генератора, або призначаєте один раз усвідомлено (assigned id), і далі ставитеся до неї як до ідентичності рядка.
7. Рамка вибору ідентифікатора: три питання
Щоб не тонути в анотаціях, корисно тримати просту рамку. Вона звучить майже як запитання на співбесіді, але в хорошому сенсі: відповідаючи на них, ви одразу розумієте, який клас рішень вам підходить, а який створить зайву складність. Зараз ми поставимо ці запитання без глибокого порівняння стратегій — воно буде в наступних лекціях дня.
Ось ці три запитання в «людському» вигляді:
| Запитання | Що ви з’ясовуєте | Чому це важливо для поведінки Hibernate |
|---|---|---|
| Хто створює значення id? | БД / Hibernate (Java-side) / ваш код | Від цього залежить, де знаходиться «джерело істини» |
| Коли id стає доступним? | До persist() / після persist() / лише на INSERT | Це впливає на insert‑потік, зв’язки і ваш код навколо сутності |
| Чи несе id предметний зміст? | так (business) / ні (technical) | Це впливає на читабельність моделі і на те, що можна/не можна змінювати |
Тими самими питаннями далі й користуватимемося. Спочатку важливо зрозуміти, як різні способи генерації technical PK змінюють момент появи id і форму INSERT‑потоку. Потім — навіщо поруч із цим PK живе business-id на кшталт sku або orderNumber. І лише потім — коли взагалі нечесно вдавати, що ідентичність уміщується в одне поле, бо рядок задає комбінація значень.
Якщо ви чесно відповіли на ці питання, ви вже на 80% захистилися від двох крайнощів. Перша крайність — «усюди один і той самий підхід, бо так звикли». Друга — «давайте зробимо красиво доменно, але потім здивуємося ціні для ORM і SQL». У нашому проєкті, як правило, у core‑сутностей буде технічний id, а бізнес‑поля на кшталт sku, email, orderNumber житимуть поруч і виражатимуть предметний зміст окремо. Але сьогодні ми поки що фіксуємо не конкретні відповіді, а саму звичку їх ставити.
8. Типові помилки під час роботи з ідентифікаторами
Помилки навколо ідентифікаторів найчастіше не виглядають як «помилка компіляції». Вони виглядають як дивні побічні ефекти: зайві запити, неочікувана поведінка колекцій, плутанина «чому Hibernate вважає це новою сутністю», проблеми з detached‑даними. Хороша новина в тому, що майже всі ці помилки лікуються одним і тим самим засобом: повернути ідентифікатор у runtime‑картину світу, а не ставитися до нього як до «колонки в таблиці».
Помилка №1: вважати, що @Id потрібен лише «щоб робити SELECT за ключем».
Коли ви так думаєте, ви втрачаєте з поля зору, що @Id — це ключ identity map у persistence context. Підсумок зазвичай такий: людина дивується, чому два об’єкти «з одним і тим самим id» в одній транзакції — це проблема, або чому повторний find() не робить SQL. Щойно ви починаєте мислити «тип сутності + id = ключ managed‑об’єкта», поведінка Hibernate перестає здаватися магією.
Помилка №2: змішувати технічний id і бізнес‑ідентифікацію, роблячи їх «однією сутністю».
Наприклад, робити sku primary key просто тому, що він унікальний. Іноді це виправдано, але частіше ви платите складністю міграцій і зв’язків, а виграєте лише гарний рядок у URL. Здорова модель зазвичай тримає технічний id для внутрішніх зв’язків і окреме унікальне бізнес‑поле для доменних сценаріїв.
Помилка №3: писати код так, ніби id зʼявляється завжди одразу після new.
Це призводить до логіки на кшталт «створю об’єкт, одразу виведу id, одразу покладу id в іншу структуру, одразу віддам назовні». За деяких стратегій генерації це просто не працює: id зʼявляється пізніше, і ви починаєте будувати костилі. Правильна звичка — не вгадувати, а розуміти, коли і чому id стає доступним (і перевіряти це за SQL‑логом).
Помилка №4: робити equals()/hashCode() на основі generated id і потім дивуватися поведінці HashSet.
Якщо id генерується не миттєво, то до збереження id=null, після збереження — уже число/UUID, і хеш змінюється. Колекції на хешах не зобов’язані «перебудовуватися й пробачати». Результат виглядає як «об’єкт зник із set», хоча ви тримаєте на нього посилання. Це не містика Hibernate, це чиста математика хеш‑таблиць, помножена на життєвий цикл entity.
Помилка №5: дозволяти зовнішньому DTO «керувати» полем id.
Часто це виглядає як «в апдейт прийшов id, я його просто сетнув у entity». У кращому разі ви отримаєте виняток, у гіршому — заплутаєте семантику new/existing і отримаєте дивну поведінку merge/dirty checking. Зрілий стиль — використовувати id як вказівник, кого змінювати, а не як дані, які змінюються.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ