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, она не обязана вас искать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ