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. Допустим, вы спрятали автопереход статуса NEW → CONFIRMED в @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, либо сервис.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ