← назад к разделу

В реляционной базе данных таблицы соединяются через внешние ключи. В JPA то же самое выражается аннотациями на полях сущностей. Здесь разберём, как это устроено, на что обратить внимание и где легко ошибиться.

Customer id = 7 @OneToMany(mappedBy="customer") Order id = 101 @ManyToOne @JoinColumn таблица orders — что уйдёт в базу при flush id customer_id 101 1. объекты созданы, связь не проставлена ни на одной стороне orders: [ ] customer: null NULL FK брать неоткуда 2. customer.getOrders().add(order) — только коллекция orders: [ Order#101 ] customer: null коллекция NULL сторона mappedBy — только чтение, в SQL она не попадает 3. order.setCustomer(customer) — владеющая сторона orders: [ Order#101 ] customer: Customer#7 коллекция FK NULL поле customer заполнено — flush знает, что писать 4. flush: значение FK берётся со стороны @ManyToOne orders: [ Order#101 ] customer: Customer#7 коллекция FK 7 INSERT INTO orders VALUES (101, 7)

Связь для базы — это значение столбца 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 выбирает стратегию загрузки исходя из типа связи:

АннотацияСтратегия по умолчанию
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

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 Student id = 7 Course id = 42

Третья таблица берётся не из воздуха: 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() сотрёт строки.

orders.remove(line) убрали из коллекции сирота родителя больше нет flush конец транзакции DELETE order_lines строка удалена

Один 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 и без @JoinColumn Hibernate создаст промежуточную таблицу — как при @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.

Что почитать дальше