1. Коллекции после link entity
Если до этого вы жили в мире @ManyToMany, то могли вообще не задумываться, что коллекция внутри entity — это не просто «обычный список». После перехода на link entity вы начинаете работать не с «категориями товара», а с объектами связи, и тут внезапно выясняется: один неловкий add() — и граф в памяти распадается на две параллельные реальности. В этот момент Hibernate не ломается. Ломаемся мы — когда пытаемся объяснить себе, почему SQL и состояние объектов расходятся.
Главная проблема звучит очень по‑человечески: связь теперь двунаправленная и составная. У нас есть Product со списком categoryAssignments, у нас есть Category со списком productAssignments, и у нас есть сам ProductCategoryAssignment, который хранит ссылки на оба конца. Это уже три места, которые должны быть согласованы. Если вы изменили только одно место, Hibernate может и сохранит «как‑то» (или не сохранит), но вы почти гарантированно получите «призрачные» assignments в памяти, дубли, внезапные UPDATE, или ситуацию «в БД уже удалено, а в коллекции всё ещё лежит».
Наивный код обычно выглядит примерно так: кто‑то получает доступ к коллекции и начинает «ковырять» её напрямую. Вроде бы работает, пока вы не добавили второй сценарий и третий тест.
// Плохая идея: сервис напрямую меняет внутренности entity
// Здесь создаётся «пустой» assignment, у которого не выставлены ссылки на Product/Category,
// и в результате граф в памяти становится неконсистентным.
product.getCategoryAssignments().add(
new ProductCategoryAssignment()
);
В этой строчке всё плохо сразу: assignment создаётся «пустым», ссылки не выставлены, у Category коллекция не обновилась, а ещё вы только что разрешили внешнему коду «делать что угодно» с внутренним состоянием Product. В результате Hibernate будет вынужден угадывать, что вы имели в виду. Угадаю заранее: Hibernate угадывает хуже, чем кажется. И, к сожалению, он не может спросить «а вы точно хотели так?».
2. Owning side и жизненный цикл
Перед тем как писать helper‑методы, полезно на минуту остановиться и «повесить табличку на дверь»: кто здесь за что отвечает. Это не философия — это чистая механика ORM. Если вы перепутаете owning side и lifecycle owner, вы начнёте лечить симптомы (странный SQL), вместо того чтобы исправить причину (не та модель изменения графа).
Наша модель после выделения ProductCategoryAssignment выглядит так: у assignment есть два @ManyToOne (это owning side для каждого FK), а у Product и Category есть @OneToMany(mappedBy = "...") коллекции (inverse side). При этом жизненный цикл assignment‑объекта мы хотим привязать к товару: товар управляет тем, какие категории ему назначены, но не управляет жизненным циклом самой категории (категория — shared reference).
Для удобства зафиксируем это в маленькой схеме:
classDiagram
class Product {
Long id
List~ProductCategoryAssignment~ categoryAssignments
}
class Category {
Long id
List~ProductCategoryAssignment~ productAssignments
}
class ProductCategoryAssignment {
Long id
Product product
Category category
int sortOrder
Instant assignedAt
}
Product "1" --> "*" ProductCategoryAssignment : "categoryAssignments (inverse)"
Category "1" --> "*" ProductCategoryAssignment : "productAssignments (inverse)"
ProductCategoryAssignment "*" --> "1" Product : "product (owning FK)"
ProductCategoryAssignment "*" --> "1" Category : "category (owning FK)"
И короткая «карта ответственности» (да, иногда таблица спасает больше нервов, чем три часа дебага):
| Часть модели | Что это | Кто должен менять | Почему это важно |
|---|---|---|---|
| ProductCategoryAssignment .product | owning side (FK в таблице) | создаём/обнуляем внутри helper‑методов | без этого FK не будет управляемым и связь станет «призрачной» |
| Product.categoryAssignments | inverse side + lifecycle owner | Product.assignCategory(...) / Product.unassignCategory(...) | именно тут живёт правило «какие категории у товара» |
| Category.productAssignments | inverse side для навигации | меняем внутренне, но не «снаружи» | чтобы граф в памяти оставался консистентным |
3. Добавление: Product.assignCategory(...)
Когда связь стала отдельной сущностью, добавление категории товару — это уже не «добавить в список категорий», а «создать новый объект назначения (assignment)». С этого момента удобно мыслить как разработчик, который заботится о поддерживаемости: мы хотим один метод, который выражает намерение. Не «положить что‑то в коллекцию», а «назначить категорию товару», причём сразу с данными связи (sortOrder, assignedAt) и с корректной синхронизацией обеих сторон.
Сами сущности при этом не меняются: тот же ProductCategoryAssignment остаётся центром связи, просто мы перестаём разбрасывать его сборку и синхронизацию по сервисам.
Почти всегда лучший вариант — сделать helper‑метод на Product, потому что в нашем домене именно товар управляет набором назначений. Внутри helper‑метода мы создаём ProductCategoryAssignment, выставляем ссылки, добавляем его в Product, и аккуратно обновляем Category. То есть делаем ровно то, что сервис бы сделал руками, только теперь это спрятано в одном месте и не размазано по коду.
Мини‑пример метода (пока без сложных инвариантов — их подробно раскроем в следующей лекции):
import java.time.Instant;
public void assignCategory(Category category, int sortOrder, Instant assignedAt) {
// Создаём assignment сразу «полным»: с обеими сторонами и данными связи
ProductCategoryAssignment assignment =
new ProductCategoryAssignment(this, category, sortOrder, assignedAt);
// Обновляем сторону владельца жизненного цикла (Product хранит правило «какие категории назначены»)
categoryAssignments.add(assignment);
// Обновляем обратную сторону (Category) ради консистентности графа в памяти
category.addAssignment(assignment); // внутренний helper на Category
}
Обратите внимание на пару важных мелочей, которые сильно влияют на жизнь.
Во‑первых, мы создаём assignment уже «полным»: передаём this (товар), категорию и поля связи. Это не «технический объект», который потом кто‑то дозаполнит. Это маленькая, но полноценная часть доменной модели.
Во‑вторых, мы добавляем assignment в коллекцию товара, потому что это тот список, который отражает «состав категорий товара». При cascade = CascadeType.ALL (или хотя бы PERSIST) Hibernate сможет автоматически сохранить новую запись связи, когда Product находится в managed‑состоянии.
В‑третьих, мы обновляем сторону Category через её helper‑метод. Формально для записи в БД это может быть не строго необходимо (owning side — у assignment), но для консистентности in‑memory графа — критично. Иначе вы тут же получите ситуацию, когда product.getCategoryAssignments() уже содержит связь, а category.getProductAssignments() её ещё «не знает». Это идеальный рецепт для удивления, особенно если вы позже в рамках одной транзакции захотите пройтись по категории и увидеть «какие товары к ней привязаны».
Ещё одна важная инженерная привычка: не отдавать наружу изменяемую коллекцию. Тогда «сырой add/remove» просто становится невозможным, и мир становится чуть более безопасным местом.
import java.util.Collections;
import java.util.List;
public List<ProductCategoryAssignment> getCategoryAssignments() {
// Не отдаём наружу изменяемую коллекцию: изменения — только через helper-методы.
return Collections.unmodifiableList(categoryAssignments);
}
Да, кому‑то это кажется «лишней строгостью». Но это та самая строгость, которая экономит часы расследований в будущем. Hibernate сам по себе добавляет достаточно неявности; не стоит добавлять ещё и «каждый может менять всё как хочет».
Навигация на Category без владения
Очень легко, вдохновившись helper‑методами, начать превращать Category в «второго владельца» связи. Это соблазнительно: «давайте и у категории будет assignProduct(...)». Но в нашей модели это быстро создаёт конкуренцию двух «центров управления», а значит — противоречивые правила. Поэтому стратегия такая: Category хранит коллекцию assignments для навигации, но изменения этой коллекции происходят только как часть сценариев на стороне Product.
Самый практичный стиль — сделать методы на Category максимально маленькими и ограничить их видимость. В идеале они должны быть package‑private (без public), чтобы снаружи их не вызывали.
void addAssignment(ProductCategoryAssignment assignment) {
// Важно: этот метод используется только как часть сценария на стороне Product,
// чтобы поддерживать консистентность in-memory графа.
productAssignments.add(assignment);
}
void removeAssignment(ProductCategoryAssignment assignment) {
// Симметричное удаление для консистентности графа в памяти
productAssignments.remove(assignment);
}
Почему это важно именно в Hibernate‑проекте, а не просто «потому что красиво»?
Потому что так вы защищаете модель от странного сценария: кто‑то в сервисе взял категорию и начал «назначать товары» через неё, забыв добавить assignment в товар. В итоге одна сторона говорит «да, связь есть», другая — «нет», а SQL на flush будет зависеть от того, какая сторона реально owning. И вы будете долго смотреть на лог, как на кота, который уронил вазу, и говорить: «Ну почему ты так сделал?».
В этом месте можно запомнить простое правило: Category знает о связях, но Product управляет связями. Если держать эту мысль в голове, helper‑методы становятся естественными.
4. Удаление связи и orphanRemoval
Удаление связи — это место, где начинаются настоящие «боли» начинающего ORM‑кода. Потому что в голове очень легко перепутать три разных действия: удалить связь, удалить assignment‑объект, удалить категорию. В нашей модели удаление категории у товара означает: удалить одну запись assignment, а категория как сущность должна остаться жить своей жизнью. Именно ради этого мы и ушли от наивного ManyToMany: чтобы различать эти понятия не только словами, но и кодом.
Мы хотим, чтобы удаление выглядело как понятная операция: «убрать у товара категорию с таким‑то id». Внутри мы должны: найти assignment, удалить его из коллекции товара, убрать его из коллекции категории и разорвать ссылки внутри assignment, чтобы in‑memory граф не оставался «наполовину живым».
Один из удобных вариантов — искать assignment по categoryId. Мы специально сравниваем по id, а не по equals() сущностей, потому что правила equality для entity — это отдельный тонкий мир (мы будем разбирать его позже).
public void unassignCategory(Long categoryId) {
// Ищем конкретный assignment по id категории (а не по equals сущностей)
ProductCategoryAssignment assignment = categoryAssignments.stream()
.filter(a -> a.getCategory().getId().equals(categoryId))
.findFirst()
.orElseThrow();
// Удаляем из коллекции товара: при orphanRemoval это означает «удалить orphan-строку связи»
categoryAssignments.remove(assignment);
// Удаляем с обратной стороны, чтобы граф в памяти оставался консистентным
assignment.getCategory().removeAssignment(assignment);
// Явно разрываем доменные ссылки в самом assignment
assignment.detach();
}
Пара нюансов, которые стоит проговорить словами, а не надеяться на «и так понятно».
Во‑первых, здесь ключевую роль играет orphanRemoval = true на коллекции Product.categoryAssignments. Когда Product managed, и мы удаляем assignment из коллекции, Hibernate воспринимает это как «assignment больше не принадлежит товару», а значит — его нужно удалить (потому что orphan removal и lifecycle ownership). На SQL‑уровне это обычно превращается в DELETE из таблицы assignment’ов.
Во‑вторых, мы удаляем assignment и из Category.productAssignments. Это нужно не для того, чтобы Hibernate «лучше сохранил» (owning side всё равно у ManyToOne), а для того, чтобы в рамках текущей транзакции, в рамках текущего persistence context, объектный граф был честным. Если вы это не сделаете, то в памяти у категории останется ссылка на assignment, который уже запланирован на удаление. И в какой‑то момент вы начнёте дебажить «почему у категории есть связь, которой уже нет», хотя на самом деле вы просто забыли синхронизировать стороны.
В‑третьих, мы вызываем assignment.detach(). Зачем — обсудим прямо следующим разделом, потому что это кажется мелочью, а на практике спасает нервы.
5. Разрыв ссылок внутри assignment
Когда вы удаляете assignment из коллекции, Hibernate действительно может корректно удалить строку из таблицы. Но объект в памяти при этом всё ещё существует, и внутри него всё ещё могут быть ссылки на Product и Category. Это похоже на ситуацию «я выписался из квартиры, но ключи почему‑то оставил себе». Формально в реестре вы уже не живёте, но если начать анализировать предметную область по объектам в памяти — получится путаница.
Поэтому полезно иметь маленький метод, который делает разрыв связи явным на уровне Java‑графа. Обычно это называют detach(), unlink() или даже markAsRemoved() — название не так важно, важно намерение.
public void detach() {
// Разрываем доменную связь: этот assignment больше ни к кому не привязан в объектном графе.
this.product = null;
this.category = null;
}
Это выглядит слишком просто, чтобы быть важным. Но на практике даёт сразу несколько плюсов.
Первый плюс — отладка. Когда вы логируете объект assignment (не надо логировать весь граф, но иногда вы всё равно это сделаете), вы видите, что он «осиротел» по‑настоящему, а не просто «лежит отдельно в списке».
Второй плюс — защита от случайного повторного использования. Иногда разработчик удалил assignment из коллекции, а потом (например, из‑за ошибочной логики) попытался его снова добавить или модифицировать. Если внутри assignment всё ещё ссылки на продукт и категорию, это может выглядеть «вроде бы логично» и порождать неожиданные side effects.
Третий плюс — честная доменная модель. Мы буквально кодом фиксируем факт: эта связь разорвана. Не «где‑то в БД», а прямо здесь, в памяти приложения.
Конечно, важно помнить: entity‑метод detach() — это не про Hibernate‑операцию EntityManager.detach(entity). Название совпадает, смысл другой. Hibernate detach() — про состояние entity относительно persistence context, а наш assignment.detach() — про разрыв доменной связи. Если вас смущает терминология, можно назвать метод unlink().
6. Использование в сервисах
Теперь у нас есть helper‑методы в модели. Осталось самое приятное: сервисный код становится короче и честнее. Вместо того чтобы размазывать логику по нескольким объектам, сервис делает одно понятное действие: загружает сущности и вызывает доменный метод. Hibernate, persistence context и dirty checking делают остальное (в рамках транзакции).
Пример сервиса назначения категории товару может выглядеть так:
import java.time.Instant;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogCategoryService {
@Transactional
public void assignCategory(long productId, long categoryId, int sortOrder) {
// Product берём как managed-entity, чтобы dirty checking и cascade отработали автоматически
Product product = productRepository.findById(productId).orElseThrow();
// Category можно взять как reference: нам важно получить proxy/сущность по id, без обязательного select
Category category = categoryRepository.getReferenceById(categoryId);
// Вся бизнес-логика изменения графа спрятана в доменном helper-методе
product.assignCategory(category, sortOrder, Instant.now());
}
}
Обратите внимание, как здесь «красиво» ложатся темы прошлых дней.
Транзакция висит на service‑методе, значит Product — managed, изменения попадут в persistence context, а cascade на categoryAssignments позволит сохранить новый assignment без ручного entityManager.persist(...). И, что важно, сервис вообще не делает «механический save()» — это именно тот стиль, который мы уже обсуждали на дне про dirty checking: если объект managed, Hibernate сам увидит изменения и синхронизирует их на flush/commit.
Удаление выглядит ещё проще:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void unassignCategory(long productId, long categoryId) {
// Загружаем managed Product в транзакции
Product product = productRepository.findById(productId).orElseThrow();
// Удаление связи — это доменная операция, а не «remove из коллекции где-то в сервисе»
product.unassignCategory(categoryId);
}
И вот здесь вы начинаете чувствовать главную ценность helper‑методов: транзакция, чтение, изменение графа, flush — всё предсказуемо. Вы не пытаетесь вспомнить, «а надо ли удалить ещё из той коллекции?», «а надо ли поставить null на owning side?», «а надо ли save()?» — потому что это зашито в модель, где этой логике и место.
Плюс, это очень удобно для SQL‑наблюдаемости. Когда вы видите в логе INSERT в product_category_assignment, вы можете пальцем ткнуть в assignCategory(...) и сказать: «вот почему он появился». И это, честно, одно из самых приятных ощущений в Hibernate‑мире: когда SQL перестаёт быть мистикой.
7. Типичные ошибки при helper‑методах для link entity
Ошибка №1: изменять коллекции напрямую, обходя helper‑методы.
Чаще всего это начинается невинно: «ну я же только в одном месте добавлю assignment». Потом это «одно место» становится тремя, затем вы забываете синхронизировать вторую сторону, а затем начинается карнавал в стиле «в памяти одно, в БД другое». От этого очень помогает простая дисциплина: наружу отдавать коллекции только как unmodifiableList, а изменения делать только через методы assign.../unassign....
Ошибка №2: синхронизировать только одну сторону связи и считать, что этого достаточно.
Иногда разработчик добавляет assignment только в Product.categoryAssignments и не обновляет Category.productAssignments. SQL при этом может быть корректным (особенно если owning side выставлен), но объектный граф внутри транзакции становится «врущим». Через пару методов вы начинаете видеть то дубли, то «удалённые» элементы, то странные поведения при повторных проходах. В Hibernate‑коде лучше сразу держать граф консистентным, иначе отладка превращается в психологический триллер.
Ошибка №3: пытаться удалять Category, когда нужно удалить только assignment.
Это классическая путаница «удалить связь» vs «удалить сущность». Если категория — справочник и используется многими товарами, то CascadeType.REMOVE или механическое categoryRepository.delete(category) в сценарии «убрать категорию у товара» — почти гарантированная авария. В link entity модели удаление связи — это удаление ProductCategoryAssignment, и именно на это должен быть направлен код.
Ошибка №4: забывать про orphanRemoval и ожидать, что «удаление из коллекции само удалит запись».
Если orphanRemoval не включён (или вы управляете жизненным циклом иначе), то удаление assignment из коллекции товара может привести не к DELETE, а к попытке занулить FK, или вообще к тому, что связь «останется в БД», пока вы вручную её не удалите. В нашем домене assignment живёт и умирает вместе с товаром, поэтому orphanRemoval=true на Product.categoryAssignments — осмысленное решение, но его нужно помнить и проверять.
Ошибка №5: оставлять assignment «полуживым» в памяти после удаления.
Когда вы удалили assignment из коллекций, но не разорвали ссылки внутри него, вы оставляете объект, который выглядит связанным с продуктом и категорией, хотя связь уже разорвана. Это не всегда ломает SQL, но ломает мышление и диагностику: потом кто‑то печатает лог, видит assignment.product.id, и делает неверные выводы. Маленький detach/unlink делает модель честнее и спокойнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ