JavaRush /Курси /Spring Data JPA /Owning side: хто зап...

Owning side: хто записує зв’язок у БД

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

1. Термін owning side: зміст у коді

Щойно ви додаєте @OneToMany і бачите колекцію products всередині Category, мозок природно робить висновок: «Ага, отже категорія “володіє” товарами». З погляду бізнес-логіки це справді так. Але ORM живе у двох світах одночасно — Java-об’єктів і SQL-таблиць — і слово owning тут раптом означає значно прозаїчнішу річ: хто записує зовнішній ключ. У цій лекції зручно перекладати owning side як керівна сторона зв’язку. Ідеться не про життєвий цикл об’єктів і не про те, хто “головніший” у домені, а лише про те, за яким полем JPA пише FK у БД. Якщо не розібратися зараз, далі ви писатимете код, який виглядає правильно, компілюється, а в базі поводиться, мов кіт: робить вигляд, що вас не існує.

Термін owning side (керівна сторона зв’язку) існує, щоб відповісти на одне просте технічне питання: яка сторона зв’язку є «офіційною» для бази даних? Тобто на зміни якої сторони JPA/Hibernate справді орієнтується, коли вирішує, чи надсилати UPDATE ... SET fk = ... у БД.

Важливо відчути різницю: у Java двосторонній зв’язок — це два посилання, а в SQL найчастіше один зовнішній ключ. Отже, і «власник» на рівні БД зазвичай один. Хто видаляє кого разом із ким — окреме питання; тут нас цікавить лише запис FK.

2. FK в одній таблиці: SQL-модель owning side

Щоб owning side перестала бути містикою, на хвилину відвернімося від ORM і повернімося до таблиць. Так, саме в цей момент програмісту, який працює з ORM, корисно згадати SQL і не робити вигляд, ніби «анотація все вирішить». Зовнішній ключ — це звичайна колонка в одній таблиці. Його не розмазують по двох таблицях «заради справедливості». Він лежить там, де його створили.

Наприклад, зв’язок Category (1) -> Product (many) на SQL-рівні зазвичай виглядає так: у таблиці product є колонка category_id, яка посилається на category.id. Тобто зовнішній ключ зберігається на боці товару. Для замовлень аналогічно: у таблиці order_item зберігається customer_order_id, який вказує на customer_order.id.

Можна намалювати собі просту схему, прямо як на серветці в кафе, де ви раптом вирішили обговорити архітектуру — таке теж буває:

erDiagram
    CATEGORY ||--o{ PRODUCT : "product.category_id -> category.id"
    CUSTOMER_ORDER ||--o{ ORDER_ITEM : "order_item.customer_order_id -> customer_order.id"

Якщо висловити ту саму думку табличкою, щоб мозок новачка не страждав, вийде так:

Зв’язок у домені FK-колонка в БД Де живе FK Хто записує FK у JPA
Product -> Category product.category_id у product поле Product.category
OrderItem -> CustomerOrder order_item.customer_order_id у order_item поле OrderItem.customerOrder

І саме це і є фундамент: керівна сторона зв’язку — це сторона, яка відповідає місцю, де в таблиці реально лежить FK.

3. Правило для @ManyToOne / @OneToMany

Зараз буде приємний момент: у нашому сьогоднішньому наборі відношень правило дуже просте. Ми не розглядаємо OneToOne і ManyToMany (це буде пізніше), тому можете тримати в голові одну робочу формулу:

У парі @ManyToOne / @OneToMany керівна сторона зв’язку — завжди там, де @ManyToOne.

Чому так? Тому що @ManyToOne сидить на боці, де за змістом є FK-колонка: у товару є category_id, у позиції замовлення — customer_order_id. А колекція @OneToMany(mappedBy = "...") — це дзеркало, зручний спосіб навігації, але не місце, де записується «офіційна правда» про зв’язок.

Подивіться на мінімальний каркас (у стилі нашого проєкту shop-data-jpa).

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
class Product {

    @Id
    private Long id;

    // Керівна сторона зв’язку: саме це поле відповідає FK-колонці product.category_id
    @ManyToOne
    private Category category;
}

А ось «дзеркало» з боку категорії:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class Category {

    @Id
    private Long id;

    // Зворотна сторона: колекція відображає зв’язок, але не керує FK у БД
    @OneToMany(mappedBy = "category")
    private List<Product> products = new ArrayList<>(); // ініціалізуємо, щоб не отримати NPE
}

З погляду JPA фраза mappedBy = "category" означає: «Оця колекція productsне власник. Власник там, де поле Product.category».

Це і є головний зміст owning side: на якій стороні ви маєте змінювати об’єктне посилання, щоб це перетворилося на SQL-зміну FK.

4. Баг: змінюємо лише inverse side — і зв’язок “не зберігається”

Давайте подивимося на ситуацію, яку проходять майже всі. Ви додали Category.products, зраділи колекції та написали приблизно таке (не в entity, а десь у сервісі або тесті):

// Змінюємо лише inverse side (mappedBy) — FK у БД від цього не зобов’язаний змінитися
category.getProducts().add(product);
categoryRepository.save(category);

На рівні людської логіки здається, що ви зробили все: товар додано до категорії, отже категорія тепер містить товар. Але на рівні бази даних ви… можливо, не зробили нічого, тому що ви не змінювали керівну сторону зв’язку, тобто product.category.

Hibernate мислить приблизно так: «Гаразд, у вас є Category.products, але вона mappedBy. Ви просто показали мені колекцію, яка відображає Product.category. А Product.category ви не чіпали. Отже, category_id у таблиці product змінювати не треба».

Щоб відчути це на рівні поведінки, корисно спеціально зробити мінідемо. Уявіть, що товар уже лежить в одній категорії, а ви хочете перенести його в іншу.

Ось спрощений фрагмент коду, наприклад, у тесті або в CommandLineRunner для навчального експерименту:

import java.math.BigDecimal;

// Категорія, у якій товар уже перебуває
Category oldCategory = new Category();
oldCategory.setName("Книги");
categoryRepository.save(oldCategory);

// Категорія, у яку хочемо перенести товар
Category newCategory = new Category();
newCategory.setName("Гаджети");
categoryRepository.save(newCategory);

// Товар уже збережено і справді посилається на oldCategory
Product p = new Product();
p.setName("Java 25 для сміливих");
p.setPrice(new BigDecimal("19.99"));
p.setCategory(oldCategory);
productRepository.save(p);

// Намагаємося "перенести" товар лише через inverse side нової категорії
newCategory.getProducts().add(p);
categoryRepository.save(newCategory);

У пам’яті JVM у вас справді newCategory.getProducts() тепер містить p. Але в базі даних FK у таблиці product може й далі вказувати на oldCategory, тому що ви не змінили керівну сторону зв’язку, тобто p.setCategory(newCategory).

Правильна зміна, яку JPA вважає «офіційною», виглядає ось так:

// Змінюємо керівну сторону зв’язку: саме це відобразиться в UPDATE по FK-колонці
p.setCategory(newCategory);
productRepository.save(p); // це про запис нового category_id у таблицю product

Якщо у вас увімкнені SQL-логи (а ми їх увімкнули раніше), ви побачите, що в другому варіанті з’являється зрозумілий SQL на кшталт:

-- FK оновлюється в таблиці product, тому що керівна сторона зв’язку — Product.category
update product set category_id = ? where id = ?

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

5. Узгодженість у пам’яті: друга сторона не синхронізується сама

Після попереднього розділу можна зробити поспішний висновок: «Чудово! Отже, я змінюватиму лише owning side, і все буде гаразд». На рівні бази даних — справді так: якщо ви завжди встановлюєте product.setCategory(category), FK буде коректним. Але виникає інша, тихіша проблема: ваша об’єктна модель у пам’яті стає неузгодженою.

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

Піймати це можна навіть без бази, чисто в Java-логіці:

// До зв’язування колекція порожня
System.out.println(c.getProducts().size()); // 0

// Змінюємо керівну сторону: тепер товар "знає" про категорію
p.setCategory(c);

// Але inverse side сама не оновиться, поки ви не зробите це вручну
System.out.println(c.getProducts().size()); // все ще 0
System.out.println(p.getCategory() == c);   // true

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

І ось тут ми підходимо до рятувального круга — helper-методів.

6. Helper-методи для синхронізації обох сторін

В ідеальному світі ви щоразу, коли зв’язуєте Category і Product, не забували б оновлювати обидві сторони. У реальному світі ви один раз забудете, другий раз забудете, а третій раз скажете: «Та ну його, давайте зробимо EAGER і JSON-серіалізацію…» — і десь на горизонті вже маячить сумний архітектор.

Helper-метод — це маленький метод усередині сутності, який робить одну просту річ: синхронізує обидві сторони зв’язку. Причому робить це в одному місці, щоб ви не розмазували «ритуал» по сервісах, контролерах і тестах.

Для Category -> Product логічний helper-метод зазвичай живе в Category, тому що бізнес-сенс такий: «додати товар до категорії». Але всередині він зобов’язаний оновити керівну сторону зв’язку, тобто product.category.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class Category {

    @Id
    private Long id;

    @OneToMany(mappedBy = "category")
    private List<Product> products = new ArrayList<>();

    public void addProduct(Product product) {
        // 1) Оновлюємо зворотну сторону: щоб у пам’яті категорія "бачила" товар
        products.add(product);

        // 2) Оновлюємо керівну сторону зв’язку: саме це відобразиться у FK-колонці в БД
        product.setCategory(this);
    }
}

Для Category -> Product у поточному навчальному мінішопі цього helper-методу достатньо, щоб показати принцип. Ми тримаємо Product.category обов’язковою, тож типовий сценарій тут — додати товар до категорії або переназначити його в іншу, а не робити товар «без категорії». Розрив зв’язку через null можна зустріти як загальний JPA-прийом, але для нашого поточного базового сценарію це не основний шлях. Якщо товар переноситься між двома категоріями і обидві колекції вже завантажені в пам’ять, стару колекцію теж потрібно оновити, інакше якийсь час існуватимуть дві «правди».

Те саме ми робитимемо в CustomerOrder, коли додаємо позицію замовлення. Так, бізнес-сенс — «замовлення містить позиції», але FK живе в OrderItem, отже керівна сторона зв’язку — OrderItem.customerOrder.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class CustomerOrder {

    @Id
    private Long id;

    @OneToMany(mappedBy = "customerOrder")
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        // У пам’яті: замовлення "бачить" позицію у своїй колекції
        items.add(item);

        // У БД: FK оновиться лише якщо ми змінили керівну сторону на OrderItem
        item.setCustomerOrder(this);
    }
}

Важливо вловити методичну думку: helper-методи — це не «красивість» і не «DDD-ритуал». Це спосіб зробити так, щоб ваші сутності не потрапляли в напівзв’язаний стан через забудькуватість програміста, тобто нас із вами.

7. Owning side у замовленнях: зв’язок і зовнішній ключ

Із замовленнями в новачків часто виникає ще підступніша плутанина. На бізнес-мові CustomerOrder — явно «головний», а OrderItem — «рядок», «дочка», підпорядкована частина. І здається логічним очікувати, що «головний» і буде керівним. Але owning side — це не про владу в компанії і не про те, хто кого поважає. Це про зовнішній ключ.

У SQL зовнішній ключ живе в order_item.customer_order_id. Отже, керівна сторона зв’язку — OrderItem.customerOrder. Навіть якщо ви проєктуєте замовлення як єдине ціле, JPA оновлюватиме FK саме тоді, коли змінюється поле на OrderItem.

Це добре видно в коді сутності позиції:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
class OrderItem {

    @Id
    private Long id;

    // Керівна сторона зв’язку: це поле відповідає FK order_item.customer_order_id
    @ManyToOne
    private CustomerOrder customerOrder;
}

І ось тепер стає зрозуміло, чому «додати item у order.getItems()» недостатньо: це зміна inverse side. Для бази даних це не команда «онови FK». Команда «онови FK» — це item.setCustomerOrder(order).

Тому helper-метод CustomerOrder.addItem() — не просто зручність, а реальний захист від розходження моделі.

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

8. Типові помилки під час розуміння owning side

Помилка №1: вважати, що owning side — це «батько» за бізнес-змістом.
На бізнес-рівні категорія «містить» товари, а замовлення «містить» позиції, і хочеться оголосити власником саме батька. Але JPA визначає owning side за місцем, де реально живе зовнішній ключ. У ManyToOne/OneToMany керівна сторона зв’язку майже завжди на боці @ManyToOne, тобто в «дитини». Батько може бути головним у домені, але це не робить його керівним для FK.

Помилка №2: оновлювати лише колекцію на inverse side і чекати, що БД зміниться.
Додати об’єкт у category.getProducts() або order.getItems() приємно і читабельно, але якщо це сторона з mappedBy, то вона лише відображає зв’язок. Hibernate не буде «вгадувати», що ви хотіли оновити FK. Для бази даних значущою є зміна поля керівної сторони: product.setCategory(category) або item.setCustomerOrder(order).

Помилка №3: оновлювати лише owning side і потім дивуватися, що колекція «не бачить» зміну в тій самій транзакції.
Якщо ви зробили product.setCategory(category), FK у БД буде коректним. Але category.getProducts() може залишитися порожньою просто в пам’яті JVM до повторного завантаження. У результаті бізнес-логіка, яка спирається на колекцію, починає поводитися дивно. Якщо у вас двосторонній зв’язок, синхронізація обох сторін — обов’язок вашого коду.

Помилка №4: розносити синхронізацію зв’язку по сервісах і тестах замість helper-методу.
Коли «ритуал» із двох рядків (products.add(p) і p.setCategory(this)) розмазаний по 12 місцях, ви гарантовано десь забудете одну зі сторін. Helper-метод у entity — це спосіб зробити правильну поведінку «за замовчуванням», а не сподіватися на дисципліну і хороший настрій усієї команди.

Помилка №5: плутати «видалення з колекції» і зміну FK на керівній стороні.
products.remove(product) або items.remove(item) — це лише операція над Java-колекцією. Для бази даних важлива керівна сторона: у товару це product.setCategory(...), у позиції замовлення — item.setCustomerOrder(...). У поточному навчальному мінішопі товар зазвичай не роблять «без категорії», а переназначають іншій категорії; для замовлення ж зв’язок нерідко справді розривають. Загальна думка одна: сама колекція FK не переписує.

1
Задача
Spring Data JPA, 8 рівень, 2 лекція
Недоступна
`addProduct()` синхронізує обидві сторони зв’язку
`addProduct()` синхронізує обидві сторони зв’язку
1
Задача
Spring Data JPA, 8 рівень, 2 лекція
Недоступна
`addItem()` оновлює керівну сторону у позиції замовлення
`addItem()` оновлює керівну сторону у позиції замовлення
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ