JavaRush /Курси /Spring Data JPA /Навігація в JPA: одна чи дві сторони

Навігація в JPA: одна чи дві сторони

Spring Data JPA
Рівень 8 , Лекція 3
Відкрита

1. Навігація в сутностях: як обрати

Коли ви вперше бачите зв’язки JPA, з’являється цілком природне бажання: «А давайте зробимо так, щоб усюди можна було ходити туди-сюди». Товар знає категорію — отже, категорія має знати товари. Позиція знає замовлення — отже, замовлення має знати позиції. Начебто логічно: чим більше посилань, тим зручніше. Але в ORM зручність майже завжди приходить у комплекті з обов’язками.

У JPA навігація — це не просто «щоб було зручно ходити по об’єктах». Це частина об’єктної моделі, яку ви підтримуєте кодом. Вона впливає на те, як ви створюєте об’єкти, переносите їх між сутностями, пишете toString(), уникаєте розсинхронізації та тримаєте інваріанти. Тому тут дуже корисне правило початківця, яке, на жаль, рідко пишуть у перших туторіалах: починайте з мінімально достатньої навігації і розширюйте її лише тоді, коли вона справді робить модель кращою, а не просто «красивішою».

2. Мінімальна модель: лише @ManyToOne

Почнемо з найспокійнішої й найпередбачуванішої форми зв’язку: дитина зберігає посилання на батька. Це @ManyToOne. На рівні SQL це виглядає максимально чесно: FK живе в таблиці дитини. На рівні Java це виглядає як звичайне поле-посилання. І найприємніше: якщо у вас лише @ManyToOne, ви майже не можете «випадково» розсинхронізувати дві сторони, тому що другої сторони просто немає.

Уявіть наш міні-магазин. Товар (Product) належить категорії (Category). Усередині Product це виглядає приблизно так (показую лише важливу частину, решту ми вже робили в попередні дні):

import jakarta.persistence.ManyToOne;

// Керівна сторона зв’язку: FK живе в таблиці Product.
@ManyToOne
private Category category;

І цього справді може бути достатньо, якщо ваш основний сценарій звучить як «у товару є категорія», «у позиції замовлення є замовлення», «у позиції замовлення є товар». Тобто коли ви частіше рухаєтеся ланцюжком від дитини до батька.

Тепер зверніть увагу на важливий психологічний момент. Дуже багато початківців сприймають відсутність зворотної колекції як «модель неповна». Але модель стає неповною лише тоді, коли ви справді намагаєтеся розв’язати сценарій використання і раптом розумієте: «Мені потрібно з категорії швидко отримати товари прямо через об’єкт». Якщо такого сценарію немає — у вас немає проблеми, ви просто завчасно її собі вигадали.

У практичному коді це відображається так: щоб «прикріпити товар до категорії», вам потрібно оновити лише одне поле на товарі. Наприклад, у CatalogService (який ми почали збирати на рівні 7) можна написати цілком прямолінійний метод:

import org.springframework.stereotype.Service;

@Service
public class CatalogService {

    public void assignCategory(Product product, Category category) {
        // Оновлюємо керівну сторону зв’язку (Product.category).
        // Цього достатньо, бо другої сторони в моделі немає.
        product.setCategory(category);
    }
}

Так, тут я навмисно не показую репозиторії та save(): сьогодні нам важлива форма моделі, а не те, як саме ви збережете зміни. Суть у тому, що в односпрямованій моделі у вас немає дилеми «а колекцію оновили?». Ви оновили керівну сторону — і все.

3. Ціна двосторонності: «друга правда»

Тепер додамо зворотний бік. Нехай Category теж містить колекцію товарів. Технічно це виглядає так:

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

// Зворотний бік зв’язку: читаємо «список товарів категорії» з об’єктної моделі.
// Важливо: керівною стороною все одно залишається Product.category.
@OneToMany(mappedBy = "category")
private List<Product> products = new ArrayList<>(); // Ініціалізація захищає від NPE під час додавання та видалення

У цей момент зазвичай звучить внутрішня радість: «О! Тепер у категорії є getProducts()». Але разом із getProducts() ви отримали другу «точку зору на реальність». У базі даних FK один. Керівна сторона зв’язку — все одно Product.category. А колекція на Category — це відображення зв’язку в пам’яті.

На механічному рівні це ми вже розібрали: колекція з mappedBy сама FK не пише. Тут важливий інший висновок — разом із зручнішою навігацією ви берете на себе обов’язок тримати поле і колекцію узгодженими. Якщо обмежитися одним category.getProducts().add(product), у пам’яті здаватиметься, що зв’язок є, а FK у базі може залишитися попереднім. Тому двобічна модель майже одразу потребує helper-методу. Він виглядає дуже просто, але економить купу нервів:

public void addProduct(Product product) {
    // 1) Оновлюємо колекцію на батьку (зворотний бік).
    products.add(product);

    // 2) Оновлюємо поле на дитині (керівна сторона, саме вона впливає на FK).
    product.setCategory(this);
}

З погляду початківця це схоже на ритуал. Але насправді це чесна інженерна ціна двобічності: якщо у вас у пам’яті два вказівники, ви повинні підтримувати їх узгодженість. Це як зберігати один і той самий номер телефону у двох записниках: зручно, доки ви не змінили номер і не забули оновити другий.

І ще один важливий наслідок. Двобічність майже завжди робить об’єктний граф циклічним: категорія містить товари, товар містить категорію. Це може несподівано спливати там, де ви цього не чекаєте, наприклад у toString().

Анти-приклад, який на перший погляд здається безневинним:

@Override
public String toString() {
    // Обережно: якщо Product.toString() друкує category,
    // тут легко отримати нескінченну рекурсію.
    return "Category{id=" + id + ", products=" + products + "}";
}

Якщо у Product теж є toString(), який друкує категорію, ви можете отримати нескінченну рекурсію. Ми не занурюємося в цю тему глибоко, але важливо зафіксувати: двобічна навігація робить такі сюрпризи ймовірнішими.

4. Двобічність для parent/child: CustomerOrder -> OrderItem

Тепер до найближчої до практики частини: як зрозуміти, що двобічність потрібна, а не просто «можна»? Дуже проста евристика: двобічна навігація зазвичай виправдана, коли батьківська сутність у своїй предметній логіці працює з набором дітей як із частиною себе. Тобто діти не виглядають самостійними об’єктами «поза батьком», а є вмістом батька.

У нашому проєкті CustomerOrder і OrderItem — чудовий приклад. Замовлення без позицій зазвичай не має сенсу. Позиції замовлення живуть усередині замовлення. Навіть якщо ви не занурюєтеся в DDD, логіка тут зрозуміла і на побутовому рівні: замовлення — це коробка, позиції — це те, що лежить у коробці.

Тому колекція позицій у замовленні — не примха, а відображення моделі:

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

// Зворотний бік: замовлення «вміщує» позиції в об’єктній моделі.
// Керівною стороною залишається OrderItem.customerOrder.
@OneToMany(mappedBy = "customerOrder")
private List<OrderItem> items = new ArrayList<>();

Далі майже неминуче з’являється helper-метод:

public void addItem(OrderItem item) {
    // Тримаємо обидві сторони зв’язку узгодженими в одному місці.
    items.add(item);              // зворотний бік
    item.setCustomerOrder(this);  // керівна сторона (FK)
}

І ось тепер створення замовлення в коді починає виглядати природно. Навіть початківець, який у JPA ще не дуже орієнтується, прочитає й зрозуміє:

CustomerOrder order = new CustomerOrder();
order.setCustomerEmail("alice@example.com"); // базові дані замовлення

OrderItem item = new OrderItem();
item.setQuantity(2);          // бізнес-дані позиції
item.setProduct(product);     // позиція знає товар (зазвичай @ManyToOne)

order.addItem(item);          // важливо: зв’язуємо через helper, щоб оновилися обидві сторони

Це схоже на звичайну об’єктну модель, без відчуття «я пишу SQL на Java».

Тут достатньо побачити: як модель об’єктів, двобічний зв’язок справді допомагає. Окреме питання — що батьківська сутність має право робити з життєвим циклом позицій: зберігати їх разом із собою, видаляти чи залишати. Це вже інша вісь рішення, і вона не випливає автоматично із самої навігації.

Якщо ж ви залишите тільки @ManyToOne на OrderItem, то в замовлення не буде «вмісту» у вигляді колекції. Створення замовлення перетвориться на більш конвеєрну процедуру: створити замовлення, зберегти, створити item, зв’язати, зберегти item… Це теж можливо, але це вже не модель замовлення як об’єкта, а модель «набір записів у таблицях». Іноді це доречно, але для нашого навчального проєкту і для типового домену замовлень — частіше гірше.

5. Двобічність і довідники: Category -> Product

Тепер приклад, де двобічність не очевидна: категорія і товари. Категорія в нашому домені — це довідник. Товар як сутність каталогу цілком самостійний: у нього є sku, ціна, статус тощо. Він може змінити категорію, категорію можуть вимкнути, а товари при цьому не «зникають із системи». У нашій базовій моделі товар не має перетворюватися на «сироту без категорії»; звична операція тут — переназначити його іншій категорії.

І ось тут виникає важливе питання: чи потрібна Category.products усередині сутності? Іноді так, але дуже часто це просто «щоб було зручно», без чіткого бізнес-сенсу.

Якщо ви залишаєте тільки Product.category, модель стає простішою. Переміщення товару в іншу категорію — це просто зміна одного посилання:

public void moveProduct(Product product, Category newCategory) {
    // Односпрямована модель: змінюємо лише керівну сторону зв’язку.
    product.setCategory(newCategory);
}

Якщо ж у вас двобічний зв’язок і ви чесно хочете тримати об’єктну модель узгодженою, то переміщення товару перетворюється на дві дії: прибрати зі старої категорії і додати в нову. Причому «стару категорію» ще потрібно пам’ятати й оновлювати теж. Навіть якщо показати ідею в лоб, не переходячи до helper-методів, коду вже більше:

public void moveProduct(Product product, Category newCategory) {
    // У двобічній моделі важливо синхронізувати обидві сторони.
    Category oldCategory = product.getCategory();

    // Оновлюємо зворотні колекції (у пам’яті).
    oldCategory.getProducts().remove(product);
    newCategory.getProducts().add(product);

    // Оновлюємо керівну сторону (саме вона впливає на FK у БД).
    product.setCategory(newCategory);
}

Тут важливий сам факт: окрім поля на товарі, вам доводиться пам’ятати ще й про стару та нову колекції. Навіть без cascade та інших тем це вже виглядає як «ніби проста операція, а коду стало більше». І це саме той сигнал, який вам потрібен: двобічність має виправдовувати свою ціну.

Є ще один нюанс: категорія, на відміну від замовлення, рідко «керує життєвим циклом товару». Товар не має видалятися лише тому, що ви прибрали його з категорії. Якщо в моделі є колекція products, дуже легко почати мислити «категорія володіє товарами», а це вже спірне твердження для звичайного каталогу. Тобто двобічність може непомітно підштовхнути вас до неправильної моделі володіння, а потім це спливе в наступній темі — життєвому циклі.

Тому хороша практична позиція звучить так: для довідників часто достатньо односпрямованого зв’язку (товар знає категорію), а зворотну колекцію варто додавати лише якщо категорія справді поводиться як «контейнер», який часто керує набором товарів як частиною своєї логіки.

6. Як вибрати навігацію: пам’ятка і схема

Коли ви на початку шляху, хочеться якогось правила. Абсолютного правила немає (інакше не було б половини дискусій навколо JPA), але є робоча пам’ятка, якої вистачає для рівня junior і навчального проєкту. Я люблю формулювати її так: не робіть двобічний зв’язок лише заради читання — робіть його заради моделі та інваріантів.

Нижче — невелике порівняння, яке можна тримати перед очима:

Питання Односпрямована (@ManyToOne лише на дитині) Двобічна (@ManyToOne + @OneToMany(mappedBy))
Скільки «точок істини» у пам’яті? Одна (поле на дитині) Дві (поле на дитині + колекція на батьківському об’єкті)
Ризик розсинхронізації Мінімальний Реальний, потрібні helper-методи
Код створення графа об’єктів Іноді більш «ручний» Часто більш «природний» (особливо для parent/child)
Підходить для довідників Часто так Іноді, але обережно
Підходить для «контейнерів» (замовлення і його позиції) Можна, але незручніше Зазвичай так

А ось «дерево рішень» у вигляді схеми. Воно навмисно просте: ми не обговорюємо завантаження, оптимізацію та інші майбутні теми — лише сенс моделі та підтримуваність коду.

flowchart TD
    A["Хочемо зв’язати сутності"] --> B{"Батьківська сутність справді керує
набором дітей як частиною себе?"} B -->|Так| C["Двобічний зв’язок доречний:
@OneToMany(mappedBy) + helper-методи"] B -->|Ні| D{"Потрібна навігація від батьківської сутності до дітей
прямо в об’єктній моделі?"} D -->|Ні| E["Залишаємо лише @ManyToOne
модель простіша, менше обов’язків"] D -->|Так| F["Додаємо @OneToMany, але свідомо:
готові підтримувати синхронізацію"]

Якщо вам хочеться дуже короткої «мантри», то вона така: двобічна навігація — це не «більше зручності», а «більше відповідальності». І відповідальність має сенс брати тоді, коли вона зменшує хаос у коді й краще відображає предметну область.

7. Типові помилки під час вибору навігації

Помилка №1: «Давайте зробимо двобічним усе, щоб було зручно».
Такий підхід спочатку здається дорослим і «як у справжніх проєктах», але зазвичай закінчується тим, що ви отримуєте величезний граф об’єктів, де кожен знає про кожного. У результаті стає складніше писати helper-методи, складніше тримати модель узгодженою, і будь-яка операція перетворюється на танець «онови тут, не забудь там».

Помилка №2: вважати, що колекція на батькові — це керівна сторона.
Це одна з найболючіших плутанин. У парі Category.products і Product.category керівною стороною залишається Product.category. Якщо змінювати лише колекцію, ви створюєте гарну картинку в пам’яті, але не факт, що вона перетвориться на правильний FK у базі. Коли таке трапляється, початківець зазвичай звинувачує «JPA-магію», хоча проблема насправді в моделі володіння.

Помилка №3: додали двобічність, але не ввели helper-методи.
Технічно ви можете руками в сервісі робити category.getProducts().add(product) і product.setCategory(category). Практично це майже завжди призводить до розсинхронізації: в одному місці забули оновити поле, в іншому — колекцію, а за тиждень ви вже не пам’ятаєте, де саме «правильно». Helper-метод у entity — це не розкіш, а спосіб тримати правила зв’язку в одному місці.

Помилка №4: намагатися «спростити» модель і прибрати колекцію там, де батько керує дітьми.
Це протилежна крайність: ви залишаєте лише @ManyToOne на дитині навіть для відносин типу CustomerOrder -> OrderItem, і потім бізнес-логіка замовлення починає виглядати як робота з розрізненими рядками таблиць. У підсумку сервісний код росте, гірше читається і стає крихкішим, бо інваріанти замовлення (наприклад, «замовлення має містити щонайменше одну позицію») складніше підтримувати без явної колекції в моделі.

Помилка №5: очікувати, що двобічність автоматично означає «батько володіє життєвим циклом».
Сама по собі колекція нічого не говорить про життєвий цикл. Категорія може мати колекцію товарів, але це не означає, що товари треба видаляти під час видалення категорії. Дуже важливо розділяти «навігацію» і «життєвий цикл». Однакова форма зв’язку ще не означає однакові правила життя об’єктів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ