В реляционной базе данных таблицы соединяются через внешние ключи. В 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 выбирает стратегию загрузки исходя из типа связи:
| Аннотация | Стратегия по умолчанию |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
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указывает на поле владеющей стороны.- Без
mappedByHibernate создаст промежуточную таблицу — как при@ManyToMany. - Для двунаправленных связей нужно обновлять обе стороны — используйте хелпер-методы.
@ManyToOneи@OneToOneпо умолчаниюEAGER— переключайте наLAZYи подгружайте черезJOIN FETCH.cascade = CascadeType.ALL+orphanRemoval = true— удобный дуэт для агрегатов, где дочерние сущности живут только внутри родителя.
Что почитать дальше
- Маппинг сущностей — аннотации
@Column,@Table, типы, конвертеры. - Ленивая и жадная загрузка — когда и как загружаются связанные объекты.
- Проблема N+1 запросов — как связи порождают лавину запросов и как это остановить.
- Spring Data JPA — репозитории и производные запросы поверх Hibernate.