cascade і orphanRemoval

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

1. Життєвий цикл зв’язків

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

І тут важливо відразу розвести два різні значення слова «володіє». owning side, про яку йшлося раніше, — це керівна сторона зв’язку: та, за полем якої JPA пише FK. У cascade і orphanRemoval нас цікавить уже не FK, а володіння життєвим циклом: хто живе самостійно, а хто існує лише як частина батька.

Спробуймо перевести це на дуже побутовий приклад. Є «категорія» і «товар». Товар належить категорії, але якщо ви видалили категорію «Смартфони», ваш магазин не має автоматично перетворитися на магазин порожнечі, де всі смартфони зникають із всесвіту. Найімовірніше, ви або забороните видаляти категорію, доки в ній є товари, або переведете товари в «Без категорії», або попросите адміністратора вибрати нову категорію. Але «видалити категорію = видалити всі товари» зазвичай звучить як дуже агресивна бізнес-логіка і як швидкий спосіб отримати дзвінок від менеджера з фразою «у нас усі товари зникли».

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

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

Щоб було простіше тримати це в голові, зафіксуймо нашу доменну інтуїцію прямо схемою:

flowchart LR
    Category["Категорія (довідник)"] -->|"зв’язок, але не володіння життям"| Product["Товар (самостійна сутність)"]
    CustomerOrder["Замовлення клієнта (корінь замовлення)"] -->|"володіє життєвим циклом"| OrderItem["Позиція замовлення"]

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

2. cascade у JPA і ON DELETE CASCADE

Слово cascade звучить так, ніби зараз буде красиво і страшно одночасно, як водоспад. У JPA сенс простіший: каскадування — це правило, за яким операції над однією сутністю автоматично застосовуються до пов’язаних сутностей. Тобто ви виконали операцію над батьком — а Hibernate (або інший провайдер JPA) виконав подібну операцію над дітьми.

Важливо відразу не переплутати два різні світи. У базі даних є каскади рівня FK, наприклад ON DELETE CASCADE: це поведінка самої бази, і вона може видаляти рядки «дітей», коли ви видаляєте рядок «батька». А JPA cascade — це поведінка ORM у вашому застосунку, тобто те, які SQL-запити Hibernate вирішить надіслати, коли ви викликали методи репозиторію/EntityManager.

Простіше кажучи, cascade — це не «магічна галочка в базі». Це «правило для Hibernate: якщо я роблю X з батьком, зроби Y з дітьми». Каскадування не змінює керівну сторону зв’язку: FK у CustomerOrder -> OrderItem як і раніше пише OrderItem.customerOrder, а в Category -> ProductProduct.category.

У JPA каскадування задається на асоціації, найчастіше на стороні «батька», наприклад на @OneToMany. Найчастіше використовувані варіанти в нашому навчальному проєкті — це PERSIST, MERGE, REMOVE і їхня «комбо-версія» ALL.

Невелика таблиця, щоб не тримати все в голові, ніби заклинання:

Налаштування Коли спрацьовує (спрощено) Що означає людською мовою Де це може бути доречно
CascadeType.PERSIST коли зберігаємо нового батька «Збережи дітей разом зі мною, якщо вони нові» замовлення → позиції
CascadeType.MERGE коли зберігаємо зміни батька «Онови дітей разом зі мною» замовлення → позиції (якщо ви змінюєте їх через об’єктний граф)
CascadeType.REMOVE коли видаляємо батька «Видаляй дітей, коли видаляєш мене» замовлення → позиції (часто так), категорія → товари (майже завжди ні)
CascadeType.ALL коли щось робимо з батьком «Роби все каскадом» лише там, де дитина повністю залежить від батька

Якщо ви бачите CascadeType.ALL у випадковому місці, ставтеся до цього як до знайденого на підлозі кабелю. Він може бути потрібним, але спочатку хочеться уточнити: «А він точно звідси?».

Ось як це виглядає у фрагменті сутності на @OneToMany:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;

@OneToMany(mappedBy = "customerOrder", cascade = CascadeType.PERSIST)
private List<OrderItem> items = new ArrayList<>(); // Каскадуємо збереження нових позицій разом із замовленням

Тепер важливий методичний момент. Каскади зазвичай ставлять від «батька» до «дитини» (наприклад, CustomerOrder -> OrderItem). І майже завжди небезпечно ставити каскади у зворотний бік, на @ManyToOne, тому що @ManyToOne дуже часто вказує на спільну сутність — довідник, товар або користувача. Якщо ви випадково зробите так, що збереження позиції замовлення буде зберігати товар, ви отримаєте «сюрприз»: будь-яка зміна позиції може несподівано зачепити таблицю товарів.

Наприклад, так робити не варто в нашому проєкті:

import jakarta.persistence.CascadeType;
import jakarta.persistence.ManyToOne;

// Антиприклад: товар живе сам по собі, ним не має керувати OrderItem
@ManyToOne(cascade = CascadeType.ALL) // Небезпечно: операція з OrderItem може потягнути збереження/видалення Product
private Product product;

І ще одна тонкість, яка часто плутає новачків. cascade не робить сторону зв’язку «керівною». Керівна сторона все так само та, де розташований FK (у наших прикладах це OrderItem.customerOrder і Product.category). Каскадування лише говорить: «коли я зберігаю батька, пройдися по колекції й теж збережи елементи». Але FK буде записаний правильно лише тоді, коли ви не забудете синхронізувати керівну сторону зв’язку через setCustomerOrder(this) або setCategory(this).

3. Category -> Product без cascade

Після теорії завжди хочеться прикрутити все красиве до всього. І тут починається найтиповіша пастка: студент бачить, що в категорії є список товарів, і рука тягнеться поставити cascade = CascadeType.ALL, щоб «усе зберігалося саме». Це людське бажання, і воно навіть десь шляхетне. Але в домені mini-shop це майже завжди неправильна бізнес-модель: категорія — довідник, а товар — самостійна сутність каталогу.

Давайте подивимося на зв’язок через його сенс. Товар може змінити категорію. Товар може тимчасово не бути в категорії — у реальному світі так, а в навчальному проєкті ми можемо зробити optional = false, щоб не ускладнювати. Категорія може стати неактивною, але товари мають жити. Навіть якщо категорію видалили, що взагалі спірно, товари мають або переїхати, або операцію треба заборонити. У будь-якому разі категорія не «володіє життям» товару так, як замовлення володіє життям позиції.

Тому в нашому проєкті зв’язка Category -> Product найчастіше виглядає так: зв’язок є, але каскадування життєвого циклу відсутнє. Фрагмент Category:

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

@OneToMany(mappedBy = "category") // Без cascade: категорія не керує життєвим циклом товару
private List<Product> products = new ArrayList<>();

А на боці товару — звичайне посилання на категорію, саме воно керує FK:

import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@ManyToOne(optional = false) // FK обов’язковий: товар завжди належить до якоїсь категорії (у нашому спрощеному варіанті)
@JoinColumn(name = "category_id") // FK лежить у таблиці product, отже це керівна сторона зв’язку
private Category category;

Зверніть увагу: відсутність каскаду не забороняє helper-методи. Helper-методи потрібні, щоб об’єктна модель у пам’яті була чесною. Вони не «вмикають каскад», вони просто синхронізують обидві сторони.

public void addProduct(Product product) {
    products.add(product);       // Оновлюємо колекцію на боці Category (зручна навігація)
    product.setCategory(this);   // Керівна сторона зв’язку: саме це поле визначає FK у БД
}

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

public Product createProduct(Long categoryId, Product product) {
    Category category = categoryRepository.getReferenceById(categoryId); // Беремо посилання на вже наявну категорію
    product.setCategory(category); // Керівна сторона зв’язку: задаємо FK через посилання на категорію
    return productRepository.save(product); // Зберігаємо саме товар, бо він "самостійний"
}

Якби ми поставили cascade = CascadeType.ALL на Category.products, а потім видалили категорію, Hibernate міг би видалити всі товари. І це не «помилка Hibernate» — це точне виконання вашої команди. Тому для Category -> Product краще тримати модель «м’якою»: зв’язок є, але життєвий цикл незалежний.

4. CustomerOrder -> OrderItem: cascade і orphanRemoval

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

У нашому проєкті логіка така: ви створили замовлення, додали до нього позиції, і вам хочеться зробити один зрозумілий виклик orderRepository.save(order). Без каскаду ви зобов’язані окремо зберігати кожен OrderItem або отримаєте помилки на кшталт «transient instance» та інші радощі. З каскадом PERSIST (або ALL) ORM збереже позиції разом із замовленням.

Фрагмент CustomerOrder — саме тут зручно поставити каскадування:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;

@OneToMany(
        mappedBy = "customerOrder",
        cascade = CascadeType.ALL,   // Замовлення керує життєвим циклом позицій: зберігаємо, оновлюємо й видаляємо разом
        orphanRemoval = true         // Якщо позицію прибрали із замовлення — видаляємо її як "сироту"
)
private List<OrderItem> items = new ArrayList<>();

А на боці OrderItem у нас керівне посилання на замовлення. Тут каскади, як правило, не потрібні:

import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@ManyToOne(optional = false) // Позиція не має сенсу без замовлення
@JoinColumn(name = "customer_order_id") // Тут лежить FK: керівна сторона зв’язку
private CustomerOrder customerOrder;

Каскади й orphanRemoval тут не скасовують базову механіку: FK як і раніше пише OrderItem.customerOrder. Просто тепер ця механіка збігається з доменною логікою замовлення, тому helper-методи справді доречні:

public void addItem(OrderItem item) {
    items.add(item);                 // Колекція — це навігація і зручність для доменної моделі
    item.setCustomerOrder(this);     // Керівна сторона зв’язку: саме це задає FK
}

І для симетрії — видалення:

public void removeItem(OrderItem item) {
    items.remove(item);              // Прибираємо з колекції замовлення
    item.setCustomerOrder(null);     // Розриваємо керівну сторону зв’язку: так ORM побачить "сироту" для orphanRemoval
}

Якщо вас бентежить item.setCustomerOrder(null) при optional = false, це нормальна реакція. Тут null не означає новий валідний довгоживучий стан «позиція без замовлення». Ми лише позначаємо сироту, яку orphanRemoval потім видалить. Для OrderItem це допустимо саме тому, що позиція не має жити окремо від замовлення.

5. orphanRemoval: видалення сиріт

orphanRemoval = true — налаштування з чудовою назвою. Воно буквально говорить: «якщо дитина стала сиротою, тобто більше не належить батькові, видали її». Це не «відв’язати», не «обнулити FK», а саме «видалити рядок у таблиці». Хороша аналогія — файл, який існував лише всередині папки: якщо ви дістали його з папки й більше нікуди не поклали, то він вам, найімовірніше, не потрібний, і його можна видалити.

Важливий нюанс: orphanRemoval — не те саме, що CascadeType.REMOVE. Каскад REMOVE відповідає на питання «що робити з дітьми, коли батька видаляють». А orphanRemoval відповідає на питання «що робити з дитиною, коли її виключили з колекції батька».

Порівняймо на одному міні-прикладі:

Ситуація Що робить CascadeType.REMOVE Що робить orphanRemoval = true
orderRepository.delete(order) видалить OrderItem разом із замовленням (якщо налаштовано) також видалить, бо видалення батька означає, що діти не можуть жити
order.removeItem(item) і зберігаємо замовлення не зобов’язаний нічого видалити, бо батька ми не видаляли видалить OrderItem як «сироту»

І ще одна практична деталь. orphanRemoval зазвичай спрацьовує не в момент виклику remove() зі списку, а коли ORM синхронізує зміни з базою, зазвичай наприкінці транзакції. У нашому поточному модулі ми ще не розбирали глибоко flush, persistence context і dirty checking, тому запам’ятаємо просту прикладну думку: orphanRemoval працює коректно, коли зміни зроблені через об’єктний граф і дійшли до збереження або коміту.

Саме тут контраст між сценаріями стає остаточно очевидним: для замовлення сирота — це сміття, і його справді треба видаляти. Для товару після втрати категорії це вже інший бізнес-сценарій, а не привід тихо видалити запис.

Зрештою, дуже часта поломка — видалити зі списку, але не оновити керівну сторону зв’язку. Це виглядає так:

order.getItems().remove(item); // Недостатньо: керівна сторона (FK) і далі може вказувати на замовлення

Такий код легко призводить до несподіванок: ORM може не зрозуміти, що зв’язок розірвано, або зрозуміти, але залишити неконсистентний стан у пам’яті. Правильний шлях — користуватися helper-методом, де ви розриваєте зв’язок з двох сторін:

order.removeItem(item); // Усередині: remove зі списку + item.setCustomerOrder(null)

6. Типові помилки: cascade і orphanRemoval

Майже всі помилки навколо каскадів починаються з однієї думки: «я зараз поставлю CascadeType.ALL усюди, і воно саме якось розрулить». JPA справді багато чого робить за нас, але лише в межах тих правил, які ми їй задали. Якщо правила випадкові, результат буде… теж випадковий, але з дуже впевненим виразом обличчя.

Помилка №1: CascadeType.ALL на Category -> Product «для зручності».
Категорія — довідник, товар — самостійна сутність каталогу. Каскад «видалення товарів разом із категорією» майже ніколи не відповідає бізнес-сенсу. У навчальному проєкті краще явно зберігати товар через ProductRepository, а видалення категорії або заборонити за наявності товарів, або робити окремим осмисленим сценарієм.

Помилка №2: orphanRemoval = true там, де дитина може жити окремо.
orphanRemoval добре працює, коли сутність справді не має існувати поза батьком, як OrderItem поза CustomerOrder. Якщо поставити його на зв’язок, де дитина «переїжджає» між батьками або взагалі може існувати окремо, ви отримаєте «зникнення даних» під час звичайних операцій.

Помилка №3: очікування, що каскад виправить несинхронізований двосторонній зв’язок.
Каскадування не робить керівну сторону зв’язку другорядною. Якщо ви додали OrderItem у колекцію замовлення, але не зробили item.setCustomerOrder(order), Hibernate може зберегти позицію з неправильним FK або отримати помилку від бази. Helper-методи в сутності — це не косметика, а страховка від таких багів.

Помилка №4: видалення лише з колекції без розриву owning side.
items.remove(item) без item.setCustomerOrder(null) часто призводить до «напівзв’язаного» стану. Ззовні здається, що позиції вже немає, а всередині ORM все ще бачить посилання на замовлення. Правильний патерн — order.removeItem(item) і одна точка істини про правила зв’язку.

Помилка №5: спроба каскадувати «вгору» по @ManyToOne.
Коли ви ставите каскад на @ManyToOne, ви ризикуєте зробити так, що зміни «дочірньої» сутності почнуть зберігати або видаляти «батька», який узагалі-то є довідником або спільною сутністю. У нашому проєкті OrderItem не має керувати життєвим циклом Product, а Product не має автоматично зберігати або видаляти Category.

1
Опитування
Зв’язки JPA, рівень 8, лекція 4
Недоступний
Зв’язки JPA
Many-to-one і каскади
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ