JavaRush /Курси /Hibernate deep-dive /@NaturalId як бізнес-...

@NaturalId як бізнес-ідентифікатор

Hibernate deep-dive
Рівень 15 , Лекція 2
Відкрита

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, вона не зобов’язана вас шукати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ