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), с категориями (через link entity), и «исчезновение строки» может ударить по целостности.

Во-вторых, на реляционную целостность. Physical delete — это реальный DELETE, который обязан уважать foreign keys. Если кто-то в БД на эту строку ссылается, БД либо запретит удаление, либо каскадно удалит полтаблицы (а вы потом будете объяснять на code review, что «оно само»). Soft delete меняет характер проблемы: строка остаётся, ссылки не ломаются, но появляется другой риск — невидимость.

В-третьих, на read-поведение. И вот это ключевой пункт дня: после удаления у вас остаются «обычные запросы» — списки товаров, поиск по SKU, карточки категорий, проверка существования. И именно они определяют, насколько ваша система предсказуема. Потому что если после soft delete запросы продолжают возвращать «удалённое», пользователи и сервисы будут регулярно получать сюрпризы.

Чтобы почувствовать, почему remove() — не финал истории, полезно держать в голове простую цепочку:

flowchart TD
    A["Service: remove()"] --> B["Entity помечена как 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 как в runtime-системе: состояние меняется сразу, 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 чаще всего выглядит примерно так:

-- Physical delete: строка реально удаляется из таблицы
delete from category where id = ?;

И вот здесь начинается взрослая часть разговора. Physical delete требует ответа на вопрос: «А кто ещё использует эту строку?» Если на Category ссылаются строки ProductCategoryAssignment, или на Product ссылаются InventoryItem/OrderItem, удаление может либо упасть по FK, либо запустить каскад. И если вы сейчас подумали «ну каскад же удобен» — отлично, вы только что мысленно наступили на грабли из модуля про lifecycle связей. Каскад — это не “сделай, чтобы всё само”, а правило жизни агрегата.

В каталоге и заказах 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” и задайте другой вопрос: что вернёт типичный read-case? Не тот, который вы специально написали «для удаления», а самый обычный: список товаров, поиск, проверка существования. Именно там 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) видимость — это когда в одном use case объект должен быть скрыт, а в другом — видим. Классический пример: админский экран “Показать архив” или внутренний сервисный сценарий, который пересобирает связи и должен учитывать скрытые записи.

Чтобы это лучше уложилось, зафиксируем простое сравнение:

Вопрос Always-on правило Scenario-dependent правило
Должно ли работать по умолчанию? Да, всегда Нет, включается явно
Можно ли “показать архив” в рамках обычного запроса? Только специальным обходным путём Да, если включить правило/параметр
Главный плюс Предсказуемость и простота чтений Гибкость под разные use cases
Главный минус Сложно увидеть “скрытое” там, где оно нужно Stateful-поведение, легко забыть включить/выключить

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

И обратите внимание: мы пока сознательно говорим про правила в целом, а не про конкретные аннотации. Потому что если вы начнёте с техники (“вот тут @Something”), не ответив на вопрос “где и когда видно”, вы почти гарантированно сделаете систему, где видимость — случайность.

Три вопроса перед реализацией soft delete

Перед тем как выбирать механизм реализации, полезно честно ответить себе на три вопроса. Они звучат просто, но именно они превращают soft delete в проектируемое решение, а не в бесконечное “почему этот товар снова вылез в списке”.

Первый вопрос: что именно считается удалённым? Иногда это бинарное состояние “активен/удалён”. Иногда “активен/скрыт/архивирован/заблокирован”. В нашем дне мы будем мыслить простым индикатором “удалён/не удалён”, но важно понимать, что вы определяете смысл, а не Hibernate.

Второй вопрос: где живёт индикатор удаления? В таблице самой сущности (например, product.deleted)? Или в таблице связи (например, “строка связи товар–категория удалена”)? Это критично, потому что правила для entity и правила для join-строк — разные по природе.

Третий вопрос: должна ли видимость переключаться по сценарию? Если нет — вам нужен “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 эта ловушка особенно жестокая: вы можете написать красивый метод “удалить товар”, поставить брейкпоинт, увидеть, что поле 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);
}

Дальше вы запускаете типичный read-case (например, список товаров) и смотрите на результат и 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-проверках. Лечится это не добавлением ещё пяти условий в разные запросы, а проектированием единого “default visibility behavior”.

Ошибка №2: думать только про удаление и забывать про чтение.
Удалить запись — это один write-case. А после удаления живут десятки read-case’ов, часто написанных разными людьми. Если правило видимости не централизовано, оно будет нарушаться постоянно, и каждый такой баг будет выглядеть как “ну просто забыли условие”. Это не забывчивость, это архитектурная трещина.

Ошибка №3: игнорировать бизнес-ключи и уникальность (sku, code).
Soft delete оставляет строки в таблице, а значит, уникальные ограничения продолжают действовать. Если команда хотела “после удаления можно переиспользовать SKU”, но не продумала, как это поддержать на уровне БД и запросов, она получит либо ошибки вставки, либо два разных товара с одним ключом (и тогда уже ошибки будут не техническими, а смысловыми).

Ошибка №4: размазывать deleted = false по каждому запросу вручную.
Первое время это даже кажется дисциплинированным: “мы не забываем”. Но система растёт, появляются новые запросы, новые репозитории, новые проекции, и правило видимости превращается в копипасту. При этом SQL-поведение становится трудно объяснимым: один запрос фильтрует, другой нет, третий фильтрует “не так”. Гораздо здоровее, когда “по умолчанию” задаётся одним механизмом и одинаково работает везде.

Ошибка №5: не проверять итог через SQL-лог.
Soft delete — это как тихий режим на телефоне: кажется, что он включён, пока вам не позвонит мама. Если вы не посмотрели SQL и не прогнали типичный read-case, вы не знаете, как система реально себя ведёт. А весь курс мы строим вокруг идеи: “верю не аннотации, верю SQL”.

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