JavaRush /Курси /Hibernate deep-dive /Межа відповідальності в ca...

Межа відповідальності в 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["Метод сервісу @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. Припустімо, ви сховали автоперехід статусу 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() чи прямо в сервісі. У навчальному проєкті ми часто лишаємо прості переходи в методах сутності, а сервіс відповідає за оркестрацію. Але важливе інше: сам факт переходу має бути видимим. Якщо він схований у @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, або сервіс.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ