JavaRush /Курсы /Hibernate deep-dive /Граница ответственности в ...

Граница ответственности в callbacks

Hibernate deep-dive
22 уровень , 3 лекция
Открыта

1. Введение

Когда разработчик впервые узнаёт про callbacks, его внутренний голос обычно говорит: “О, класс! Я могу сделать так, чтобы оно само делалось!”. Это тот же голос, который потом предлагает “давай включим EAGER везде” и “давай откроем OSIV, чтобы не падало”. Проблема не в желании упростить жизнь, а в том, что ORM-упрощения легко превращаются в необъяснимую магию, особенно когда проект живёт месяцами и код читают разные люди.

Граница ответственности нужна не ради теоретической красоты. Она нужна, чтобы ваш OrderService выглядел как понятный рассказ: “создали заказ → зарезервировали остатки → подтвердили” — а не как детектив, где половина событий происходит “за кадром” в @PreUpdate, и вы узнаёте об этом только по странному SQL в логах. В курсе Hibernate deep-dive это особенно важно: мы учимся связывать Java-код, persistence context и SQL. Если бизнес-логика прячется в callbacks, эта связка ломается.

2. Модель: callback внутри flush

Если хочется удержать в голове одну мысль, то пусть будет такая: callback — это не “часть вашего сервиса”, а часть механики синхронизации ORM. Он может сработать при коммите, при flush-before-query, при промежуточном flush, и иногда — в момент, который вы как разработчик вообще не ожидали (мы это уже обсуждали в темах flush/dirty checking).

Посмотрите на цепочку, как это обычно выглядит в Spring Boot приложении с Hibernate:

flowchart TD
    A["Service method @Transactional"] --> B["Изменяем managed entity"]
    B --> C["Hibernate dirty checking"]
    C --> D["Flush cycle"]
    D --> E["Lifecycle callbacks / listeners"]
    E --> F["Generated SQL: INSERT/UPDATE/DELETE"]
    F --> G["Commit"]

Именно поэтому callback должен быть “микро-действием”, которое безопасно выполнить в любой момент этого цикла, не превращая flush в сюжетный поворот.

В Commerce Persistence Lab мы уже привыкли: транзакционная граница живёт на service layer, open-in-view=false, и мы не хотим, чтобы сущности “тайно” меняли бизнес-смысл. Callback — это место для минимальной технической автоматики, которая помогает инфраструктуре, но не перехватывает управление сценарием.

3. Техника vs бизнес-эффект

Слова “техническое” и “бизнесовое” звучат абстрактно, поэтому заземлим их. Техническая автоматизация — это то, что не меняет смысл операции для пользователя и домена. Бизнес-эффект — это то, что должно быть понятно из названия метода сервиса и вообще из бизнес-истории.

Например, проставить createdAt при вставке — это техническая автоматизация. Пользователь не воспринимает это как “шаг бизнес-процесса”, это просто метаданные. А вот “подтвердить заказ” или “сгенерировать номер заказа” — это бизнес-события. Они меняют то, что заказ “значит” в системе, и должны быть видимы в явном месте.

В проекте это очень быстро ощущается на сущности PurchaseOrder. Допустим, вы спрятали автопереход статуса NEWCONFIRMED в @PreUpdate. Что тогда видит читатель сервиса?

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public void updateDeliveryAddress(Long orderId) {
        // На вид: просто обновили адрес.
        // В реальности: где-то в callback мог смениться статус заказа.
        // Важно: читатель метода не видит этот шаг бизнес-истории.
    }
}

Сервис выглядит безобидно, а SQL и итоговое состояние — уже с сюрпризом. Поэтому граница ответственности начинается с честного вопроса: “Если я удалю callback, бизнес всё ещё будет понятен?”. Если ответ “нет”, значит это не callback-логика.

4. Признак №1: радиус влияния

Когда мы говорим “callback должен быть маленьким”, это не про количество строк (хотя и про него тоже). Это про радиус влияния. Хороший callback обычно трогает только саму сущность, к которой привязан, и только её технические поля.

Пример “нормальной” автоматики для InventoryItem: обновлять updatedAt перед update. Это затрагивает только одну строку той же таблицы и не меняет бизнес-правила резервирования.

import jakarta.persistence.PreUpdate;
import java.time.Instant;

public class InventoryMetadataListener {

    @PreUpdate
    public void touch(InventoryItem item) {
        // Техническая автоматизация: проставляем метку изменения.
        // Важно: не меняем бизнес-состояние и не ходим в БД.
        item.setUpdatedAt(Instant.now());
    }
}

А теперь представим, что кто-то решил “помочь” и в этом же callback сделать бизнес-действие: если availableQty стало меньше нуля — “исправить” или “автоматически зарезервировать”. Это уже не автоматика, а вмешательство в бизнес-правила, причём в момент flush. Если у вас оптимистические/пессимистические блокировки, если есть проверка инвариантов, если нужен понятный отказ — всё это должно происходить в сервисном сценарии, а не в тёмном углу listener’а.

Тут полезна аналогия: callback — это “автоматический доводчик двери”. Он должен плавно закрыть дверь (обновить метаданные) и не должен внезапно менять план квартиры (переставлять стены и делать ремонт).

5. Признак №2: запросы в БД

После слов “внутри callback нельзя вызывать EntityManager или Query” обычно возникает вопрос: “Почему нельзя, ведь технически-то можно?”. И это отличный вопрос, потому что ответ здесь не религиозный, а инженерный.

Во-первых, callback выполняется внутри ORM-процесса синхронизации. Если вы во время flush начнёте делать запросы, вы можете получить очень странные эффекты: повторные flush, дополнительную загрузку графов, неожиданное SQL-упорядочивание, а иногда и рекурсивные цепочки, которые тяжело диагностировать.

Во-вторых, запрос в базу означает, что логика уже не “локальная”, а зависит от текущего состояния данных. Это сразу поднимает вопрос: “А в какой транзакции? А под какой блокировкой? А что если конкурентно изменилось?”. Эти вопросы — из мира service layer, а не из мира callbacks.

Вот типичный анти-пример, который многие пытаются написать (и да, иногда даже заставляют работать):

import jakarta.persistence.PreUpdate;

public class ProductBadListener {

    @PreUpdate
    public void doSomething(Product product) {
        // Плохо: логика требует доступа к БД, но callback — не место для этого.
        // Причина: мы находимся внутри flush-цикла, и дополнительные запросы могут вызвать повторный flush/рекурсию.
        // entityManager.createQuery("update ...").executeUpdate();
    }
}

Если логика требует “узнать что-то из базы”, она должна жить в сервисе, где вы явно видите unit of work, транзакцию и последствия. Например, генерировать orderNumber на основе последовательности/таблицы — это точно сервисная логика (или отдельный компонент, вызываемый сервисом), потому что это доменно значимое действие и почти всегда требует согласованности.

6. Признак №3: шаг unit of work

Один из лучших тестов на правильную границу звучит так: прочитайте сервисный метод вслух (да, это смешно, но работает). Если сервисный метод по названию и телу не объясняет, что происходит с доменными сущностями — значит, часть истории спрятана.

В нашем проекте PurchaseOrder — агрегат, и изменение статуса — это важное доменное действие. Поэтому подтверждение заказа должно быть явным:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final PurchaseOrderRepository orderRepository;

    public OrderService(PurchaseOrderRepository orderRepository) {
        // Явная зависимость: сервис управляет сценарием (unit of work), а не callback.
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void confirm(Long orderId) {
        // Загружаем агрегат в рамках транзакции.
        PurchaseOrder order = orderRepository.getReferenceById(orderId);

        // Бизнес-переход должен быть видимым шагом сценария.
        order.confirm(); // бизнес-переход виден здесь, а не прячется в listener
    }
}

Обратите внимание на тонкую вещь. Мы не спорим, где лучше держать бизнес-логику — в entity-методе confirm() или прямо в сервисе. В учебном проекте мы часто держим простые переходы в entity-методах, а сервис отвечает за orchestration. Но важно другое: сам факт перехода должен быть видим. Если он спрятан в @PreUpdate, читатель сервиса не понимает, что произошло, и вы теряете контроль.

7. Признак №4: время и внешние эффекты

Время — коварная штука. Instant.now() в callback для updatedAt обычно допустим: это техническая метка, и если она на пару миллисекунд отличается — мир не рухнет. Но как только вы начинаете на основе времени делать бизнес-идентификаторы или “умные правила”, вы входите в зону: “это должно быть видно и управляемо”.

Плохой пример (именно как бизнес-логика) — генерировать orderNumber внутри listener’а:

import jakarta.persistence.PrePersist;
import java.time.Instant;

public class PurchaseOrderBadListener {

    @PrePersist
    public void beforeInsert(PurchaseOrder order) {
        // Плохо: это бизнес-идентификатор, а не техническая метка.
        // Проблема: сценарий "создать заказ" становится неявно "создать заказ + сгенерировать номер".
        order.setOrderNumber("PO-" + Instant.now().toEpochMilli()); // бизнес-идентификатор в тени
    }
}

Почему это плохо именно как граница ответственности, даже если “работает”?

Потому что orderNumber — бизнес-ключ, на него завязаны уникальности, поиск, интеграции, ссылки в отчётах. Если он генерируется в callback, у вас появляется неявное поведение: сервис “создаёт заказ”, но на самом деле “создаёт заказ и генерирует номер”. Это должно быть в явном сценарии создания, чтобы при чтении кода не было эффекта “внезапно появился номер”.

Ещё более жёсткий запрет — любые внешние эффекты (отправка письма, создание записи в другой таблице через отдельный репозиторий, публикация событий). Такие штуки должны быть частью сервиса, иначе вы получите идеальную смесь: “неявно”, “сложно тестировать” и “сложно откатывать при ошибке”.

8. Таблица: callback или service layer

Чтобы не гадать на кофейной гуще, полезно иметь “шпаргалку”. Не как строгий закон, а как быстрый фильтр для ревью. В контексте нашего курса она помогает держать проект предсказуемым.

Вопрос о логике Если ответ “да” — чаще callback Если ответ “да” — чаще service layer
Это чисто техническое поле (createdAt, updatedAt)? Да Нет
Действие трогает только текущую entity? Да Нет (затрагивает другие сущности/агрегаты)
Для выполнения не нужны запросы/репозитории/EntityManager? Да Нет
Это действие не меняет бизнес-смысл (статусы, номера, доступность товара)? Да Нет
Поведение должно быть одинаковым для всех “путей” обновления/удаления? Иногда да, но осторожно Да (сервис контролирует сценарий)
Действие важно видеть в коде как шаг unit of work? Нет Да
Действие зависит от конкурентности, блокировок, повторных попыток? Нет Да

Заметьте, тут нет магического слова “listener”. Вынести код из entity в отдельный listener — это про организацию, а не про “сделать бизнес-логику безопасной”. Если бизнес-логика спрятана — она спрятана и в entity callback, и в listener.

9. Примеры границы в Commerce Lab

Важно, чтобы наша теория не висела в воздухе. В лабораторном проекте мы можем сделать три очень характерных примера: один про @PrePersist, один про @PreUpdate, один про “точно не callback”.

Пример технического createdAt внутри PurchaseOrder (локально, коротко, без сюжета):

import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import java.time.Instant;

@Entity
public class PurchaseOrder {

    private Instant createdAt;

    @PrePersist
    void initCreatedAt() {
        // Техническая метка создания: полезно, локально, без доменного смысла.
        // Важно: не трогаем статусы/номера/интеграции — только метаданные.
        if (createdAt == null) createdAt = Instant.now();
    }
}

Здесь действие легко объяснить одной фразой: “Если создаём заказ — ставим createdAt”. Это не меняет доменную историю, это просто метка.

Пример технического updatedAt в listener для InventoryItem (то же самое, но вынесено наружу):

import jakarta.persistence.PreUpdate;
import java.time.Instant;

public class InventoryMetadataListener {

    @PreUpdate
    public void touch(InventoryItem item) {
        // Техническая метка обновления: безопасна в любой момент flush-цикла.
        item.setUpdatedAt(Instant.now());
    }
}

Опять же — “доводчик двери”: полезно, локально, без сценарной магии.

И пример, который должен жить в сервисе: подтверждение заказа.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public void confirm(PurchaseOrder order) {
        // Явный шаг бизнес-сценария: читатель сервиса должен увидеть смену статуса.
        // Важно: это не "техническая автоматика", а доменное действие.
        order.setStatus(OrderStatus.CONFIRMED);
    }
}

Да, код короткий. Но он короткий в правильном месте. Переход статуса — это доменно значимый шаг. Даже если он занимает одну строку, это не делает его “callback-логикой”. Иногда самое опасное — это как раз “бизнес-логика в одну строку, спрятанная так, что её никто не видит”.

10. Типичные ошибки выбора места логики

Ошибка №1: “Раз уж это одна строка, пусть живёт в callback”.
Самая частая ловушка — мерить решение количеством строк. В итоге в @PreUpdate поселяется “невинное” order.setStatus(CONFIRMED), а потом вы неделю ищете, почему статус меняется “сам”. Критерий не в длине кода, а в том, меняется ли бизнес-смысл. Если меняется — это сервисный шаг, даже если он короткий.

Ошибка №2: генерация бизнес-номера заказа в @PrePersist.
Номер заказа — это не “техническое поле”. Он участвует в поиске, интеграциях и часто в уникальности. Если он генерируется в callback, сервисный сценарий становится неполным: “создать заказ” на самом деле означает “создать заказ и присвоить ему номер”. Это должно быть явно в сервисе (или в отдельном генераторе, но вызванном сервисом), чтобы код читался честно.

Ошибка №3: попытка ходить в БД из listener’а (через EntityManager, репозиторий или “хитрый” Spring bean).
Иногда люди думают: “Я просто заинжекчу репозиторий и проверю что-то”. Но как только callback начинает зависеть от запросов, вы переносите в flush-цикл логику, которая должна жить в сценарии unit of work. Это ухудшает диагностику и создаёт опасные эффекты: лишние запросы, неожиданная рекурсия, трудные для объяснения точки падения.

Ошибка №4: смешивание техники и бизнеса в одном callback.
Очень типичный “монстр”: в одном listener одновременно ставят createdAt, генерируют orderNumber, проставляют status, а заодно “подправляют” позиции заказа. Такая смесь делает поведение сущности нечитабельным: чтобы понять, что реально произойдёт при сохранении, нужно помнить правила listener’а. Правильнее: техника остаётся в минимальном callback, бизнес — в сервисе.

Ошибка №5: дублирование логики — и в сервисе, и в callback.
Бывает наоборот: вы сначала сделали updatedAt в callback, потом кто-то “на всякий случай” добавил updatedAt = now() в сервис. В итоге поле меняется дважды, а иногда это даже приводит к лишним UPDATE, потому что dirty checking видит лишнюю модификацию. У каждого автоматического поля должен быть один “хозяин” — либо callback, либо сервис.

1
Задача
Hibernate deep-dive, 22 уровень, 3 лекция
Недоступна
Технический callback и явная активация в сервисе
Технический callback и явная активация в сервисе
1
Задача
Hibernate deep-dive, 22 уровень, 3 лекция
Недоступна
`@PostLoad` для helper-поля и отдельный бизнес-метод `archive`
`@PostLoad` для helper-поля и отдельный бизнес-метод `archive`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ