JavaRush /Курсы /Hibernate deep-dive /@NaturalId как busine...

@NaturalId как business-идентификатор

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 (и тем более уникальный constraint в миграции Flyway) говорит базе данных: «Не пускай дубли». Это про целостность данных на уровне таблицы. А @NaturalId — это про смысл идентификатора на уровне ORM-модели и поведения Hibernate.

Можно представить это как два разных слоя одного соглашения. База данных отвечает за то, чтобы не появилось два товара с одинаковым SKU. Hibernate отвечает за то, чтобы у вас в runtime было понятное место для «поиска по бизнес-ключу» и чтобы он мог относиться к этому ключу как к особому, а не как к случайной строке.

Важный нюанс для нашего курса: схема у нас управляется через Flyway, а не через ddl-auto. Поэтому только аннотаций недостаточно, даже если вы поставите unique=true. Аннотация — это хорошая документация и подсказка ORM, но «железобетон» должен быть в миграции: уникальность SKU, email и номера заказа должна существовать в базе реально, а не «в голове разработчика».

Полезно держать в голове такую мини-таблицу (без религиозных войн, просто здравый смысл):

Что вы сделали Что это гарантирует Кто «контролёр» Зачем это в проекте
Уникальный constraint в 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 становится business-идентификатором. Обратите внимание: @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;

    // Business-идентификатор товара: уникальный и обязательный, по нему удобно искать и логировать
    // Важно: @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 клиента — классический пример business-идентификатора. Но он сразу приносит философский спор: можно ли менять 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;

    // Business-идентификатор клиента, который бизнес может разрешить менять (mutable=true)
    // Цена: Hibernate должен поддерживать актуальность связи natural id ↔ primary key
    @NaturalId(mutable = true)
    @Column(nullable = false, unique = true)
    private String email;
}

Как с этим жить в проекте? Спокойно, если у вас есть чёткое правило. И правило обычно такое: natural id должен быть уникальным и в идеале стабильным. Если он изменяемый, то вы принимаете техническую цену и обязуетесь менять его аккуратно — не «везде и часто», а как управляемую бизнес-операцию.

Если хочется простоты и предсказуемости, есть хороший компромисс: считать email важным business-полем, делать его уникальным и non-null, но не делать его natural id, если бизнес допускает частые изменения. А в качестве natural id выбрать что-то стабильнее (например, «номер клиента»). В нашем учебном проекте мы можем допустить mutable=true, чтобы наглядно обсудить цену, но в реальном продукте это решение нужно защищать аргументами, а не энтузиазмом.

5. Поиск по natural id в Spring Data

Когда мы говорим «Hibernate поддерживает natural id», у новичка часто возникает ожидание: «сейчас я напишу @NaturalId, и репозиторий сам начнёт делать что-то магическое». Нет, магия будет, но не такого сорта. В нашей архитектуре (Spring Data JPA + сервисный слой) самый привычный и читаемый путь — это обычные репозиторные методы поиска по business-полю.

И это нормально. @NaturalId здесь работает как смысловой маркер: поле — уникальный предметный идентификатор. А конкретный запрос вы оформляете как метод репозитория.

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Поиск по business-идентификатору (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 по business-идентификатору логичнее делать там же, где вы дальше будете работать с 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("Unknown sku: " + sku));
    }
}

Почему это хорошо связано с тем, что мы уже проходили:

Внутри @Transactional(readOnly = true) Hibernate создаёт нормальный persistence context. Если дальше по цепочке вы обращаетесь к lazy-связям (там, где это уместно), вы не словите LazyInitializationException просто потому, что «ну я же где-то там раньше загрузил товар». А если вы будете делать lookup в одном месте, а работать с сущностью в другом (за пределами транзакции), то «почему оно падает» снова станет магией.

Natural id не отменяет базовые дисциплины ORM. Он просто добавляет вам человеческий ключ, через который вы входите в сценарий.

flowchart TD
    In["Пришёл sku из внешнего мира"] --> Svc["@Transactional service"]
    Svc --> Repo["ProductRepository.findBySku(sku)"]
    Repo --> PC["Persistence Context
managed Product"] PC --> Use["Дальше сценарий: чтение/проверки/логика"]

7. Natural id и equals() / hashCode()

На дне про equality мы договорились: у 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, и миграционный constraint.

Если 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, но забыть про уникальность на уровне базы.
В учебных проектах иногда хочется «просто аннотацией пометить». Но реальность такая: если уникальность не защищена constraint’ом, дубликаты появятся. Не потому что “плохие разработчики”, а потому что жизнь. Миграция, импорт, параллельные транзакции, ручные правки — и всё, два SKU. Hibernate не должен гадать, какой из них “настоящий”.

Ошибка №4: сделать natural id изменяемым “на всякий случай”.
mutable=true — это не про удобство, а про усложнение. Если бизнес не требует менять поле-идентификатор, лучше считать его immutable. Иначе вы начнёте жить в мире, где один и тот же клиент «вчера был a@b.com, сегодня c@d.com», и вам постоянно придётся думать, что именно означает “найти по email” в исторических данных и логах.

Ошибка №5: использовать mutable natural id как основу equals() / hashCode().
Это тот самый путь к багам с коллекциями, orphan removal и “почему оно исчезло”. Если поле может меняться, оно не должно быть опорой для хеширования. Иначе вы буквально меняете «координаты объекта» в коллекции, а коллекция — это не GPS, она не обязана вас искать.

1
Задача
Hibernate deep-dive, 15 уровень, 2 лекция
Недоступна
Product по sku как natural id
Product по sku как natural id
1
Задача
Hibernate deep-dive, 15 уровень, 2 лекция
Недоступна
Изменяемый natural id для email
Изменяемый natural id для email
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ