JavaRush /Курси /Hibernate deep-dive /Soft delete: життєвий цикл даних

Soft delete: життєвий цикл даних

Hibernate deep-dive
Рівень 20 , Лекція 0
Відкрита

1. Видалення в Hibernate: remove(entity)

Коли розробник уперше бачить entityManager.remove(entity), у нього часто виникає дуже проста картинка: «викликав remove() — і рядок зник». Це як чекати, що кнопка «Delete» в редакторі коду ще й зварить вам каву: приємно, але реальність значно приземленіша й водночас хитріша. В ORM видалення — це не лише операція запису, а й зміна правил читання.

Почнімо з базової думки: видалення — це рішення про життєвий цикл даних. І це рішення майже завжди впливає одразу на три рівні.

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

По-друге, на реляційну цілісність. Physical delete — це справжній DELETE, який зобов’язаний поважати foreign keys. Якщо хтось у БД на цей рядок посилається, БД або заборонить видалення, або каскадно видалить частину таблиці, а ви потім пояснюватимете на code review, що «воно само». Soft delete змінює характер проблеми: рядок залишається, посилання не ламаються, але з’являється інший ризик — невидимість.

По-третє, на поведінку читання. І ось це ключовий пункт дня: після видалення у вас залишаються «звичайні запити» — списки товарів, пошук за SKU, картки категорій, перевірка існування. І саме вони визначають, наскільки ваша система передбачувана. Адже якщо після soft delete запити продовжують повертати «видалене», користувачі та сервіси регулярно отримуватимуть несподіванки.

Щоб відчути, чому remove() — не фінал історії, корисно тримати в голові простий ланцюжок:

flowchart TD
    A["Сервіс: remove()"] --> B["Сутність позначено як REMOVED у persistence context"]
    B --> C["flush / commit"]
    C --> D{"Правило видалення"}
    D -->|physical| E["SQL: DELETE"]
    D -->|soft| F["SQL: UPDATE (позначка deleted)"]
    E --> G["Рядок фізично зник"]
    F --> H["Рядок залишився, але змінюється правило видимості"]

Зверніть увагу: сервісний код може виглядати однаково, а підсумковий SQL — різний. Це не «магія заради магії», а прямий наслідок того, що ORM дає змогу моделювати різні життєві цикли даних.

2. Physical delete: коли remove() перетворюється на DELETE

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

Погляньмо на максимально знайомий код. Він виглядає нудно, а отже підозріло корисно — тому що саме на нудних місцях зазвичай і ламається інтуїція.

import com.example.commerce.catalog.entity.Category;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void deleteCategory(Long id) {
    // Важливо: find() може повернути null — тоді remove() завершиться помилкою.
    Category category = entityManager.find(Category.class, id);

    // Важливо: remove() не обов’язково надішле SQL одразу — DELETE піде на flush/commit.
    entityManager.remove(category);
}

Що відбувається на практиці — покроково:

1) find() повертає managed-сутність або null.
2) remove() переводить її в стан removed усередині persistence context.
3) До flush база може взагалі нічого не знати про ваше «видалення». Якщо в межах тієї самої транзакції ви зробите інший запит, flush може відбутися раніше, але це вже тема окремих лекцій про flush: Hibernate дбає про коректність запитів і іноді мусить синхронізуватися.

У SQL-логах physical delete найчастіше виглядає приблизно так:

-- Фізичне видалення: рядок справді зникає з таблиці
delete from category where id = ?;

І тут починається серйозна частина розмови. Physical delete вимагає відповіді на запитання: «А хто ще використовує цей рядок?» Якщо на Category посилаються рядки ProductCategoryAssignment, або на Product посилаються InventoryItem/OrderItem, видалення може або впасти через FK, або запустити каскад. І якщо зараз ви подумали «ну каскад же зручно» — чудово, ви щойно подумки наступили на граблі з модуля про життєвий цикл зв’язків. Каскад — це не «зроби, щоб усе само», а правило життя агрегату.

У каталозі та замовленнях physical delete часто виявляється занадто «сильним»: ви не просто ховаєте об’єкт зі списків, ви стираєте факт, що він колись існував у системі. Іноді це нормально (наприклад, тестові дані, чернетки, технічне сміття), але часто — ні.

3. Soft delete: рядок залишається, але перестає бути «видимим»

Soft delete зазвичай описують фразою «це коли ми не видаляємо рядок, а позначаємо його видаленим». Формально — так. Практично — це неповне пояснення, тому що найважливіше не в тому, що рядок залишається, а в тому, як змінюється поведінка читання. І ось тут починається інженерія: soft delete — це домовленість про видимість даних, а не просто про поле deleted.

Уявіть, що Product — це не папірець на столі, а запис у бухгалтерській книзі. Ви можете перекреслити рядок червоною ручкою (soft delete), але ви не можете вирвати сторінку (physical delete), якщо потім за цим записом хтось звірятиме дані. Схожа логіка працює і в звичайних системах: вам потрібні слід, відновлення, аудит і стабільність посилань.

У контексті Commerce Persistence Lab soft delete особливо природний для каталогу та довідників:

  • товар можуть зняти з продажу, але його SKU не має раптово «звільнятися» для іншого товару, інакше ви отримаєте хаос у пошуку та інтеграціях — навіть у навчальному проєкті;
  • категорію можуть приховати, але старі зв’язки й історія залишаються, і ви не хочете руйнувати модель посилань;
  • зв’язок товар–категорія теж може мати життєвий цикл, особливо якщо це link entity. І це важливо: у нашому проєкті зв’язок — не «невидима join table», а повноцінна сутність, а правила її видимості — окрема тема.

Варто помітити ще одну тонкість: soft delete майже завжди означає, що «видалений» об’єкт усе ще існує як дані, але перестає існувати як «звичайний результат запиту». Тому на запитання «товар видалено?» у світі soft delete часто правильніше відповідати: «він активний або прихований», «видимий чи невидимий за замовчуванням».

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

4. Видимість даних у звичайних запитах

Якщо ви хочете зрозуміти, чи правильно у вас зроблено soft delete, забудьте на хвилину слово «delete» і поставте інше запитання: що поверне типовий сценарій читання? Не той, який ви спеціально написали «для видалення», а найзвичайніший: список товарів, пошук, перевірка існування. Саме там soft delete або стає зручним стандартом, або перетворюється на вічне джерело багів.

Ось приклад «звичайного» запиту з каталогу, який виглядає цілком невинно:

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import java.util.List;

public List<Product> listProducts(EntityManager em) {
    // Важливо: без спільного правила видимості цей запит поверне і активні, і «видалені» (soft delete) товари.
    return em.createQuery("select p from Product p order by p.name", Product.class)
             .getResultList();
}

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

Наївний варіант, до якого майже всі приходять першим кроком: «ну додамо умову безпосередньо в запит».

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import java.util.List;

public List<Product> listVisibleProducts(EntityManager em) {
    // Наївний варіант: фільтруємо вручну.
    // Проблема: рівно таку саму умову доведеться пам’ятати в усіх інших запитах.
    return em.createQuery(
            "select p from Product p where p.deleted = false order by p.name",
            Product.class
    ).getResultList();
}

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

Soft delete — це не «умова в одному запиті», а єдине правило видимості для всіх звичайних читань.

Тут важливий баланс. Ми не хочемо перетворювати кожне читання на квест «не забудь deleted = false», але й не хочемо магії, яку неможливо вимкнути там, де потрібно бачити архівні дані, наприклад в адміністративній панелі. Звідси й виникає поділ на always-on і scenario-dependent механізми, про який поговоримо трохи далі.

5. Бізнес-ключі та унікальність

У soft delete є дуже «неочевидний» бік: він майже завжди стосується унікальності та бізнес-ключів. Якщо за physical delete рядок зникає, то (теоретично) ви можете створити новий рядок із тим самим sku або code, і унікальний індекс уже не заперечить. А при soft delete рядок залишається — і унікальність продовжує діяти, ніби об’єкт «усе ще займає ім’я». І це не баг, а наслідок вашого вибору життєвого циклу.

Візьмімо простий приклад із каталогу: перевірити, чи зайнятий SKU.

import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
    // Важливо: у нашому домені SKU вважається зайнятим навіть після soft delete.
    // Тому "exists" тут має враховувати і приховані (deleted=true) записи.
    boolean existsBySku(String sku);
}

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

Що це означає на практиці:

  • existsBySku("SKU-123") має повертати true, навіть якщо товар прихований, інакше ми створимо новий товар із тим самим SKU та отримаємо два різні об’єкти під одним ідентифікатором. Це вже не ORM-помилка, а доменна катастрофа.
  • Унікальне обмеження в БД продовжує працювати «як звичайно», тому що рядок залишається в таблиці.
  • Будь-які «відновлення» (un-delete) стають простішими: ви не ризикуєте, що хтось устиг зайняти ваш SKU новим записом.

Звісно, у реальних системах іноді роблять інакше: наприклад, хочуть дозволити повторне використання коду після видалення. Тоді з’являється цілий шар додаткових рішень: часткові унікальні індекси, розширені ключі, префікси, versioned keys. Але це вже окрема інженерна дискусія, і вона легко з’їдає тиждень життя. У нашому курсі ми оберемо шлях, який простіше пояснити та діагностувати за SQL: рядок залишається, ключ залишається зайнятим, правило очевидне.

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

6. Always-on і scenario-dependent видимість

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

Завжди увімкнена (always-on) видимість — це правило на кшталт «звичайні запити не мають бачити видалене ніколи». Воно нагадує правило дорожнього руху: ви не «інколи» їдете на червоне, хіба що ви не швидка допомога і не в кіно. Для каталогу це часто доречно: користувацькі списки не мають показувати приховані товари.

Перемикна (scenario-dependent) видимість — це коли в одному сценарії об’єкт має бути прихований, а в іншому — видимий. Класичний приклад: адміністративний екран «Показати архів» або внутрішній сервісний сценарій, який перебудовує зв’язки і має враховувати приховані записи.

Щоб це краще вклалося, зафіксуймо просте порівняння:

Запитання Always-on правило Scenario-dependent правило
Чи має це працювати за замовчуванням? Так, завжди Ні, вмикається явно
Чи можна «показати архів» у межах звичайного запиту? Лише спеціальним обхідним шляхом Так, якщо увімкнути правило/параметр
Головний плюс Передбачуваність і простота читання Гнучкість під різні сценарії
Головний мінус Складно побачити «приховане» там, де воно потрібне Поведінка, що залежить від стану, легко забути увімкнути або вимкнути

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

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

Три запитання перед реалізацією soft delete

Перед тим як обирати механізм реалізації, корисно чесно відповісти собі на три запитання. Вони звучать просто, але саме вони перетворюють soft delete на спроєктоване рішення, а не на нескінченне «чому цей товар знову з’явився у списку».

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

Друге запитання: де живе індикатор видалення? У таблиці самої сутності (наприклад, product.deleted)? Чи в таблиці зв’язку (наприклад, «рядок зв’язку товар–категорія видалено»)? Це критично, тому що правила для entity та правила для рядків зв’язку — різні за природою.

Третє запитання: чи має видимість перемикатися за сценарієм? Якщо ні — вам потрібен механізм always-on. Якщо так — вам потрібен механізм, який можна вмикати й вимикати в межах конкретної Session/transaction/use case. І тут ми вже підходимо до вибору інструментів дня, але поки що фіксуємо саме запитання.

А тепер «бонусне запитання», яке ви зазвичай почуєте від DBA або від продакшн-реальності: як це вплине на запити та індекси? Тому що soft delete збільшує обсяг таблиць (рядки не зникають), а умова deleted = false раптом стає частиною майже кожного читання. Сьогодні ми не підемо в індексну оптимізацію, але пам’ятати про це корисно вже зараз: приховуємо дані — отже, вони продовжують жити в таблиці та впливати на плани запитів.

Якщо хочеться побачити це як міні-алгоритм вибору, можна тримати в голові такий ескіз:

flowchart TD
    A["Потрібне 'видалення' даних"] --> B{"Рядок має зникнути фізично?"}
    B -->|Так| C["Physical delete: DELETE + наслідки FK/cascade"]
    B -->|Ні| D["Soft delete: індикатор + правило видимості"]
    D --> E{"Правило видимості завжди однакове?"}
    E -->|Так| F["Always-on правило (статичне)"]
    E -->|Ні| G["Scenario-dependent правило (перемикне)"]

Це не про «яку анотацію обрати», а про те, як не переплутати зміст. Анотація — лише інструмент реалізації обраного змісту.

І звідси вже видно три сімейства механізмів. Якщо потрібно змінити саму семантику видалення, щоб remove() означав не фізичний DELETE, а логічне приховування, для цього є @SoftDelete. Якщо прапорець уже зберігається в даних, а нам важливо лише зафіксувати правило читання, стають у пригоді @SQLRestriction / @SQLJoinTableRestriction; якщо ту саму видимість треба перемикати за сценарієм, у пригоді стануть @Filter / @FilterJoinTable.

7. Перевірка soft delete: SQL-лог і read-cases

У світі Hibernate дуже легко «повірити», що ви щось зробили, бо код виглядає правильно. З soft delete ця пастка особливо підступна: ви можете написати красивий метод «видалити товар», поставити breakpoint, побачити, що поле deleted змінилося… і водночас звичайні запити все одно повертатимуть приховані рядки, бо правило видимості ніде не закріплено. Тому наш давній курсовий принцип — дивитися на SQL — тут особливо доречний.

Візьмімо видалення товару на рівні сервісу. Код навмисно «нудний», тому що сенс буде в тому, який SQL справді піде (сьогодні ми ще не фіксуємо остаточну техніку, але готуємо правильну перевірку).

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void hideProduct(Long id) {
    // Важливо: ми працюємо в транзакції, щоб Hibernate зміг застосувати зміну під час flush/commit.
    Product product = entityManager.find(Product.class, id);

    // Важливо: сенс remove() залежить від обраної стратегії (physical vs soft delete).
    entityManager.remove(product);
}

Далі ви запускаєте типовий сценарій читання (наприклад, список товарів) і дивитеся на результат та SQL-слід. Якщо це physical delete, рядок зникне і запит не поверне його за визначенням. Якщо це soft delete (у коректній реалізації), рядок залишиться, але звичайний select p from Product p ... уже не має його повертати. І ця відмінність — головний критерій коректності.

Тут корисно прямо словами зафіксувати «перевірку здорового глузду»:

- якщо ви робите soft delete, але в списку товарів прихований товар досі показується, значить soft delete як правило видимості не реалізований (у вас просто зафіксовано факт у колонці, і не більше);
- якщо ви робите soft delete і раптом не можете показати прихований товар навіть в адміністративному сценарії, значить ви реалізували always-on правило там, де вам потрібна перемикальність, або взагалі не передбачили сценарій архіву.

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

8. Типові помилки при soft delete

Помилка №1: сприймати soft delete як «ще одне булеве поле», а не як правило видимості.
Часто розробник додає колонку deleted, ставить її в true і на цьому внутрішньо завершує задачу. Але потім з’ясовується, що приховані записи вилазять у списках, пошуку та exists-перевірках. Лікується це не додаванням ще п’яти умов у різні запити, а проєктуванням єдиного правила видимості за замовчуванням.

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

Помилка №3: ігнорувати бізнес-ключі та унікальність (sku, code).
Soft delete залишає рядки в таблиці, а отже, унікальні обмеження продовжують діяти. Якщо команда хотіла «після видалення можна перевикористати SKU», але не продумала, як це підтримати на рівні БД та запитів, вона отримає або помилки вставки, або два різні товари з одним ключем, а тоді вже помилки будуть не технічними, а змістовими.

Помилка №4: розмазувати deleted = false по кожному запиту вручну.
Перший час це навіть здається дисциплінованим: «ми не забуваємо». Але система росте, з’являються нові запити, нові репозиторії, нові проєкції, і правило видимості перетворюється на копіпасту. При цьому SQL-поведінка стає важкою для пояснення: один запит фільтрує, інший — ні, третій фільтрує «не так». Набагато здоровіше, коли «за замовчуванням» задається одним механізмом і однаково працює всюди.

Помилка №5: не перевіряти підсумок через SQL-лог.
Soft delete — це як тихий режим на телефоні: здається, що він увімкнений, поки вам не зателефонує мама. Якщо ви не подивилися SQL і не прогнали типовий сценарій читання, ви не знаєте, як система реально поводиться. А весь курс ми будуємо навколо ідеї: «вірю не анотації — вірю SQL».

1
Задача
Hibernate deep-dive, 20 рівень, 0 лекція
Недоступна
Фізичне видалення категорії
Фізичне видалення категорії
1
Задача
Hibernate deep-dive, 20 рівень, 0 лекція
Недоступна
Ручний soft delete без Hibernate-механізмів
Ручний soft delete без Hibernate-механізмів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ