Вопрос 24 · Раздел 16

В чём особенности bidirectional relationships

Bidirectional relationships (двунаправленные связи) позволяют навигацию между сущностями в обоих направлениях. Они требуют ручной синхронизации обеих сторон и правильного исполь...

Версии по языкам: English Russian Ukrainian

Обзор

Bidirectional relationships (двунаправленные связи) позволяют навигацию между сущностями в обоих направлениях. Они требуют ручной синхронизации обеих сторон и правильного использования mappedBy.


🟢 Junior Level

Что такое bidirectional relationship

Это связь, где обе стороны знают друг о друге.

// Order знает о User
@Entity
public class Order {
    @ManyToOne
    private User user;
}

// User знает о Orders
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

Owner side и Inverse side

Owner side — сторона с FK (Order с @ManyToOne).

Inverse side — сторона с mappedBy (User с @OneToMany(mappedBy = "user")).

// Owner — содержит @JoinColumn
@ManyToOne
@JoinColumn(name = "user_id")
private User user;

// Inverse — содержит mappedBy
@OneToMany(mappedBy = "user")
private List<Order> orders;

🟡 Middle Level

Синхронизация обязательна

@Entity
public class User {
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Order> orders = new ArrayList<>();

    // Helper methods — обязательно!
    public void addOrder(Order order) {
        orders.add(order);
        order.setUser(this);  // ОБЯЗАТЕЛЬНО!
    }

    public void removeOrder(Order order) {
        orders.remove(order);
        order.setUser(null);  // ОБЯЗАТЕЛЬНО!
    }
}

Почему рассинхронизация — проблема

// ❌ Рассинхронизация
User user = new User();
Order order = new Order();
user.getOrders().add(order);

// order.getUser() == null → FK не установится!
entityManager.persist(user);  // При cascade=ALL order сохранится,
// но order.getUser() = null → FK column = NULL → constraint violation или лишний UPDATE

// ✅ Синхронизация через helper
User user = new User();
Order order = new Order();
user.addOrder(order);  // setter + setUser

// order.getUser() == user → FK установится
entityManager.persist(user);  // cascade → order сохранится

Типичные ошибки

// ❌ Рассинхронизация
user.getOrders().add(order);
// order.user == null → FK не установится

// ❌ mappedBy на owner side
@ManyToOne(mappedBy = "orders")  // ❌ mappedBy только на inverse
private User user;

// ❌ Без helper methods
order.setUser(user);
// user.orders не содержит order → несогласованность

🔴 Senior Level

equals/hashCode для bidirectional

// Используйте business key, НЕ ID!
@Entity
public class User {
    @Column(unique = true, nullable = false)
    private String email;  // business key

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return email != null && email.equals(user.email);
    }

    @Override
    public int hashCode() {
        return email != null ? email.hashCode() : 0;
    }
}

Почему не ID для equals/hashCode

// ❌ ID для equals/hashCode — проблемы
@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof User)) return false;
        return id != null && id.equals(((User) o).id);
    }
}

// Проблема:
// 1. Transient entity (id=null) != Transient entity (id=null)
// 2. HashSet теряет entity после persist (id меняется)
// 3. Невозможно добавить в HashSet до persist

Если нет natural/business key: используйте UUID, назначаемый при конструировании объекта.

public class User {
    private final UUID uuid = UUID.randomUUID();
    // equals/hashCode по uuid
}

Паттерн бизнес-ключа

@Entity
public class OrderItem {
    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String sku;  // business key

    @ManyToOne
    private Order order;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderItem)) return false;
        OrderItem that = (OrderItem) o;
        return sku != null && sku.equals(that.sku);
    }

    @Override
    public int hashCode() {
        return sku != null ? sku.hashCode() : 0;
    }
}

Продвинутая синхронизация

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        if (items.remove(item)) {
            item.setOrder(null);  // важно для консистентности
        }
    }

    // Для replace
    public void replaceItems(List<OrderItem> newItems) {
        items.clear();  // orphanRemoval удалит старые
        for (OrderItem item : newItems) {
            addItem(item);
        }
    }
}

Performance considerations

Bidirectional overhead:
- Helper methods (minimal)
- Память для обеих ссылок
- Синхронизация при изменении

Benefits:
- Навигация в обе стороны
- Cascade с orphanRemoval
- Полная модель домена

Best Practices

✅ Helper methods для синхронизации
✅ Business key для equals/hashCode
✅ mappedBy на inverse side
✅ Синхронизация обеих сторон
✅ Инициализация коллекций

❌ ID для equals/hashCode
❌ Без helper methods
❌ mappedBy на owner side
❌ Рассинхронизация
❌ Прямое изменение коллекций

🎯 Шпаргалка для интервью

Обязательно знать:

  • Bidirectional = навигация в обе стороны, требует синхронизации обеих сторон
  • Owner side — с FK (@ManyToOne), Inverse side — с mappedBy (@OneToMany)
  • Helper methods ОБЯЗАТЕЛЬНЫ: addOrder/addItem синхронизируют обе стороны
  • equals/hashCode по business key (email, SKU), НЕ по ID — ID меняется при persist
  • Для equals/hashCode без business key — использовать UUID при конструировании
  • mappedBy только на inverse side — на owner side такого атрибута нет

Частые уточняющие вопросы:

  • Почему ID для equals/hashCode плох? Transient entity (id=null) != Transient entity, HashSet теряет после persist
  • Что если рассинхронизация? FK не установится или будет NULL — constraint violation или лишний UPDATE
  • Business key что если нет? UUID при создании объекта — стабильный identifier до persist
  • Performance overhead bidirectional? Minimal: helper methods + память для ссылок, benefits outweigh costs

Красные флаги (НЕ говорить):

  • «ID для equals/hashCode» — проблемы с HashSet/HashMap, потеря entities
  • «Без helper methods» — рассинхронизация, FK не установится
  • «Прямое изменение коллекций» — bypass helper methods → несогласованность
  • «mappedBy на owner side» — не существует, только на inverse

Связанные темы:

  • [[23. Как правильно использовать @OneToMany и @ManyToOne]]
  • [[22. Что такое orphan removal]]
  • [[25. Как избежать бесконечной рекурсии при сериализации Entity]]
  • [[20. Как работают каскадные операции (Cascade)]]