1. Business-id поруч із @Id
Щойно порівняння IDENTITY, SEQUENCE і UUID вляглося, постає інше питання: якщо в сутності вже є sku, email або orderNumber, навіщо їй ще й технічний @Id? Якщо дивитися на сутності очима Hibernate, то @Id — це «якір», який прив’язує Java-обʼєкт до конкретного рядка таблиці. А якщо дивитися очима бізнесу, то id=42 — це приблизно як «людина №42 із натовпу»; жити можна, але спілкуватися незручно. У реальному проєкті зазвичай є ідентифікатори, якими користуються люди: sku, email, orderNumber.
Подумайте про нашу лабораторію Commerce Persistence Lab. У каталозі товарів навряд чи хтось скаже: «Покажіть товар із id=17». Скоріше скажуть: «Покажіть товар за SKU SKU-2026-0001». Клієнта майже завжди шукають за email. Замовлення в переписці й логах згадують за orderNumber, а не за технічним ключем таблиці.
Проблема в тому, що якщо ми залишимо ці поля просто «звичайними колонками», то в коді легко починається змішування смислів. Десь ми використовуємо id, десь sku, десь ще й equals() раптово починає «підхоплювати» то одне, то інше. Виходить ефект «усе унікальне — це одне й те саме», а це як вважати паспорт, ІПН і номер телефону одним ідентифікатором людини (спойлер: бухгалтерія з цим не погодиться).
@NaturalId — це спосіб сказати Hibernate (і майбутньому читачеві коду): «Ось це поле — не просто зручний фільтр, це справжній предметний ідентифікатор. Він унікальний, обовʼязковий і стабільний настільки, щоб на нього можна було спиратися».
2. @NaturalId і unique=true
На перший погляд може здатися, що @NaturalId — це «декоративна наліпка» поверх @Column(unique = true). Але сенс глибший. unique=true (і тим більше унікальне обмеження у міграції Flyway) каже базі даних: «Не пускай дублікати». Це про цілісність даних на рівні таблиці. А @NaturalId — це про зміст ідентифікатора на рівні ORM-моделі та поведінки Hibernate.
Можна уявити це як два різні шари однієї домовленості. База даних відповідає за те, щоб не зʼявилося два товари з однаковим SKU. Hibernate відповідає за те, щоб у рантаймі у вас було зрозуміле місце для пошуку за бізнес-ключем і щоб він міг ставитися до цього ключа як до особливого, а не як до випадкового рядка.
Важливий нюанс для нашого курсу: схема у нас керується через Flyway, а не через ddl-auto. Тому лише анотацій недостатньо, навіть якщо ви поставите unique=true. Анотація — це хороша документація й підказка ORM, але «залізобетон» має бути в міграції: унікальність SKU, email і номера замовлення має існувати в базі реально, а не «в голові розробника».
Корисно тримати в голові таку мінітаблицю (без релігійних воєн, просто зі здоровим глуздом):
| Що ви зробили | Що це гарантує | Хто «контролер» | Навіщо це в проєкті |
|---|---|---|---|
| Унікальне обмеження у Flyway | Дублікатів у БД не буде | PostgreSQL | Коректність даних та інваріанти |
| @Column(unique = true) | Документація + спроба DDL (якби ви його вмикали) | JPA/Hibernate | Читабельність моделі, підказка IDE |
| @NaturalId | «Це business-id, можна на нього спиратися» | Hibernate | Чітка модель ідентифікації, зручні lookup-сценарії |
І ще одна думка: @NaturalId не замінює primary key. Він не перетворює ваші зовнішні ключі на «FK на SKU». Він не скасовує @Id. Він про інше.
3. Приклад: Product.sku як natural id
Коли ми додаємо natural id, важливо не влаштувати «нову сутність заради анотації», а акуратно підсилити вже наявну модель. У Commerce Persistence Lab товар (Product) — ідеальний кандидат: SKU — природний предметний ідентифікатор, який ми використовуємо в пошуку, імпорті, інтеграціях і просто в розмовах людей (так, люди теж частина системи — несподівано).
Нижче — мінімальний фрагмент Product, де id залишається технічним якорем, а sku стає бізнес-ідентифікатором. Зверніть увагу: @NaturalId — це анотація Hibernate, не JPA.
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;
@Entity
public class Product {
// Технічний ключ (primary key): потрібен Hibernate для identity map і роботи persistence context
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// Бізнес-ідентифікатор товару: унікальний і обов’язковий, за ним зручно шукати й логувати
// Важливо: @NaturalId — анотація Hibernate, не JPA
@NaturalId
@Column(nullable = false, unique = true)
private String sku;
}
Тут GenerationType.SEQUENCE показано узагальнено. У проєкті sequence все одно фіксується явно через @SequenceGenerator, а сама sequence живе у Flyway-міграції. Для цієї теми важливий лише сам принцип: sku живе поруч із technical PK, а не замінює його.
Що тут важливо зрозуміти на рівні «мислення рантаймом»:
1. id потрібен Hibernate для identity map у persistence context. Саме за id Hibernate гарантує: «всередині однієї сесії один рядок таблиці = один managed-обʼєкт». Це фундамент, на якому стоять dirty checking, merge і вся інша магія, яку ми вже розклали на молекули в перших модулях.
2. sku потрібен вам і бізнесу як «зовнішнє імʼя» товару. Його приємно логувати, за ним зручно шукати, його зручно передавати в DTO і назад — у міру, звісно, без фанатизму. А @NaturalId робить це «зовнішнє імʼя» офіційним громадянином нашої ORM-моделі, а не просто випадковим рядком.
4. Natural id: Customer.email і mutability
Email клієнта — класичний приклад бізнес-ідентифікатора. Але він одразу ставить філософське питання: чи можна змінювати email? У звичайному житті люди змінюють пошту, і бізнес часто це допускає. З іншого боку, якщо ви зробили email ідентифікатором, то фактично сказали: «це стабільна вісь ідентичності». А email… ну, він не завжди стабільний, особливо якщо клієнт у 2012 році зареєструвався на mail.ru, а в 2026-му вирішив, що час дорослішати.
Hibernate дозволяє позначити natural id як змінюваний. Для цього є mutable = true. Але це не безплатна опція, а ускладнення: Hibernate всередині сесії має підтримувати відповідність natural id ↔ primary key і вміти коректно оновлювати її, якщо ви змінюєте значення.
Мінімальний приклад:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;
@Entity
public class Customer {
// Технічний ключ (primary key): потрібен для ORM-ідентичності всередині persistence context
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// Бізнес-ідентифікатор клієнта, який бізнес може дозволити змінювати (mutable=true)
// Ціна: Hibernate має підтримувати актуальність зв’язку natural id ↔ primary key
@NaturalId(mutable = true)
@Column(nullable = false, unique = true)
private String email;
}
Як із цим жити в проєкті? Спокійно, якщо у вас є чітке правило. Зазвичай воно таке: natural id має бути унікальним і в ідеалі стабільним. Якщо він змінюваний, ви приймаєте технічну ціну й зобов’язуєтеся змінювати його акуратно — не «скрізь і часто», а як керовану бізнес-операцію.
Якщо хочеться простоти й передбачуваності, є хороший компроміс: вважати email важливим бізнес-полем, робити його унікальним і non-null, але не робити його natural id, якщо бізнес допускає часті зміни. А як natural id вибрати щось стабільніше, наприклад номер клієнта. У нашому навчальному проєкті ми можемо допустити mutable=true, щоб наочно обговорити ціну, але в реальному продукті це рішення потрібно захищати аргументами, а не ентузіазмом.
5. Пошук за natural id у Spring Data
Коли ми говоримо «Hibernate підтримує natural id», у новачка часто виникає очікування: «зараз я напишу @NaturalId, і репозиторій сам почне робити щось магічне». Ні, магія буде, але не такого типу. У нашій архітектурі (Spring Data JPA + сервісний шар) найзвичніший і найчитабельніший шлях — це звичайні репозиторні методи пошуку за бізнес-полем.
І це нормально. @NaturalId тут працює як смисловий маркер: поле — унікальний предметний ідентифікатор. А конкретний запит ви оформлюєте як метод репозиторія.
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Пошук за бізнес-ідентифікатором (SKU), а не за технічним @Id
Optional<Product> findBySku(String sku);
}
Те саме для клієнтів і замовлень:
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// Пошук клієнта за email як за бізнес-полем (у нашій моделі воно унікальне)
Optional<Customer> findByEmail(String email);
}
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Пошук замовлення за orderNumber: так простіше читати сервісний код і логи
Optional<PurchaseOrder> findByOrderNumber(String orderNumber);
}
Чому це круто саме в deep-dive курсі з Hibernate? Бо це робить код «читабельним для людини», а не лише для ORM. Коли ви в сервісі бачите findBySku("SKU-..."), вам одразу ясно, що відбувається. А коли ви бачите findById(17L), ви починаєте гадати: «17 — це що? product id? customer id? order id? номер аудитора в таблиці?»
Natural id допомагає вибудувати модель так, щоб код говорив мовою предметної області, а не мовою «таблиця-рядок-колонка».
6. Business-id lookup і транзакції
Навіть най«миліший» метод findBySku() може стати джерелом дивної поведінки, якщо ви використовуєте його хаотично. Ми вже знаємо, що транзакція — це межа unit of work, а persistence context живе всередині неї. Тому lookup за бізнес-ідентифікатором логічніше робити там само, де ви далі працюватимете з managed-обʼєктом: у сервісному методі, у нормальній транзакції, а не «десь у контролері, бо так швидше написати».
Приклад «читабельної» сервісної операції читання товару за SKU:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductQueryService {
private final ProductRepository productRepository;
public ProductQueryService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// readOnly=true: ми читаємо сутність, а persistence context потрібен для коректної роботи lazy-зв’язків
@Transactional(readOnly = true)
public Product getBySku(String sku) {
return productRepository.findBySku(sku)
// Явно падаємо, якщо SKU не знайдено: це краще, ніж повернути null і отримати NPE пізніше
.orElseThrow(() -> new IllegalArgumentException("Невідомий SKU: " + sku));
}
}
Чому це добре пов’язано з тим, що ми вже проходили:
Всередині @Transactional(readOnly = true) Hibernate створює нормальний persistence context. Якщо далі по ланцюжку ви звертаєтесь до lazy-зв’язків (там, де це доречно), ви не отримаєте LazyInitializationException лише тому, що «ну я ж десь там раніше завантажив товар». А якщо ви будете робити lookup в одному місці, а працювати із сутністю в іншому, за межами транзакції, то «чому воно падає» знову стане магією.
Natural id не скасовує базові дисципліни ORM. Він просто додає вам людський ключ, через який ви входите в сценарій.
flowchart TD
In["SKU надійшов із зовнішнього світу"] --> Svc["@Transactional сервіс"]
Svc --> Repo["ProductRepository.findBySku(sku)"]
Repo --> PC["Persistence Context
керований Product"]
PC --> Use["Далі сценарій: читання/перевірки/логіка"]
7. Natural id і equals() / hashCode()
На рівні ідентичності сутності ми домовилися: у entity identity все складніше, ніж у звичайного Java-обʼєкта. Generated id зʼявляється не одразу, проксі підміняють класи, а Set уміє «губити» елементи, якщо ви вибрали неправильну опору для hashCode(). І тут natural id може стати порятунком — але лише якщо він справді стабільний.
Ідея проста: якщо в Product SKU задається під час створення і далі не змінюється, то SKU — чудовий кандидат для equality. Він зʼявляється до persist(), його можна використовувати ще на transient-стані, і він не залежить від стратегії генерації @Id. Це часто робить модель значно менш крихкою в колекціях.
Мінімальний варіант equals()/hashCode() для Product на базі sku (без Lombok і без автогенераторів, бо ми їх свідомо уникаємо):
import org.hibernate.Hibernate;
import java.util.Objects;
@Override
public boolean equals(Object o) {
// Швидка перевірка на посилальну рівність
if (this == o) return true;
// Захист від проксі: порівнюємо "реальні" класи, а не getClass()
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Product that = (Product) o;
// Рівність за SKU має сенс лише якщо SKU заданий (не null)
return sku != null && sku.equals(that.sku);
}
@Override
public int hashCode() {
// Хешуємо те саме поле, що й у equals(): інакше колекції почнуть "чудити"
return Objects.hashCode(sku);
}
Зверніть увагу на дві дрібниці, які на практиці економлять години життя.
Перша — Hibernate.getClass(this) замість getClass(). Це якраз захист від проксі: Hibernate може повернути вам обʼєкт-підклас, і getClass() несподівано стане різним, хоча сутність логічно та сама.
Друга — ми не порівнюємо sku як «може бути null» бездумно. Якщо sku раптом виявиться null, рівність перетворюється на дивний атракціон. Тому в доменній моделі SKU має бути non-null за контрактом. І так, це означає і nullable=false, і міграційне обмеження.
Якщо natural id mutable (наприклад, email можна змінювати), то робити equals()/hashCode() на його основі — погана ідея, бо обʼєкт може опинитися в Set, ви зміните email, hashCode зміниться, і колекція скаже: «Я тебе не знаю». Це не жарт, це класика. У цей момент починаються ті самі баги, де «в памʼяті елемент є, а remove() його не видаляє». Захоплива гра, але без призів.
Тож правило просте: immutable natural id іноді можна використовувати як основу equality; mutable — краще не треба. І це чудово перегукується з тим, чому mutable natural id взагалі потрібно вводити обережно.
8. Типові помилки під час роботи з @NaturalId
Помилка №1: вважати, що @NaturalId автоматично робить поле primary key.
Це часте очікування після перших зіткнень із терміном “id”. Але natural id не замінює @Id і не перетворює ваші зв’язки на “FK на SKU”. Якщо ви почнете проєктувати асоціації так, ніби business-id — це основний ключ бази, ви швидко прийдете до незручних FK, складних міграцій і дуже крихкої моделі.
Помилка №2: поставити @NaturalId на поле, яке може бути null.
Natural id без non-null — як паспорт без номера: ніби папірець є, але ідентифікувати за ним не можна. У Hibernate це виливається в дивні сценарії, а в бізнесі — у неможливість стабільно посилатися на сутність. Якщо поле — natural id, воно має бути обовʼязковим, і це має бути видно і в Java-коді, і в міграції.
Помилка №3: оголосити natural id, але забути про унікальність на рівні бази.
У навчальних проєктах іноді хочеться «просто позначити анотацією». Але реальність така: якщо унікальність не захищена обмеженням, дублікати зʼявляться. Не тому що «погані розробники», а тому що життя. Міграція, імпорт, паралельні транзакції, ручні правки — і все, два SKU. Hibernate не має гадати, який із них «справжній».
Помилка №4: зробити natural id змінюваним «про всяк випадок».
mutable=true — це не про зручність, а про ускладнення. Якщо бізнес не вимагає змінювати поле-ідентифікатор, краще вважати його immutable. Інакше ви почнете жити у світі, де один і той самий клієнт «вчора був a@b.com, сьогодні c@d.com», і вам постійно доведеться думати, що саме означає «знайти за email» в історичних даних і логах.
Помилка №5: використовувати mutable natural id як основу equals() / hashCode().
Це той самий шлях до багів із колекціями, orphan removal і «чому воно зникло». Якщо поле може змінюватися, воно не має бути опорою для хешування. Інакше ви буквально змінюєте «координати обʼєкта» в колекції, а колекція — це не GPS, вона не зобов’язана вас шукати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ