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["Метод сервісу @Transactional"] --> B["Змінюємо керовану сутність"]
B --> C["Dirty checking Hibernate"]
C --> D["Цикл flush"]
D --> E["Callbacks життєвого циклу / listeners"]
E --> F["Згенерований 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() чи прямо в сервісі. У навчальному проєкті ми часто лишаємо прості переходи в методах сутності, а сервіс відповідає за оркестрацію. Але важливе інше: сам факт переходу має бути видимим. Якщо він схований у @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, або сервіс.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ