JavaRush /Курсы /Hibernate deep-dive /Инварианты link entity

Инварианты link entity

Hibernate deep-dive
12 уровень , 3 лекция
Открыта

1. Инварианты link entity

Если сказать по‑простому, инвариант — это правило, которое должно оставаться истинным независимо от того, кто и откуда трогал ваш код: сервис, тест, миграция данных, будущий коллега, который «сделал маленький рефакторинг в пятницу вечером». В ORM-мире инварианты особенно важны, потому что вы работаете не напрямую с таблицей, а через граф объектов, который живёт в памяти, а в базу «проявляется» только на flush() или commit().

С link entity ситуация ещё интереснее: мы не просто храним «факт связи», мы вводим отдельный объект, который представляет одну строку таблицы связи. А раз это объект, у него появляется собственная идентичность, собственные поля и собственные правила корректности. Если этих правил нет, ProductCategoryAssignment превращается в технический мешок полей, который легко создать в поломанном состоянии и так же легко сохранить. И вот тогда вы получаете не доменную модель, а «ORM-театр абсурда».

То есть дальше мы не придумываем новый mapping, а усиливаем уже собранную модель правилами корректности.

В нашем Commerce Persistence Lab для ProductCategoryAssignment есть три ключевых инварианта, которые прямо следуют из смысла задачи:

Инвариант Человеческий смысл Что будет, если не держать
assignedAt != null У назначения категории есть момент времени Невозможно понять историю, сортировать по времени, делать аудит «когда назначили»
sortOrder >= 0 Порядок — это порядок, а не случайное отрицательное число Отрицательные значения превращают сортировку в магию и ломают UI/выдачу
product + category Одна категория не должна назначаться одному товару дважды Дубли в выдаче, странные удаления, неожиданные unique-ошибки на flush()

Важно заметить одну вещь: инвариант — это не обязательно «валидация UI». Это не про то, чтобы подсветить поле красным. Это про то, чтобы модель физически не могла жить в некорректном состоянии. И чем ближе вы подводите проверку к месту создания/изменения объекта, тем меньше у Hibernate шансов удивить вас неожиданным SQL и неожиданными исключениями.

2. Поле assignedAt

Когда мы впервые добавляем поле времени в модель, почти всегда происходит типичная начинающая ошибка: «давайте положим дату куда-нибудь в Product, ведь это же товару назначили категорию». Но назначение категории — не свойство товара “вообще”, и не свойство категории “вообще”. Это свойство пары: конкретный товар + конкретная категория. То есть это натуральное поле link entity.

Если провести аналогию без фанатизма: Product и Category — это два человека, а ProductCategoryAssignment — это запись о браке. Дата свадьбы — это свойство записи о браке, а не «свойство человека» и не «свойство второго человека». Точно так же assignedAt — это «дата появления связи».

В Java для этого удобно использовать Instant. Это момент времени в UTC, без «часового пояса в объекте», что снижает шанс потом устроить дискуссию на тему «почему у меня в логах один час, а в БД другой». В PostgreSQL обычно это ложится на timestamp with time zone (или на timestamp, если вы так настроили), а Hibernate спокойно умеет это маппить.

Минимальная идея маппинга выглядит так:

import jakarta.persistence.Column;
import java.time.Instant;

// Момент назначения категории — обязательный факт (null быть не должен).
@Column(name = "assigned_at", nullable = false, updatable = false)
// updatable = false: обычно «дату назначения» не переписывают при обычных апдейтах assignment.
private Instant assignedAt;

Обратите внимание на updatable = false. Мы тем самым говорим: «момент назначения — это факт, он не должен переписываться при обычных обновлениях assignment». Это не догма, но в большинстве доменных моделей это разумно: если вы хотите “переиграть историю”, это отдельная операция, а не побочный эффект случайного сеттера.

Теперь вопрос: где именно устанавливать assignedAt? Лучшее место — там, где рождается assignment: в конструкторе или фабричном методе.

import java.time.Instant;
import java.util.Objects;

public ProductCategoryAssignment(Product product, Category category, int sortOrder, Instant assignedAt) {
    // Валидируем инварианты в момент создания: объект не должен «родиться поломанным».
    this.product = Objects.requireNonNull(product);
    this.category = Objects.requireNonNull(category);
    this.assignedAt = Objects.requireNonNull(assignedAt);

    // sortOrder сохраняем как есть: его не-null гарантирует тип int,
    // а «неотрицательность» обычно проверяем отдельным guard-методом.
    this.sortOrder = sortOrder;
}

Да, это выглядит чуть более «строго», чем пустой конструктор + сеттеры. Зато вы не сможете случайно создать assignment без даты. А ещё это помогает отлавливать ошибки раньше, чем Hibernate дойдёт до flush() и скажет вам что-то в духе «NULL not allowed for column assigned_at», но уже через пять уровней stack trace.

Если вам хочется сделать жизнь сервису проще, можно инициализировать дату прямо в helper-методе у Product. В прошлой лекции мы делали assignCategory(...), и там как раз удобно поставить Instant.now().

import java.time.Instant;

public void assignCategory(Category category, int sortOrder) {
    // assignedAt задаём в момент создания связи (а не «где-то потом»).
    var assignment = new ProductCategoryAssignment(this, category, sortOrder, Instant.now());

    // Важно поддерживать обе стороны связи в памяти, чтобы граф объектов оставался консистентным.
    categoryAssignments.add(assignment);
    category.addAssignment(assignment);
}

Здесь есть инженерный компромисс: Instant.now() сложно контролировать в тестах и сложно «подменять». В учебном проекте это нормально, а в проде часто используют Clock, но сегодня мы не уходим в отдельный курс «как тестировать время». Нам важнее понять: assignedAt — часть связи, и она должна быть заполнена всегда.

3. Поле sortOrder

Пока у нас есть просто набор категорий, кажется, что порядок не важен: в базе это всё равно строки, Hibernate всё равно может вернуть их в любом порядке, а UI «пусть сортирует сам». Но как только вы строите backoffice-выдачу или хотите стабильное отображение, “порядок назначения” становится реальным бизнес‑требованием. Например, «первая категория — главная, остальные — вторичные».

И снова мы упираемся в ключевую мысль: sortOrder относится к паре Product + Category. У одной и той же категории порядок будет разным у разных товаров. Поэтому это поле не должно жить в Category. И точно не должно жить в Product, потому что Product не хранит «порядок для каждой категории» в одном поле — ему нужна таблица соответствий, а это и есть assignments.

С точки зрения маппинга в Hibernate — это обычное числовое поле. Если мы хотим, чтобы оно было обязательным, ставим nullable = false и задаём адекватное значение при создании.

import jakarta.persistence.Column;

// Порядок обязателен: «не задано» нам здесь не нужно, поэтому nullable = false и тип int.
@Column(name = "sort_order", nullable = false)
private int sortOrder;

Теперь «инвариантность» тут чаще всего начинается с простого: не разрешать отрицательные значения. Это звучит банально, но отрицательный sortOrder — это как отрицательное количество товара на складе. Формально число, но в реальности сигнал «что-то пошло не так».

public void changeSortOrder(int sortOrder) {
    // Не даём объекту уйти в некорректное состояние: отрицательный порядок запрещён.
    if (sortOrder < 0) {
        throw new IllegalArgumentException("sortOrder must be >= 0");
    }
    this.sortOrder = sortOrder;
}

Если вы сейчас подумали: «А можно сделать Integer, чтобы отличать “не задано” от “0”?» — можно, но тогда nullable=false превращается в “nullable=true”, и вы снова в стране “а почему у меня null в БД”. Для учебной модели проще и честнее держать int и считать 0 валидным значением.

Теперь важная практическая деталь: если sortOrder у вас есть, то вы обычно хотите, чтобы коллекция assignments у товара возвращалась уже в этом порядке. Это можно выразить прямо в mapping через @OrderBy. Это не про performance и не про fetching, это про смысл: «вот так мы считаем порядок».

import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;

@OneToMany(mappedBy = "product")
// Явно фиксируем семантику: порядок элементов коллекции соответствует sortOrder.
@OrderBy("sortOrder ASC")
private List<ProductCategoryAssignment> categoryAssignments = new ArrayList<>();

Да, Hibernate всё равно может сделать разные SQL в зависимости от сценария, но на уровне модели у вас появляется чёткое правило: порядок в коллекции соответствует sortOrder. А значит, меньше шансов, что кто-то через месяц случайно начнёт сортировать по id и удивляться, почему «главная категория вдруг стала третьей».

4. Уникальность product + category

Инвариант “уникальная пара” — это тот самый случай, когда без базы данных вы не сможете быть честными до конца. Вы можете сколько угодно проверять в Java, но если два параллельных запроса одновременно назначили одну и ту же категорию одному и тому же товару, то без уникального ограничения в БД вы получите дубли. А дубли в link table — это как два одинаковых ключа от одной двери: выглядят смешно, пока вы не пытаетесь ими пользоваться.

С точки зрения предметной логики правило простое: у товара не должно быть двух одинаковых назначений одной и той же категории. Это означает, что внутри Product мы должны уметь ответить на вопрос: “уже есть такая категория или нет?”.

Самый “честный” способ в рамках нашей модели — проверять по categoryId в коллекции assignments. Это можно сделать прямо в helper-методе, чтобы ошибка случалась в момент попытки создать связь, а не в момент flush().

public boolean hasCategory(Long categoryId) {
    // Проверяем по id категории: нам важно именно «та же самая категория», а не ссылка на объект.
    return categoryAssignments.stream()
            .anyMatch(a -> a.getCategory().getId().equals(categoryId));
}

И теперь в assignCategory(...) можно поставить guard.

public void assignCategory(Category category, int sortOrder) {
    // Ранний фейл: лучше понятная ошибка сейчас, чем constraint violation на flush().
    if (hasCategory(category.getId())) {
        throw new IllegalStateException("Category already assigned to product");
    }

    var assignment = new ProductCategoryAssignment(this, category, sortOrder, Instant.now());

    // Не забываем обе стороны: иначе граф объектов будет «полуправильным».
    categoryAssignments.add(assignment);
    category.addAssignment(assignment);
}

Это даёт два огромных плюса. Во-первых, ошибка будет понятной и ближе к причине. Во-вторых, вы не будете зависеть от того, когда Hibernate решит “реально” сделать INSERT (а он может сделать это на flush() и даже не там, где вы ожидаете — мы это видели в модуле про flush).

Но у этого подхода есть важное ограничение: чтобы hasCategory(...) работал, коллекция assignments должна быть доступна в памяти. Если она LAZY и вы вне транзакции, вы упадёте с ленивой инициализацией (и это нормально, мы OSIV не включаем). Сегодня мы не углубляемся в fetching, поэтому просто держим в голове: проверки “по коллекции” должны выполняться там, где коллекция доступна по выбранной вами стратегии загрузки.

Теперь вторая часть — “в базе”.

В JPA вы можете выразить уникальность пары через @Table(uniqueConstraints = ...). Это удобно: схема сама «документирует» правило, а Hibernate при генерации DDL (который мы в проекте не используем как основной способ) хотя бы знает об ограничении. Но главное — вы фиксируете правило в коде явно.

import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(
    name = "product_category_assignment",
    // Уникальность пары product_id + category_id: один товар не может иметь одну категорию дважды.
    uniqueConstraints = @UniqueConstraint(
        name = "uk_pca_product_category",
        columnNames = {"product_id", "category_id"}
    )
)
public class ProductCategoryAssignment {
}

И всё же, в нашем проекте схема управляется Flyway, а не DDL-auto. Поэтому “по-настоящему” уникальность должна быть выражена в миграции. Даже если вы добавили @UniqueConstraint, без реального ограничения в БД это остаётся благим пожеланием.

Мини-кусочек миграции (упрощённо) может выглядеть так:

-- Финальная линия обороны: база не позволит сохранить дубликаты пар product_id + category_id.
alter table product_category_assignment
add constraint uk_pca_product_category
unique (product_id, category_id);

И вот теперь правило становится железобетонным. Даже если кто-то создаст assignment напрямую через EntityManager.persist(...), забудет про helper-методы, обойдёт сервис и “просто сделает insert”, база скажет: «нет, так нельзя».

Тут важно правильное ожидание: уникальное ограничение не заменяет проверку в коде, оно её дополняет. Проверка в коде даёт хорошее сообщение и ранний фейл, а ограничение в базе — финальную гарантию корректности.

5. Где держать инварианты

Инварианты — это не один if и не одна аннотация. В хорошей модели они распределены по уровням. Не потому что “мы любим писать больше кода”, а потому что разные уровни решают разные проблемы: удобство разработчика, читаемость модели, корректность данных и стабильность при конкуренции.

Удобно думать об этом как о «защите в глубину». Можно представить это в виде простой схемы:

flowchart TD
    A[Сервис вызывает assignCategory] --> B[Helper-метод Product]
    B --> C[Проверка уникальности в памяти]
    B --> D[Создание ProductCategoryAssignment]
    D --> E[Конструктор валидирует поля]
    E --> F[Hibernate делает INSERT на flush/commit]
    F --> G[DB проверяет UNIQUE и NOT NULL]

В такой схеме каждый слой защищает вас от своей группы проблем.

Конструктор и методы сущности защищают от «создали объект в поломанном состоянии». Это особенно важно для link entity, потому что она часто создаётся “внутри” других сущностей, и ошибка должна быть видна сразу.

Helper-метод защищает от «сервис полез в коллекцию руками и забыл обновить вторую сторону». Мы это уже обсуждали, и сегодня лишь усиливаем: helper-метод — лучшее место, чтобы проверять уникальность “по коллекции” и сразу ставить assignedAt.

Аннотации вроде nullable=false защищают от “давайте всё же оставим поле пустым, а потом как-нибудь”. Hibernate, конечно, может допустить null в объекте, но на уровне схемы вы фиксируете: значение обязательно. Иначе у вас получится модель, где «в коде вроде бы всегда ставим, но иногда не ставим».

И наконец, база данных защищает от самого неприятного: от ситуаций, когда приложение ошиблось, и от ситуаций, когда два потока/процесса одновременно делают одно и то же. Даже если в учебном проекте вы не устраиваете реальную нагрузку, полезно держать эту мысль с первого дня: единственный гарант корректности уникальности — это ограничение в БД.

Если вы хотите добавить чуть больше “человечности” ошибкам ещё до базы, можно сделать проверку не только по коллекции, но и в репозитории, например exists... Это бывает полезно, если вы не хотите инициализировать коллекцию assignments ради проверки. Но важно не перепутать это с заменой DB constraint: это лишь ранняя диагностика.

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

public interface ProductCategoryAssignmentRepository
        extends JpaRepository<ProductCategoryAssignment, Long> {

    boolean existsByProductIdAndCategoryId(Long productId, Long categoryId);
}

И в сервисе:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void assign(Long productId, Long categoryId, int sortOrder) {
    if (assignmentRepo.existsByProductIdAndCategoryId(productId, categoryId)) {
        throw new IllegalStateException("Category already assigned");
    }
    // дальше: find product + category, затем product.assignCategory(...)
}

Я намеренно не показываю здесь весь сервисный метод целиком, потому что он быстро превратится в 30 строк, а нам важно поймать идею: инварианты — это не “где-то в контроллере”, это часть модели данных, и держать их лучше ближе к entity и к схеме.

6. Инварианты и flush: SQL и ошибки

На этом этапе важно связать всё с тем, что мы уже умеем делать из первых модулей курса: читать SQL-лог и помнить про flush(). Инварианты — это не только про красоту модели. Это ещё и про то, какие ошибки вы увидите в каком месте, и насколько легко вы их продиагностируете.

Когда вы создаёте новый ProductCategoryAssignment, на уровне Java всё выглядит вполне мирно. Вы добавили объект в коллекцию, обновили обе стороны связи, никаких SQL прямо сейчас может не быть. Hibernate любит откладывать реальную синхронизацию до flush().

Но как только наступает flush(), Hibernate попытается сделать INSERT в таблицу assignment. Упрощённо это выглядит так:

insert into product_category_assignment
(product_id, category_id, sort_order, assigned_at)
values (?, ?, ?, ?);

Если вы нарушили NOT NULL по assigned_at, база скажет что-то вроде «null value in column … violates not-null constraint». Если вы нарушили уникальность пары, PostgreSQL выдаст классическое:

ERROR: duplicate key value violates unique constraint "uk_pca_product_category"

А на стороне Hibernate это обычно поднимется как ConstraintViolationException (плюс обёртки Spring, если вы в Spring-контексте). И вот тут начинается “магия” для новичка: «я же удалял/добавлял в коллекцию 20 строк назад, почему ошибка здесь?». Ответ тот же, что и в лекциях про flush: потому что SQL случился здесь.

Из этого вытекает практический вывод для нашего проекта: мы стараемся, чтобы самые очевидные инварианты (типа “не дублируйся” и “assignedAt обязателен”) проверялись на уровне модели раньше, чем мы дошли до SQL. Тогда SQL-ошибка остаётся “последней линией обороны”, а не основной диагностикой для разработчика.

7. Типичные ошибки при работе с link entity

Ошибка №1: хранить assignedAt или sortOrder не в link entity, а «где-нибудь рядом».
Самая частая логическая поломка — попытаться засунуть поле связи в одну из сущностей. В результате вы или теряете смысл (потому что поле относится к паре), или начинаете городить мапы и дополнительные структуры “на стороне”, а потом удивляетесь, почему Hibernate делает странный SQL.

Ошибка №2: разрешать созданию assignment “пустым”, а потом «дозаполнять».
Когда ProductCategoryAssignment создаётся без assignedAt или без валидного sortOrder, вы формируете объект в некорректном состоянии. Hibernate не обязан ловить это раньше времени, и вы получите ошибку позже, на flush(), уже в другом месте кода. Конструктор/фабрика с requireNonNull() обычно спасают от этой истории.

Ошибка №3: надеяться на проверку в Java и не ставить уникальное ограничение в БД.
Проверка hasCategory(...) в helper-методе — отличная вещь, но она не гарантирует корректность данных в базе. Достаточно одного параллельного выполнения или одного места, где assignment создаётся не через helper-метод, и вы получаете дубли. Уникальный constraint в таблице — это не «оптимизация», это часть модели.

Ошибка №4: ставить CascadeType.ALL на связи Product -> Category (или пытаться каскадировать через link entity до Category).
Категория в нашем домене — справочник, который живёт отдельно от одного товара. Товар может управлять своими assignments, но не должен “владеть жизнью” категории. Если вы случайно настроите каскад так, что удаление assignment потянет за собой удаление Category, вы получите очень дорогую и очень неприятную ошибку: исчезновение общих справочных данных.

Ошибка №5: думать, что SQL-ошибка «должна быть сразу», и не помнить про flush().
Если вы добавили дубликат и ожидаете, что исключение вылетит на строке categoryAssignments.add(...), вы будете разочарованы. ORM работает через unit of work: SQL может уйти позже. Поэтому либо проверяйте инварианты в момент изменения модели, либо держите в голове, что ошибки целостности проявятся на flush()/commit(), и умейте читать SQL-лог.

1
Задача
Hibernate deep-dive, 12 уровень, 3 лекция
Недоступна
Инварианты `sortOrder` и `assignedAt` в конструкторе assignment
Инварианты `sortOrder` и `assignedAt` в конструкторе assignment
1
Задача
Hibernate deep-dive, 12 уровень, 3 лекция
Недоступна
Уникальная пара `teacher + subject`
Уникальная пара `teacher + subject`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ