1. Главный критерий — SQL
Теперь нам не нужно заново доказывать, что @ManyToMany прячет реальную строку связи. Это уже видно. Здесь спор решается не количеством аннотаций, а тем, какой SQL уйдёт в PostgreSQL после flush().
Чтобы сравнение было честным, нам нужен одинаковый эксперимент: одна транзакция, один сценарий (например, удалить одну категорию у товара), принудительный flush() — и мы смотрим на SQL‑след. В этом месте Hibernate становится похож на кассовый аппарат: можно спорить, «как правильно было задумано», но чек покажет, что реально произошло.
Небольшая схема мышления, которую стоит держать в голове:
flowchart TD
A[Java-код: меняем коллекцию] --> B[Hibernate: dirty checking коллекций]
B --> C["flush()"]
C --> D[SQL: INSERT/DELETE/UPDATE]
D --> E[PostgreSQL: реальная запись в таблицу]
Мини‑пример, который мы будем повторять сегодня (он не про «как делать идеально», он про «как увидеть SQL сейчас»):
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoFlush(EntityManager entityManager) {
// ... меняем связи/поля в managed-сущностях (внутри транзакции)
// Важно: до flush() Hibernate может копить изменения в persistence context и не отправлять SQL сразу.
entityManager.flush(); // принудительно отправляем SQL, чтобы увидеть его в логах
}
Ключевая мысль: если вы не заставили Hibernate сделать flush(), вы иногда сравниваете «мечты» вместо реальности. А в ORM мечты — плохой источник данных.
2. Наивный @ManyToMany: удаление и join‑таблица
Давайте сначала посмотрим на сценарий «как было бы в наивной модели». У нас есть Product, у него есть список категорий, и мы делаем remove().
Мини‑маппинг, который выглядит невинно (и именно поэтому опасен — он слишком убедительно выглядит «простым»):
import jakarta.persistence.Entity;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Product {
// Наивная many-to-many: join-таблица существует в БД,
// но в Java-модели у строки связи нет отдельной сущности/идентичности.
@ManyToMany
@JoinTable(name = "product_category_assignment")
private List<Category> categories = new ArrayList<>();
}
Ниже я намеренно использую короткие лабораторные фрагменты. Предполагаем, что Product и Category уже managed в текущей транзакции: так проще изолировать именно SQL-профиль связи, не смешивая его с кодом загрузки сущностей.
И сервисный код, который снаружи тоже выглядит «правильно»:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void removeCategoryNaive(Product product, Category category, EntityManager em) {
// На уровне Java: «просто убрали элемент из коллекции».
product.getCategories().remove(category);
// Принудительно просим Hibernate материализовать изменения в SQL,
// чтобы увидеть реальное поведение (а не гадать).
em.flush();
}
На уровне Java это выглядит как удаление одной связи. Но для @ManyToMany Hibernate управляет join‑таблицей как служебной коллекцией и потому нередко выбирает грубую синхронизацию: удалить все строки для товара и вставить обратно те, что остались.
Типичный SQL‑след (упрощённо, чтобы было читаемо):
-- Hibernate может сделать так:
delete from product_category_assignment
where product_id = ?;
insert into product_category_assignment(product_id, category_id)
values (?, ?);
insert into product_category_assignment(product_id, category_id)
values (?, ?);
Вы удалили одну категорию, а SQL мог переписать весь набор связей товара. Даже если в конкретном кейсе Hibernate окажется точнее, проблема модели никуда не денется: у строки связи всё ещё нет собственного объекта, своих полей и понятного lifecycle.
3. Link entity: точечное удаление DELETE
Теперь повторим тот же сценарий, но уже в «взрослой» модели: Product содержит не categories, а categoryAssignments, и удаление связи — это удаление конкретного assignment‑объекта.
Мини‑маппинг со стороны товара:
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Product {
// Product управляет жизненным циклом assignment-ов:
// - cascade = ALL: изменения assignment-объектов проходят вместе с продуктом
// - orphanRemoval = true: удалили assignment из коллекции -> удалили строку в БД
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductCategoryAssignment> categoryAssignments = new ArrayList<>();
}
А вот ключевая разница: у нас есть сущность связи, то есть Hibernate видит строку таблицы как объект со своим id.
Ниже — сокращённый вид того же ProductCategoryAssignment. Поля sortOrder, assignedAt, ограничения и helper-методы никуда не делись; для самого DELETE нам сейчас важны только owning-ссылки и идентичность записи.
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
@Entity
public class ProductCategoryAssignment {
// Отдельный идентификатор связи — это и есть «контроль»:
// строка в таблице становится полноценным объектом.
@Id
@GeneratedValue
private Long id;
// Ниже важны именно owning-ссылки;
// остальные поля link entity здесь просто не участвуют в самом DELETE.
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
}
Теперь сервисный код не «ковыряет коллекцию категорий», а вызывает метод модели, который удаляет assignment:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void removeCategoryExplicit(Product product, Long categoryId, EntityManager em) {
// В доменной модели удаляем именно связь (assignment), а не Category как справочник.
product.unassignCategory(categoryId);
// Сразу материализуем изменения, чтобы проверить SQL-след в логах.
em.flush();
}
Что видит Hibernate на flush()? Он видит: был managed‑объект ProductCategoryAssignment в коллекции, теперь его нет, а orphanRemoval = true означает: «удалить orphan из БД». И SQL становится точечным и предсказуемым.
Упрощённый SQL‑след выглядит так:
delete from product_category_assignment
where id = ?;
Иногда перед этим будет SELECT (например, чтобы инициализировать коллекцию assignments, если она lazy и ещё не загружена). Но ключевое отличие всё равно сохраняется: операция удаления не приводит к пересборке всей связи, и вы видите ровно ту механику, которую ожидали как инженер: «удаляем одну запись связи».
И вот здесь появляется ощущение контроля. Не «Hibernate вроде бы как‑то удалил», а «я понимаю, какая сущность удаляется и почему именно такой SQL».
4. Добавление категории: понятный INSERT
Удаление — самая драматичная часть, но добавление тоже очень показательно. В @ManyToMany добавление категории — это просто «вставить пару FK в join‑таблицу». И всё. Никаких данных связи у вас нет, и добавить их нельзя (если только не начать сложные трюки, которые в большинстве проектов заканчиваются грустью и техдолгом).
В link entity добавление категории — это создание assignment‑сущности. И это кажется «больше кода», но на самом деле это больше смысла.
Мини‑пример helper‑метода на товаре (коротко, без всей обвязки):
import java.time.Instant;
public void assignCategory(Category category, int sortOrder) {
// Создаём объект связи (assignment), который может хранить атрибуты связи:
// порядок, дату назначения и т.д.
ProductCategoryAssignment a =
new ProductCategoryAssignment(this, category, sortOrder, Instant.now());
// Держим консистентность графа в памяти:
// добавляем assignment и в коллекцию продукта, и (если нужно) в коллекцию категории.
categoryAssignments.add(a);
category.addAssignment(a);
}
Что это даёт в SQL? Теперь INSERT в таблицу связи содержит не только product_id и category_id, но и поля связи:
insert into product_category_assignment
(id, product_id, category_id, sort_order, assigned_at)
values (?, ?, ?, ?, ?);
Да, SQL стал «шире». Но в этом нет ничего плохого. Наоборот: теперь SQL честно отражает модель, а модель честно отражает предметную область. И у вас появляется возможность менять sortOrder без плясок с бубном и хранить assignedAt там, где ему логически место.
Если совсем по‑человечески: раньше вы говорили базе «эти двое теперь встречаются», а теперь говорите «эти двое встречаются, и вот с какого момента, и в каком порядке они стоят в списке». Это уже не просто «связь», это часть данных.
5. Обновление данных связи: обычный UPDATE
Вот где link entity начинает выглядеть особенно приятно. В @ManyToMany вы не можете сделать «поменять порядок категории у товара», потому что порядок — это не свойство Category и не свойство Product. Это свойство пары Product + Category. А пары в модели нет — значит, менять нечего.
В link entity всё прямолинейно: sortOrder — поле assignment‑сущности. Мы меняем его как обычное поле managed entity, и dirty checking делает остальное.
Мини‑пример:
public void changeSortOrder(int newSortOrder) {
// Простейший guard на инварианты связи: отрицательный порядок запрещаем.
// Это удобно именно потому, что связь стала объектом с поведением.
if (newSortOrder < 0) {
throw new IllegalArgumentException("sortOrder must be >= 0");
}
// Обычное обновление поля managed-entity:
// на flush() Hibernate сделает UPDATE одной строки.
this.sortOrder = newSortOrder;
}
Если ProductCategoryAssignment находится в persistence context (а он там окажется, когда вы загрузили товар вместе с assignments в рамках транзакции), то на flush() вы увидите ровно один обновляющий запрос:
update product_category_assignment
set sort_order = ?
where id = ?;
Обратите внимание на инженерную красоту момента. Мы не обновляем «всю коллекцию». Мы не удаляем и вставляем заново. Мы не делаем ничего похожего на «пересобрать весь join-table как-нибудь». Мы делаем ровно то, что хотели сделать: меняем поле в одной записи связи.
Чтобы зафиксировать разницу «на бумаге», вот компактная таблица сравнения (она не про performance‑замеры, а про предсказуемость):
| Операция в коде | Наивный @ManyToMany | Link entity ProductCategoryAssignment |
|---|---|---|
| Добавить категорию | INSERT в join‑таблицу только с FK | INSERT в assignment со своими полями (sortOrder, assignedAt) |
| Удалить одну категорию | иногда точечный DELETE, иногда «delete all + insert оставшихся» | DELETE одной assignment‑строки (обычно по id) |
| Изменить sortOrder | фактически негде хранить | один UPDATE одной строки |
И да, это тот случай, когда «больше объектов в Java» даёт меньше сюрпризов в SQL.
6. «Контроль связи» на практике
Когда мы говорим «контроль связи», легко скатиться в абстрактные слова. Поэтому давайте конкретно: что именно у нас появляется после перехода на link entity, если смотреть не на красоту архитектуры, а на повседневную разработку и отладку.
Во‑первых, у строки связи появляется entity-identity. У неё есть id, её можно логировать, на неё можно поставить breakpoint, её можно удалить как объект. Это резко снижает ощущение, что join‑таблица — это «чёрная магия ORM». Она больше не магия: это обычная таблица обычной сущности.
Во‑вторых, cascade и orphanRemoval становятся семантически честными. Мы можем сказать: «Product управляет жизненным циклом assignment‑объектов», и включить cascade = ALL и orphanRemoval = true только на assignments, а не на Category. Это важная деталь: категория — shared сущность справочника, её не должен «уничтожать» продукт, когда он убирает связь. И с link entity это становится почти невозможно перепутать, потому что удаляется именно assignment.
В‑третьих, инварианты вроде «уникальная пара product + category» становятся проще поддерживать одновременно на уровне модели и на уровне схемы. В Java вы можете сделать guard (не создавать дубликат), а в PostgreSQL — добавить уникальное ограничение, чтобы база не позволила случайно записать мусор. И это уже не «теория», а практическая страховка от багов в сервисном коде.
Например, аннотация на таблице (показываю очень коротко, идею, а не полный класс):
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Table(
name = "product_category_assignment",
// Уникальность пары FK защищает от дублей на уровне БД:
// один и тот же category не должен назначаться одному и тому же product дважды.
uniqueConstraints = @UniqueConstraint(
name = "uk_product_category",
columnNames = {"product_id", "category_id"}
)
)
public class ProductCategoryAssignment { }
И наконец, link entity делает SQL‑след более «читабельным» для человека. Когда вы видите delete from product_category_assignment where id = ?, вы понимаете, что удаляется именно связь. Когда вы видите update ... set sort_order, вы понимаете, что обновляется именно порядок. Это звучит банально, но в реальных проектах «банально понятно» — это конкурентное преимущество.
7. SQL‑профили в Commerce Persistence Lab
Чтобы не оставлять всё на уровне «где‑то там в логах будет красиво», представим, как мы обычно смотрим на поведение в нашем лабораторном проекте. Мы запускаем приложение с включённым SQL trace (вы это уже делали в ранних днях курса), выполняем один сервисный метод, и внутри транзакции делаем flush(), чтобы не гадать, «когда Hibernate решит отправить SQL».
Чтобы сосредоточиться на SQL, этот сервисный кусок тоже нарочно короткий: считаем, что Product уже managed, а код загрузки по id здесь просто не влияет на сам профиль удаления связи.
Пример сервиса каталога в стиле нашего проекта (минимально):
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductCategoryService {
private final EntityManager entityManager;
public ProductCategoryService(EntityManager entityManager) {
// EntityManager нужен нам здесь, чтобы принудительно сделать flush()
// и сразу увидеть SQL в логах.
this.entityManager = entityManager;
}
@Transactional
public void unassignCategory(Product product, long categoryId) {
// Лабораторный сценарий: Product уже managed, нас интересует только SQL-след удаления связи.
product.unassignCategory(categoryId);
// Принудительный flush — ключ к «контролю по SQL».
entityManager.flush();
}
}
Если модель ещё на @ManyToMany, вы в логах можете увидеть «грубую» пересборку. Если модель уже на link entity, вы почти наверняка увидите точечный DELETE по assignment‑строке (иногда с предварительным SELECT, если коллекция ещё не загружена).
И вот здесь возникает практический критерий успешности рефакторинга дня 12: ваш код должен приводить к SQL, который говорит то же самое, что и ваш бизнес‑смысл. «Убрать категорию у товара» должен выглядеть как «удалить запись назначения», а не как «переписать все назначения товара заново».
Да, Hibernate не обязан быть «максимально экономным» в каждом edge case. Но после link entity у вас появляется возможность сделать поведение предсказуемым. А предсказуемость — это фундамент для любого следующего шага: будь то ограничения, сортировка, история изменений или просто спокойный сон разработчика.
8. Типичные ошибки при работе с link entity
Ошибка №1: сравнение решений по количеству аннотаций, а не по SQL.
Это самая частая «инженерная иллюзия»: кажется, что краткий mapping автоматически означает «просто работает». На практике @ManyToMany часто означает, что Hibernate будет делать SQL так, как ему удобно для управления коллекцией, а не так, как удобно вам для контроля связи. Правильная привычка — после любой операции со связью делать flush() в лаборатории и смотреть на generated SQL.
Ошибка №2: после перехода на link entity оставить старую коллекцию categories рядом с categoryAssignments.
Это создаёт два источника правды. В памяти приложения вы можете случайно обновлять categories, а в БД вы будете ожидать, что обновляются assignments (или наоборот). В результате вы получаете несинхронизированный граф и очень странный SQL. В проекте такого типа связь должна иметь один путь управления: либо наивный @ManyToMany (до рефакторинга), либо link entity (после).
Ошибка №3: удаление Category, когда вы хотели удалить assignment.
Когда в домене появляются Product, Category и ProductCategoryAssignment, мозг иногда путает: «ну я же удаляю категорию из товара… значит удаляю категорию». Нет. Категория — справочник, shared сущность. Удаляется запись связи. Если вы зовёте categoryRepository.delete(...) вместо удаления assignment из коллекции — вы меняете смысл операции и рискуете потерять данные справочника.
Ошибка №4: отсутствие orphanRemoval, из-за чего связь “в памяти пропала”, а в БД осталась.
Если вы удалили assignment из коллекции Product.categoryAssignments, но не настроили orphanRemoval = true, Hibernate не обязан удалять строку из таблицы связи. Он увидит, что объект больше не связан с родителем, но не получит команды «это orphan, его нужно удалить». В результате вы получите расхождение между моделью в памяти и данными в таблице product_category_assignment.
Ошибка №5: обновление только одной стороны двунаправленной связи.
Когда вы создаёте или удаляете assignment, нужно поддерживать консистентность и на стороне Product, и на стороне Category (если у категории есть коллекция assignments для навигации). Если вы добавили assignment только в product.categoryAssignments, но не добавили его в category.productAssignments, в памяти у вас уже «полусинхронизированный» граф. Hibernate, конечно, в итоге ориентируется на owning side в БД, но вы сами себе усложните жизнь: отладка, логирование и простые проверки в коде начнут вести себя противоречиво.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ