1. @ManyToMany: обзор и ограничения
Когда разработчик впервые видит @ManyToMany, внутри обычно просыпается маленький оптимизатор: «О! Можно просто связать Product и Tag, и всё само заработает, без лишних сущностей и таблиц!» Это правда звучит как мечта, особенно после mappedBy, каскадов и прочих радостей. Но у этой мечты есть важное условие: связь должна оставаться лёгкой и без собственного смысла.
В нашем мини-магазине теги — хороший пример такой лёгкой связи. Товар может иметь много тегов: sale, new, eco, gift. И каждый тег, очевидно, может быть присвоен многим товарам. Вот это и есть классическая кардинальность «многие-ко-многим»: много товаров ↔ много тегов.
Но здесь важно не перепутать «сейчас удобно» и «это реально отражает модель». @ManyToMany хорошо работает, когда теги — это именно классификация, а не самостоятельный бизнес-объект со сложной логикой. Как только бизнес говорит: «А давайте ещё хранить, кто присвоил тег, когда присвоил, и откуда он вообще пришёл (ручной/авто/импорт)» — связь перестаёт быть лёгкой, и @ManyToMany начинает трещать по швам.
Именно поэтому в нашем курсе Tag — controlled bonus. Мы умеем его сделать, понимаем цену и ограничения, но не превращаем теги в центральную ось проекта. Центральная ось у нас — каталог, остатки и заказы, а не «великая система тегирования всего сущего».
2. Join table в SQL
Если в объектной модели Product может просто держать Set<Tag>, то в реляционной модели так не получится: в таблице нет «коллекций». Таблица любит простые вещи: колонки, значения, ключи. Поэтому «многие-ко-многим» в SQL всегда хранится через третью таблицу, которую обычно называют join table (таблица-связка, таблица-склейка).
В нашем случае это будет что-то вроде product_tag, где каждая строка означает один факт связи: «вот этот товар имеет вот этот тег». Никакой магии: просто пары идентификаторов. Это удобно представить себе как «таблицу дружбы» в соцсети: в ней не хранится человек, не хранится второй человек — хранится факт связи между ними.
Схематично это выглядит так:
%% Join table: отдельная таблица, которая хранит только пары идентификаторов
erDiagram
product ||--o{ product_tag : "links"
tag ||--o{ product_tag : "links"
product {
bigint id
varchar sku
varchar name
}
tag {
bigint id
varchar code
varchar name
}
product_tag {
bigint product_id
bigint tag_id
}
А в виде DDL (упрощённо, по-человечески) join table обычно выглядит так:
create table product_tag (
product_id bigint not null references product(id), -- ссылка на товар
tag_id bigint not null references tag(id), -- ссылка на тег
primary key (product_id, tag_id) -- защита от дублей связей
);
Обрати внимание на primary key (product_id, tag_id). Это не «обязательно так», но это очень здоровая привычка: она защищает нас от дублей. Иначе можно случайно сохранить связь «product 10 ↔ tag 3» дважды, и потом удивляться, почему в каталоге «eco» показывается два раза (спойлер: потому что база честно хранит две строки).
Если попытаться мысленно прочитать product_tag как таблицу, получится очень простая картинка. Например:
| product_id | tag_id |
|---|---|
| 10 | 3 |
| 10 | 7 |
| 11 | 3 |
Это значит: товар 10 имеет теги 3 и 7, товар 11 имеет тег 3. И всё. Никаких скрытых объектов «productTag» в Java не существует — если мы используем @ManyToMany, Hibernate будет работать с join table «за кулисами».
Роль @JoinTable в JPA как раз в том, чтобы явно описать эту третью таблицу: как она называется, какие у неё колонки, и какие из них указывают на какую сущность.
3. Однонаправленный @ManyToMany: Product → Tag
Самый спокойный вариант для учебного проекта (и довольно часто для коммерческого тоже) — однонаправленная @ManyToMany. Это когда Product знает свои теги, но Tag не хранит коллекцию товаров. Такой подход даёт две приятные вещи: он проще для мозга и резко снижает риск «гигантского объектного графа», где всё связано со всем.
Начнём с сущности Tag. Мы держим её в catalog, потому что это часть каталога: это способ классифицировать товары. У тега будет уникальный code (машинный идентификатор) и человекочитаемое name.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "tag") // таблица-справочник тегов
public class Tag {
@Id
@GeneratedValue
private Long id; // технический идентификатор (PK)
@Column(nullable = false, unique = true, length = 64)
private String code; // уникальный "код" тега: sale/new/eco
@Column(nullable = false, length = 255)
private String name; // человекочитаемое имя для UI/админки
}
Теперь добавим в Product набор тегов. Здесь ключевой элемент — @JoinTable: мы явно говорим Hibernate, что связь хранится в таблице product_tag, и задаём имена колонок product_id и tag_id. Плюс, мы добавим uniqueConstraints, чтобы защититься от дублей на уровне схемы.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.UniqueConstraint;
import java.util.HashSet;
import java.util.Set;
public class Product {
@ManyToMany // связь "многие-ко-многим": у товара много тегов
@JoinTable(
name = "product_tag", // join table (таблица связей)
joinColumns = @JoinColumn(name = "product_id", nullable = false), // FK на владельца связи (Product)
inverseJoinColumns = @JoinColumn(name = "tag_id", nullable = false), // FK на вторую сторону (Tag)
uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "tag_id"}) // защита от дублей пар
)
private Set<Tag> tags = new HashSet<>(); // Set = по смыслу "набор", а не список с дублями
}
Здесь очень важно не путать joinColumns и inverseJoinColumns. Владеющая сторона — Product, и joinColumns описывает колонку в join table, которая указывает на владельца, то есть на product. А inverseJoinColumns указывает на другую сторону, то есть на tag. Названия звучат как «инверсия по настроению», но в реальности это просто «владелец ↔ противоположная сторона».
Почему мы используем Set, а не List? Потому что в нашей предметной модели «у товара есть набор тегов», и дубли нам не нужны. В List дубли — это нормальное состояние, а в Set — нет. Это не «религия коллекций», а очень практическая защита от «почему у товара три раза тег sale».
На этом месте новичок часто спрашивает: «А где mappedBy?» Ответ простой: в однонаправленной связи mappedBy не нужен. Мы не говорим, что Tag тоже знает товары. У нас только одна навигация: из товара в его теги.
И это не значит, что мы «навсегда запретили искать товары по тегам». Просто навигации tag.getProducts() в Java нет. А запросы и чтение — это отдельная тема (и мы к ней придём позже через derived queries, JPQL и projections). Сейчас мы делаем правильную вещь: держим модель простой, пока нам не доказали, что нужно усложнить.
4. Двунаправленный @ManyToMany: mappedBy и helper-методы
Двунаправленная @ManyToMany — это как двусторонняя дверь: удобно, пока ты не начал бежать через неё с двух сторон одновременно. Иногда она действительно нужна: например, если в коде есть сценарии «показать все товары по тегу» и тебе удобно начинать навигацию именно от Tag. Но очень часто двусторонность добавляют «на всякий случай», а потом получают циклы, непредсказуемые загрузки и бесконечные toString()-страдания.
Если всё-таки двусторонность нужна, то owning side всё равно должен быть только один. Обычно владеющей стороной оставляют Product (он и так центр каталога), а в Tag добавляют обратную коллекцию с mappedBy = "tags".
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;
public class Tag {
// mappedBy говорит, что владелец связи — поле Product.tags
@ManyToMany(mappedBy = "tags")
private Set<Product> products = new HashSet<>();
// геттер нужен, чтобы helper-методы на стороне Product могли синхронизировать обе стороны
public Set<Product> getProducts() {
return products;
}
}
В этот момент появляется новая обязанность: синхронизация обеих сторон в памяти. Если ты сделал product.getTags().add(tag), то коллекция tag.getProducts() не обновится сама. Hibernate не телепат. Он ORM, а не психолог.
Поэтому, если связь двусторонняя, мы почти всегда добавляем helper-методы. Обычно их держат на стороне, которая ближе к use case. В нашем случае логично держать их в Product, потому что мы «назначаем теги товару».
package com.example.shopdatajpa.catalog.entity;
public class Product {
// Важно: обновляем обе стороны, чтобы объектная модель в памяти была согласованной
public void addTag(Tag tag) {
tags.add(tag);
tag.getProducts().add(this);
}
// Аналогично при удалении: убираем связь и здесь, и с обратной стороны
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getProducts().remove(this);
}
}
Эти методы выглядят тривиально, но они экономят тебе часы отладки. Без них ты получишь очень странные эффекты. Например, ты добавил тег товару и сразу в том же методе проверяешь tag.getProducts().contains(product) — и получаешь false. Не потому что Hibernate «сломался», а потому что ты обновил только половину модели.
И ещё одна тонкость: helper-методы — это не «переделка Java под ORM». Это нормальная дисциплина для любой двусторонней структуры данных. Если у тебя есть два указателя, которые должны быть согласованы, у тебя должна быть одна точка, где они обновляются как одна операция. Иначе код превращается в «я где-то там добавил в одну коллекцию, а вторую забыть не обещаю, но давайте надеяться».
5. Удаление и каскады: риск CascadeType.REMOVE
Когда мы говорим «удалить связь», мы часто имеем в виду «убрать тег у товара». В терминах SQL это означает удалить строку из join table product_tag. Но когда мы говорим «удалить тег», это означает удалить строку из таблицы tag. Это две разные операции, и путать их — почти гарантированно получить сюрпризы.
В @ManyToMany особенно опасно включать CascadeType.REMOVE. Причина простая: один и тот же Tag может принадлежать нескольким товарам. И если ты случайно настроишь каскадное удаление и удалишь один Product, ORM может попытаться удалить и его Tag-и. А дальше произойдёт либо нарушение ссылочной целостности (в базе ещё есть другие товары, которые ссылаются на этот тег через join table), либо тихая потеря данных (если ты потом руками почистишь join table). И оба варианта плохи: один ломает приложение, второй ломает смысл данных.
Вот пример настройки, которую очень хочется написать «чтобы всё само убиралось», и которую почти всегда хочется удалить из проекта через пять минут:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;
public class Product {
// Опасно: удаляя Product, можно "зацепить" удаление общих Tag-ов
@ManyToMany(cascade = CascadeType.REMOVE)
private Set<Tag> tags = new HashSet<>();
}
Если представить себе реальность магазина, становится очевидно: удаление товара не должно удалять «понятие тега». Тег sale продолжает существовать и может быть полезен для других товаров. Значит жизненный цикл Tag не «принадлежит» товару. А если жизненный цикл не общий — каскадные удаления здесь неуместны.
В учебном проекте самый спокойный default — вообще не ставить каскады на @ManyToMany, пока не появится железная доменная причина. Теги создаются отдельно, живут отдельно, а связь между ними и товарами — просто связь, которую мы добавляем и убираем.
И ещё один момент, который полезно проговорить заранее: удаление связи — это не удаление сущности. Код уровня «убрать тег из товара» логически выглядит так, как будто мы редактируем список:
product.removeTag(tag); // по смыслу: убрать связь
А код уровня «удалить тег как сущность» — это уже отдельная операция, и она должна быть осознанной. Даже если в будущем у нас появится админская операция «удалить тег», она должна явно проверять, что тег нигде не используется (или сначала чистить связи). Но это уже не вопрос аннотации — это вопрос бизнес-правил.
6. Сущность-связка вместо @ManyToMany
@ManyToMany — это отличный инструмент, пока связь между Product и Tag остаётся простой: «есть/нет». Но бизнес почти никогда не останавливается на «есть/нет». У бизнеса есть суперспособность: он может добавить новое поле в любую связь, причём обычно в пятницу вечером. И вот в этот момент @ManyToMany начинает сопротивляться.
Представь, что мы захотели хранить не просто «какие теги у товара», а, например, источник назначения тега: MANUAL, AUTO_RULE, IMPORT. Или дату назначения. Или приоритет тега. Или «кто назначил». Это всё атрибуты связи, а не атрибуты товара или тега. И в join table появляется новая колонка, например source.
Вот здесь прямой @ManyToMany становится плохой моделью, потому что join table перестаёт быть «технической таблицей связей» и становится полноценной частью домена. И правильный шаг — сделать эту таблицу отдельной сущностью-связкой.
Например, можно завести ProductTagAssignment (название может быть любым адекватным), где будут две ManyToOne-ссылки и поля связи:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(
name = "product_tag_assignment",
uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "tag_id"}) // одна связь на пару
)
public class ProductTagAssignment {
@Id
@GeneratedValue
private Long id; // технический идентификатор строки связи
@ManyToOne
private Product product; // на какой товар назначили тег
@ManyToOne
private Tag tag; // какой именно тег назначили
@Column(nullable = false, length = 32)
private String source; // атрибут связи: откуда пришло назначение (MANUAL/AUTO_RULE/IMPORT)
}
Заметь, что мы снова ставим уникальность по паре product_id + tag_id. Просто теперь это не «скрытая» join table, а таблица со смыслом, и мы контролируем её как обычную entity.
Полезно увидеть разницу между подходами в одной таблице — не как «теория ради теории», а как инженерное решение:
| Вопрос | Прямой @ManyToMany | Сущность-связка |
|---|---|---|
| Связь имеет только факт «есть/нет» | Отлично подходит | Будет лишней сложностью |
| У связи появляются поля (source, assignedAt, priority) | Плохо/невозможно выразить нормально | Это как раз её родная зона |
| Нужно тонко управлять жизненным циклом связи | Ограниченно | Полный контроль |
| Нужно быстро стартануть и не усложнять проект | Очень удобно | Чуть тяжелее вход |
Именно поэтому мы называем @ManyToMany «ограниченным инструментом». Он не плохой. Он просто работает в довольно узком коридоре. В учебном проекте мы его показываем на тегах, потому что это как раз типичный сценарий из этого коридора. Но мы сразу фиксируем: как только связь становится «сущностью в маске» — пора снимать маску и делать отдельный класс.
7. Типичные ошибки при @ManyToMany
Ошибка №1: делать @ManyToMany для «центральных» бизнес-связей.
Новичку кажется, что если связь сложная, то @ManyToMany как раз спасёт: меньше кода, меньше таблиц, меньше сущностей. В реальности происходит наоборот: сложная связь почти всегда требует своих полей и правил, а значит просится в сущность-связку. @ManyToMany хорош именно там, где связь лёгкая и почти «техническая» по смыслу.
Ошибка №2: включать CascadeType.REMOVE, потому что «пусть оно само».
В ManyToMany сущности с обеих сторон обычно переиспользуются. Тег — общий для многих товаров. Поэтому каскадное удаление превращается в потенциальную катастрофу: удаляя один товар, ты начинаешь рисковать целым словарём тегов. Здесь лучше держать жизненные циклы раздельно и управлять удалением явно.
Ошибка №3: делать двунаправленность «на всякий случай», а потом забывать синхронизировать обе стороны.
Двунаправленная связь требует дисциплины: обновил одну сторону — обнови и вторую, иначе модель в памяти сама себе противоречит. Если обратная навигация не нужна прямо сейчас, однонаправленная Product -> tags обычно проще, чище и дешевле для понимания.
Ошибка №4: ставить @JoinTable на обе стороны или забывать mappedBy.
У @ManyToMany всё ещё есть owning side. Если пытаться описать join table с двух сторон «симметрично», можно получить либо две таблицы связей, либо конфликтующие настройки. Правило простое: @JoinTable живёт у владельца связи, обратная сторона (если она есть) использует mappedBy.
Ошибка №5: не думать о дублях в join table.
Даже если в Java ты используешь Set, это ещё не гарантирует, что в таблице не появятся повторяющиеся пары. Правильная защита — уникальность на уровне БД (например, primary key (product_id, tag_id) или unique(product_id, tag_id)) и аккуратное управление связью через один понятный метод, а не через «где-то в коде добавили, где-то удалили».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ