В реляционной базе данных таблицы соединяются через внешние ключи. В JPA то же самое выражается аннотациями на полях сущностей. Здесь разберём, как это устроено, на что обратить внимание и где легко ошибиться.
Связь для базы — это значение столбца customer_id, и берётся оно только со стороны @ManyToOne. Пока заказ добавлен лишь в коллекцию orders, писать в столбец нечего: сторона с mappedBy для Hibernate доступна только на чтение.
Почему связи — не просто аннотации
Когда разработчик впервые видит @OneToMany, кажется: поставил аннотацию — готово. На деле у каждой связи есть владеющая сторона (owning side) — та, которая управляет внешним ключом в базе. Если не указать её правильно, Hibernate либо создаст лишние столбцы, либо просто не сохранит связь.
Вторая сложность: по умолчанию часть связей загружается сразу (EAGER), часть — лениво (LAZY). Неправильное ожидание здесь — прямой путь к LazyInitializationException и проблеме N+1 запросов.
Прежде чем разбирать аннотации, стоит увидеть, что за ними стоит в базе. Связь «у заказа один покупатель» — это одна колонка:
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customer(id),
total_amount numeric(19,2) NOT NULL
);
Никакой «двусторонности» в базе нет: колонка одна, и живёт она на стороне «многих». Всё, что Hibernate называет владельцем связи, — это про то, кто отвечает за эту колонку. Обратная сторона (@OneToMany у покупателя) в схеме не существует вовсе: это запрос «выбери заказы, где customer_id равен моему», который Hibernate выполняет сам.
@ManyToOne — самая простая сторона
У связи две стороны, а внешний ключ в базе один: он лежит в таблице заказов, в колонке customer_id. Поэтому одна из сторон в JPA «владеет» связью, и по умолчанию это @ManyToOne: где внешний ключ, там и владелец.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // по умолчанию — EAGER, лучше явно ставить LAZY
@JoinColumn(name = "customer_id") // имя столбца FK в таблице orders
private Customer customer;
}
@JoinColumn указывает, какой столбец таблицы orders хранит ссылку на customers. Без этой аннотации Hibernate соберёт имя сам: имя поля плюс имя ключевого столбца, и пропустит результат через ту же стратегию именования, о которой шла речь в маппинге сущностей (hibernate.physical_naming_strategy), — выйдет customer_id. Лучше быть явным.
Важно: у @ManyToOne стратегия по умолчанию — EAGER. Это значит, что при загрузке Order Hibernate сразу подтянет Customer — даже если он не нужен. Явный fetch = FetchType.LAZY исправляет это.
Две настройки, которые задают не только объектную модель, но и схему с планом запроса.
@JoinColumn(nullable = false) означает NOT NULL на колонке — то есть заказ без покупателя база не примет. Это ровно то самое «обязательное поле по умолчанию», о котором речь в статье про антипаттерны схемы.
optional = false в самой аннотации связи говорит уже Hibernate: связь обязательна, пустой её не бывает. Отсюда два следствия. Hibernate вправе использовать внутреннее соединение вместо внешнего, когда грузит связь запросом, — план получается дешевле. И он знает, что заместителю всегда найдётся строка, поэтому в некоторых случаях может не проверять её наличие отдельным запросом.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
Ставят их всегда парой: первое — правда про схему, второе — та же правда для ORM. Расхождение между ними (в схеме NULL разрешён, а в аннотации optional = false) даёт худший вариант — Hibernate строит планы, полагаясь на обещание, которого база не держит.
@OneToMany — обратная сторона и mappedBy
@OneToMany описывает «один ко многим» со стороны «одного». Но сама по себе эта аннотация не создаёт внешний ключ — она лишь говорит Hibernate, где искать обратную связь.
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "customer") // "customer" — имя поля в Order
private List<Order> orders = new ArrayList<>();
}
mappedBy = "customer" — это указание: «владеющая сторона — поле customer в классе Order; не создавай никакой дополнительной таблицы или столбца, просто читай через него».
Если у @OneToMany нет ни mappedBy, ни @JoinColumn, Hibernate создаст промежуточную таблицу связи (customer_orders) — как при @ManyToMany. Это распространённая ошибка.
Короткая формула: mappedBy ставится на стороне, которая не владеет внешним ключом.
Однонаправленная vs двунаправленная связь
Однонаправленная — есть только одна аннотация на одной сущности:
// Только в Order — Customer ничего не знает об orders
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
Двунаправленная — это оба блока из разделов выше сразу: @ManyToOne с @JoinColumn в Order и @OneToMany(mappedBy = "customer") в Customer. Навигация удобнее в обе стороны, но нужна аккуратность: Hibernate сохраняет только то, что видит на владеющей стороне. Если вы добавите order в список customer.getOrders(), но не установите order.setCustomer(customer) — изменение не сохранится.
Хелпер-методы для синхронизации
Решение — добавлять и удалять через вспомогательные методы, которые обновляют обе стороны сразу:
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this); // синхронизируем владеющую сторону
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null);
}
}
Вызывающий код работает только через customer.addOrder(order) — и обе стороны всегда согласованы.
В removeOrder при этом спрятана мина: orders.remove(order) ищет элемент через equals. Если equals у сущности написан по идентификатору, а заказ ещё не сохранён и id пустой, из коллекции удалится не тот объект — или не удалится ничего. Как писать equals и hashCode для сущностей, разобрано в типичных ошибках.
Ту же механику видно и без базы — строку для вставки собирает только владеющая сторона:
живой пример
import java.util.ArrayList;
import java.util.List;
public class OwningSideDemo {
static class Customer {
long id = 7;
List<Order> orders = new ArrayList<>();
void addOrder(Order order) {
orders.add(order);
order.customer = this;
}
}
static class Order {
long id;
Customer customer;
Order(long id) {
this.id = id;
}
}
static String insert(Order order) {
Long fk = order.customer == null ? null : order.customer.id;
return "INSERT INTO orders (id, customer_id) VALUES (" + order.id + ", " + fk + ")";
}
public static void main(String[] args) {
Customer customer = new Customer();
Order lost = new Order(101);
customer.orders.add(lost);
Order saved = new Order(102);
customer.addOrder(saved);
System.out.println(insert(lost));
System.out.println(insert(saved));
System.out.println("в коллекции заказов: " + customer.orders.size());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Оба заказа лежат в коллекции, но в базу они уедут по-разному: у первого customer_id пустой, у второго — 7. В памяти картина выглядит правильной, в таблице — нет.
Отдельно стоит показать вариант, который часто нужен и почти никогда не упоминается: однонаправленный @OneToMany с @JoinColumn. Коллекция есть у родителя, ссылки в дочерней сущности нет, а лишней таблицы-связки не возникает:
@Entity
public class Order {
@OneToMany
@JoinColumn(name = "order_id")
private List<OrderLine> lines = new ArrayList<>();
}
Колонка order_id живёт в таблице позиций, как и при двунаправленной связи, но управляет ею родитель. Это удобно для настоящих агрегатов: позиция заказа сама по себе не существует, ссылка на заказ ей не нужна, и лишнего поля в классе не появляется.
Цена: Hibernate вставляет позицию, а потом отдельным UPDATE проставляет ей order_id — то есть два запроса вместо одного. На старых версиях это было заметно; в Hibernate 6 поведение лучше, но проверять стоит по журналу запросов. Если позиций много и вставляются они часто, двунаправленная связь с mappedBy остаётся дешевле.
И третий вариант, который в курсе с уклоном в предметную область оказывается лучшим чаще, чем кажется: не делать связь объектом вовсе. Хранить в дочерней сущности идентификатор родителя (private long orderId), а собирать целое — запросом в репозитории. Тогда нет ни ленивых заместителей, ни синхронизации двух сторон, ни случайных обходов графа; границы агрегатов становятся явными, а не следуют из аннотаций. Двунаправленная связь оправдана, когда обе стороны действительно нужны в коде вместе, — а не по привычке.
FetchType по умолчанию
Hibernate выбирает стратегию загрузки исходя из типа связи:
| Аннотация | Стратегия по умолчанию |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
ToOne-связи загружаются сразу — это часто удивляет. Достаёт их Hibernate двумя разными способами. Когда вы зовёте em.find(Order.class, 1L), связь подмешивается в тот же запрос джойном — лишнего запроса нет, просто строка шире. А вот на результатах JPQL-запроса и на любом списке он идёт отдельным SELECT на каждую строку: сто заказов с двумя @ManyToOne-полями превращаются в двести дополнительных походов в базу.
Практическое правило: всегда явно указывайте FetchType.LAZY на @ManyToOne и @OneToOne, а нужные данные подгружайте через JOIN FETCH в запросе.
Одна оговорка к правилу: на обратной стороне @OneToOne(mappedBy = ...) ленивость не работает. Внешнего ключа на этой стороне нет, и, чтобы решить, положить в поле объект или null, Hibernate обязан сходить в базу прямо сейчас. Вы поставите LAZY, а запрос всё равно уйдёт — и будете долго искать, откуда он взялся.
Подробнее о стратегиях загрузки — в статье Ленивая и жадная загрузка.
@ManyToMany
@ManyToMany создаёт промежуточную таблицу. Владеющую сторону выбираете сами — на ней ставите @JoinTable, на обратной — mappedBy.
Третья таблица берётся не из воздуха: student_course хранит по колонке на каждую сторону, и одна её строка это один факт записи студента на курс.
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "student_course", // имя промежуточной таблицы
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses") // обратная сторона
private Set<Student> students = new HashSet<>();
}
@ManyToMany удобна для простых случаев, но как только в промежуточной таблице появляются дополнительные столбцы (дата записи, статус) — её нужно превращать в отдельную сущность с двумя @ManyToOne.
Три вещи про @ManyToMany, без которых он приносит сюрпризы.
Почему Set, а не List. При изменении коллекции, объявленной как List, Hibernate не умеет «добавить одну строку» в таблицу связи: он удаляет все строки связки для этой сущности и вставляет их заново. У товара с полусотней меток добавление одной метки превращается в пятьдесят одну операцию. С Set этого не происходит — строки добавляются и удаляются точечно. Поэтому Set здесь не стилистика, а требование.
Как превратить связь в сущность. Как только у связи появляется хоть одно собственное поле (когда добавили, кто добавил, количество), @ManyToMany больше не годится — нужна отдельная сущность с двумя @ManyToOne:
@Entity
public class OrderLine {
@EmbeddedId
private OrderLineId id; // record OrderLineId(long orderId, long productId)
@MapsId("orderId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Order order;
@MapsId("productId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Product product;
private int quantity;
}
Ключ здесь составной, из двух внешних, и @MapsId связывает поля ключа с самими связями — тогда не приходится хранить идентификаторы дважды. Альтернатива — обычный суррогатный @Id плюс уникальный индекс по паре; она проще в коде и удобнее, если на такую строку кто-то ссылается.
Что делать с существующей таблицей связи. Ничего страшного: таблица уже есть, в ней две колонки-ключа. Переход на сущность — это миграция, которая добавляет к ней недостающие колонки (и, если выбран суррогатный ключ, новый идентификатор), плюс правка кода. Данные остаются на месте — меняется только то, как на них смотрит приложение.
cascade и orphanRemoval
cascade определяет, какие операции над родителем автоматически применяются к дочерним сущностям. Начать стоит с того, чего в списке не видно: по умолчанию не включён ни один каскад. Добавили новый заказ в customer.getOrders() и сохранили клиента — в базе не появится ничего, пока нужную операцию не перечислить руками. Чаще всего нужен PERSIST: строки заказа сохраняются вместе с заказом, и это безопасно. MERGE переносит изменения на дочерние при слиянии отсоединённого родителя. REMOVE удаляет дочерние вместе с родителем, и вот его берут осторожно: каскад по ссылке на общую сущность, скажем от заказа к покупателю, удалит покупателя вместе с заказом. ALL включает всё сразу, и брать его стоит только для сущностей, которые без родителя не существуют.
orphanRemoval = true — не дополнение к cascade, а отдельный переключатель: если дочернюю сущность убрать из коллекции, она удаляется из базы сама, и CascadeType.REMOVE для этого не нужен. Полезно, когда дочерняя сущность не имеет смысла без родителя, — и опасно ровно по той же причине: нечаянный orders.clear() сотрёт строки.
Один remove у коллекции доводит дело до DELETE: с orphanRemoval строка, оставшаяся без родителя, удаляется из базы на flush, а не просто теряет ссылку.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
Типичные грабли с каскадированием разобраны в распространённых ошибках Hibernate.
Коротко
@ManyToOne— владеющая сторона; именно здесь хранится FK и ставится@JoinColumn.@OneToMany(mappedBy = "...")— обратная сторона;mappedByуказывает на поле владеющей стороны.- Без
mappedByи без@JoinColumnHibernate создаст промежуточную таблицу — как при@ManyToMany. - Для двунаправленных связей нужно обновлять обе стороны — используйте хелпер-методы.
@ManyToOneи@OneToOneпо умолчаниюEAGER— переключайте наLAZYи подгружайте черезJOIN FETCH.cascade = CascadeType.ALL+orphanRemoval = true— удобный дуэт для агрегатов, где дочерние сущности живут только внутри родителя.- В базе связь — это одна колонка на стороне «многих»;
optional = falseи@JoinColumn(nullable = false)ставят парой, иначе Hibernate строит планы на обещании, которого схема не держит. - Однонаправленный
@OneToManyс@JoinColumnобходится без таблицы-связки ценой отдельногоUPDATE, а часто честнее хранить идентификатор родителя и собирать целое запросом. @ManyToManyтолько наSet(наListлюбое изменение переписывает всю связку); появилось поле у связи — нужна сущность с двумя@ManyToOneи@MapsId.
Что почитать дальше
- Маппинг сущностей — аннотации
@Column,@Table, типы, конвертеры. - Ленивая и жадная загрузка — когда и как загружаются связанные объекты.
- Проблема N+1 запросов — как связи порождают лавину запросов и как это остановить.
- Spring Data JPA — репозитории и производные запросы поверх Hibernate.