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

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

Почему связи — не просто аннотации

Когда разработчик впервые видит @OneToMany, кажется: поставил аннотацию — готово. На деле у каждой связи есть владеющая сторона (owning side) — та, которая управляет внешним ключом в базе. Если не указать её правильно, Hibernate либо создаст лишние столбцы, либо просто не сохранит связь.

Вторая сложность: по умолчанию часть связей загружается сразу (EAGER), часть — лениво (LAZY). Неправильное ожидание здесь — прямой путь к LazyInitializationException и проблеме N+1 запросов.

@ManyToOne — самая простая сторона

@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 придумает имя сам по правилам (<поле>_<id>), что обычно совпадает, но лучше быть явным.

Важно: у @ManyToOne стратегия по умолчанию — EAGER. Это значит, что при загрузке Order Hibernate сразу подтянет Customer — даже если он не нужен. Явный fetch = FetchType.LAZY исправляет это.

@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; не создавай никакой дополнительной таблицы или столбца, просто читай через него».

Без mappedBy Hibernate создаст промежуточную таблицу связи (customer_orders) — как при @ManyToMany. Это распространённая ошибка.

Короткая формула:

mappedBy ставится на стороне, которая не владеет внешним ключом.

Однонаправленная vs двунаправленная связь

Однонаправленная — есть только одна аннотация на одной сущности:

// Только в Order — Customer ничего не знает об orders
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;

Двунаправленная — аннотации на обеих сторонах, mappedBy на обратной:

// В Order:
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;

// В Customer:
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();

Двунаправленная связь удобнее для навигации, но требует аккуратности: 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) — и обе стороны всегда согласованы.

FetchType по умолчанию

Hibernate выбирает стратегию загрузки исходя из типа связи:

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

ToOne-связи загружаются сразу — это часто удивляет. Если у сущности несколько @ManyToOne-полей, каждый SELECT потянет за собой дополнительные запросы. Практическое правило: всегда явно указывайте FetchType.LAZY на @ManyToOne и @OneToOne, а нужные данные подгружайте через JOIN FETCH в запросе.

Подробнее о стратегиях загрузки — в статье Ленивая и жадная загрузка.

@ManyToMany

@ManyToMany создаёт промежуточную таблицу. Владеющую сторону выбираете сами — на ней ставите @JoinTable, на обратной — mappedBy.

@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.

cascade и orphanRemoval

cascade определяет, какие операции над родителем автоматически применяются к дочерним сущностям. Чаще всего используют:

  • CascadeType.PERSIST — дочерние сохраняются вместе с родителем.
  • CascadeType.MERGE — дочерние обновляются вместе с родителем.
  • CascadeType.REMOVE — дочерние удаляются при удалении родителя.
  • CascadeType.ALL — все операции.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();

orphanRemoval = true дополняет cascade: если дочернюю сущность убрать из коллекции — она удаляется из базы автоматически. Полезно, когда дочерняя сущность не имеет смысла без родителя.

@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();

Типичные грабли с каскадированием разобраны в распространённых ошибках Hibernate.

Коротко

  • @ManyToOne — владеющая сторона; именно здесь хранится FK и ставится @JoinColumn.
  • @OneToMany(mappedBy = "...") — обратная сторона; mappedBy указывает на поле владеющей стороны.
  • Без mappedBy Hibernate создаст промежуточную таблицу — как при @ManyToMany.
  • Для двунаправленных связей нужно обновлять обе стороны — используйте хелпер-методы.
  • @ManyToOne и @OneToOne по умолчанию EAGER — переключайте на LAZY и подгружайте через JOIN FETCH.
  • cascade = CascadeType.ALL + orphanRemoval = true — удобный дуэт для агрегатов, где дочерние сущности живут только внутри родителя.

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

  • Маппинг сущностей — аннотации @Column, @Table, типы, конвертеры.
  • Ленивая и жадная загрузка — когда и как загружаются связанные объекты.
  • Проблема N+1 запросов — как связи порождают лавину запросов и как это остановить.
  • Spring Data JPA — репозитории и производные запросы поверх Hibernate.