💾 Розділ 16 · Питання #21

Які типи Cascade існують

Специфікація JPA (Jakarta Persistence) та фреймворк Hibernate надають набір типів каскадування, що визначають, які саме операції керування життєвим циклом сутності автоматично т...

Специфікація JPA (Jakarta Persistence) та фреймворк Hibernate надають набір типів каскадування, що визначають, які саме операції керування життєвим циклом сутності автоматично транслюються від батьківського об’єкта до пов’язаних дочірніх сутностей. Розуміння специфіки кожного типу каскаду дозволяє вибудовувати надійні межі агрегатів за канонами Domain-Driven Design (DDD) та запобігати випадковому видаленню спільних даних.


🟢 Junior Level

6 стандартних типів CascadeType в JPA

У переліку jakarta.persistence.CascadeType визначено шість базових значень:

Тип каскаду Якому виклику EntityManager відповідає Поведінка при виклику на батьківській сутності
PERSIST entityManager.persist(parent) Нові дочірні об’єкти (Transient) також передаються в persist() і отримують статус Managed.
MERGE entityManager.merge(parent) Від’єднані дочірні об’єкти (Detached) також об’єднуються з поточною сесією.
REMOVE entityManager.remove(parent) Дочірні записи видаляються з бази даних при видаленні батьківського рядка.
REFRESH entityManager.refresh(parent) Дані всіх дочірніх сутностей примусово перезавантажуються з БД.
DETACH entityManager.detach(parent) Усі пов’язані об’єкти виключаються з кешу L1 і переходять у стан Detached.
ALL Усі операції одразу Активує всі 5 вищеперелічених стратегій каскадування одночасно.
// Приклад комбінування типів каскаду:
@Entity
public class Document {
    @Id
    private Long id;

    // Дозволяємо зберігати й оновлювати вкладення разом із документом,
    // але забороняємо автоматичне видалення:
    @OneToMany(mappedBy = "document", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private List<Attachment> attachments = new ArrayList<>();
}

🟡 Middle Level

Нативні типи каскадування Hibernate (org.hibernate.annotations.CascadeType)

Крім стандарту JPA, бібліотека Hibernate надає власний розширений набір анотацій каскадування @org.hibernate.annotations.Cascade:

  • SAVE_UPDATE (Legacy Hibernate): Каскадує виклики session.saveOrUpdate().
  • LOCK: Каскадно накладає блокування session.lock() на пов’язані сутності.
  • REPLICATE: Каскадно переносить сутності між різними базами даних через session.replicate().

Рекомендація: У сучасному коді слід завжди використовувати стандартний JPA jakarta.persistence.CascadeType, уникаючи прив’язки до вендорських анотацій Hibernate.

Архітектурне правило: Композиція проти Асоціації

Вибір типу каскадування повинен суворо слідувати моделі предметної області (DDD):

                   СТРАТЕГІЯ КАСКАДУВАННЯ ЗА ТИПОМ ЗВ'ЯЗКУ
                   
  1. КОМПОЗИЦІЯ (Parent володіє Child монопольно)
     Order ──────[1:N]──────> OrderItem
     Invoice ────[1:N]──────> InvoiceLine
     • Стратегія: cascade = CascadeType.ALL, orphanRemoval = true
     • Обґрунтування: OrderItem не існує у відриві від Order.
     
  2. АСОЦІАЦІЯ (Зв'язок між незалежними сутностями)
     Order ──────[N:1]──────> Customer
     Product ────[N:M]──────> Category
     • Стратегія: БЕЗ cascade (або тільки PERSIST/MERGE за необхідності)
     • Обґрунтування: Видалення замовлення НЕ ПОВИННО видаляти клієнта!

🔴 Senior Level

Пастка розділення PERSIST та MERGE

Поширена помилка проєктування: розробник вказує cascade = CascadeType.PERSIST, забуваючи про CascadeType.MERGE:

@Entity
public class Order {
    @Id
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
    private List<OrderItem> items = new ArrayList<>();
}

Що відбувається в коді сервісу:

// 1. Отримали detached-замовлення (наприклад, з REST API)
Order detachedOrder = getOrderFromApi();
detachedOrder.getItems().get(0).setPrice(BigDecimal.TEN);

// 2. У транзакції викликаємо збереження:
Order managedOrder = entityManager.merge(detachedOrder);
  • Оскільки у колекції items вказано тільки CascadeType.PERSIST, метод merge() проігнорує дочірню колекцію!
  • Батько Order оновиться, але змінена ціна в OrderItem не буде збережена в базі даних!
  • Правило: Якщо сутність може оновлюватися в detached-стані, каскади PERSIST та MERGE практично завжди повинні йти в парі: cascade = {CascadeType.PERSIST, CascadeType.MERGE} (або CascadeType.ALL).

Небезпеки каскаду REMOVE у відносинах @ManyToMany

@Entity
public class Student {
    @Id
    private Long id;

    // ❌ КАТАСТРОФА: Відрахування студента видалить усі курси з університету!
    @ManyToMany(cascade = CascadeType.ALL)
    private List<Course> courses = new ArrayList<>();
}

Якщо відрахувати одного студента через entityManager.remove(student), Hibernate видалить не просто зв’язувальний рядок у таблиці student_courses, а фізично видалить рядки з таблиці courses! А якщо в курсу Course також стояв каскад ALL до студентів, Hibernate каскадно зітре взагалі всіх інших студентів цього курсу.

Стандарт: Для @ManyToMany каскад REMOVE категорично заборонений. Дозволені лише PERSIST та MERGE.


4 Tricky Questions

1. Якщо на зв’язку стоїть лише CascadeType.PERSIST, чи спрацює збереження дочірнього елемента при виклику repository.save(parent) у Spring Data JPA, якщо батько вже існує в БД?

Відповідь:

  1. Ні, не спрацює!
  2. У Spring Data JPA метод save(entity) перевіряє, чи є сутність новою (isNew()).
  3. Оскільки батько вже існує в базі (id != null), Spring Data викликає метод entityManager.merge(parent), а не persist().
  4. Оскільки на зв’язку оголошено лише CascadeType.PERSIST, метод merge() не буде каскадуватися на дочірні елементи. Нові дочірні об’єкти залишаться в стані Transient, і СУБД викине помилку TransientPropertyValueException.

2. Що станеться, якщо батьківська сутність містить каскад CascadeType.ALL на дочірню колекцію, але в базі даних на зовнішньому ключі налаштовано правило ON DELETE RESTRICT?

Відповідь:

  1. При виклику entityManager.remove(parent) Hibernate виконає каскадний обхід і сформує в ActionQueue список команд DELETE для кожного дочірнього елемента, а потім — для батька.
  2. Оскільки Hibernate виконує видалення дочірніх записів раніше, ніж батьківського рядка, обмеження бази даних ON DELETE RESTRICT не буде порушено, і видалення завершиться успішно.
  3. Помилка СУБД виникла б лише в тому разі, якби видалення виконувалося прямим нативним SQL-запитом до батьківського рядка без попереднього очищення дітей.

3. Припустимо, ми викликаємо entityManager.refresh(parent), а на зв’язку вказано CascadeType.REFRESH. Що станеться з незбереженими змінами в дочірніх об’єктах?

Відповідь:

  1. Каскад REFRESH пошириться на кожну дочірню сутність, яка знаходиться в пам’яті.
  2. Для кожного дочірнього об’єкта Hibernate виконає SQL SELECT * FROM child_table WHERE id = ?.
  3. Усі незбережені локальні зміни, зроблені в полях дочірніх об’єктів в оперативній пам’яті JVM, будуть безповоротно стерті актуальними значеннями колонок із бази даних.

4. Чому каскад CascadeType.DETACH не захищає від LazyInitializationException після закриття сесії?

Відповідь:

  1. CascadeType.DETACH переводить об’єкти зі стану Managed у Detached.
  2. Якщо пов’язана лінива колекція не була завантажена в пам’ять до виклику detach(), вона так і залишається неініціалізованим проксі-об’єктом PersistentBag.
  3. Оскільки сесія від’єднується, проксі втрачає доступ до активного JDBC-з’єднання.
  4. Подальше звернення до гетера колекції на від’єднаному об’єкті гарантовано викине LazyInitializationException. Каскад DETACH керує статусом сутностей у контексті, але не форсує завантаження даних із бази.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«У JPA існує 6 типів каскадів (CascadeType): PERSIST, MERGE, REMOVE, REFRESH, DETACH та ALL. Головний архітектурний принцип вибору каскадів базується на концепції агрегатів DDD: для композицій @OneToMany (де дочірні об’єкти не мають сенсу без батька, як Order -> OrderItem) використовується зв’язка CascadeType.ALL разом із orphanRemoval = true. Для асоціацій @ManyToOne та @ManyToMany (де сутності незалежні) каскад REMOVE суворо заборонений задля уникнення видалення спільних даних. Важливо пам’ятати, що PERSIST і MERGE діють незалежно: якщо в Spring Data JPA викликати save() на існуючому батьку з каскадом лише PERSIST, дочірні об’єкти не збережуться».

Ключові метрики для Production-моніторингу

  • Cascade Tree Traversal Depth: глибина каскадного обходу графа об’єктів (занадто глибокі дерева сповільнюють коміт транзакції).
  • Кількість SQL-операцій на один виклик save() / remove(): дозволяє вчасно виявити надлишковий каскадний оверхед.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Я завжди ставлю CascadeType.ALL на всі анотації @ManyToOne» — Це призведе до видалення спільних користувачів або довідників при видаленні дочірніх записів.
  • ❌ «CascadeType.PERSIST автоматично включає в себе і CascadeType.MERGE» — Неправда. Це абсолютно незалежні операції життєвого циклу.
  • ❌ «Каскад REMOVE на зв’язку @ManyToMany видалить лише зв’язувальну таблицю» — Ні, він фізично видалить цільові сутності з їхніх основних таблиць.
  • ❌ «Використовую CascadeType.ALL для зв’язку Order -> Customer» — Порушення меж агрегатів, замовлення не володіє клієнтом.

Пов’язані теми