<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
АРХІТЕКТУРА · MIDDLE ЧАСТО ПИТАЮТЬ

Що таке доменні події і чим вони відрізняються від подій фреймворку та інтеграційних подій?

Доменна подія - це зафіксований факт у минулому часі, названий мовою бізнесу (`OrderPaid`), який сутність записує в себе, а застосунковий шар публікує після коміту. Події фреймворку (`kernel.request`, `eloquent.updated`, `postPersist`) описують технічний життєвий цикл запиту чи рядка, а інтеграційна подія - це окремий публічний контракт для інших сервісів, з власною версійованою схемою, який виїжджає через outbox і доставляється at-least-once.

Ми шлемо `OrderUpdated` з обсервера моделі на кожен `save()`. Це доменна подія?
Чому подію називають `OrderPaid`, а не `SendInvoice`? Яка практична різниця?
Ту саму подію, яку слухають класи всередині моноліту, ми публікуємо в Kafka для іншого сервісу. Що зламається через пів року?
Хто має диспатчити подію: сутність, репозиторій чи застосунковий сервіс? І в який момент відносно `COMMIT`?
доменні події DDD integration events outbox eventual consistency

Доменна подія описує те, що вже сталося в предметній області: OrderPaid, SubscriptionCancelled, InvoiceIssued. Минулий час у назві задає властивості обʼєкта. Факт незмінний, тому payload робиться тільки для читання і несе значення на момент події плюс occurred_at. Факт не можна відхилити: слухач здатний обробити подію з помилкою, а повернути час назад і сказати «не оплачуйте» вже ні. Публікувальник не знає, хто підписаний, тому підписників може бути нуль, один або десять. Порівняйте з командою PayOrder: наказовий спосіб, рівно один обробник, право кинути виняток і зірвати операцію. Якщо ваша «подія» мусить щось гарантовано зробити, інакше сценарій недійсний, ви написали команду і назвали її подією.

Події фреймворку грають на іншому рівні. kernel.request, kernel.exception, eloquent.created: App\Models\Order, Doctrine postPersist і postFlush описують життєвий цикл HTTP-запиту чи рядка в таблиці. Про бізнес вони не знають нічого: updated спрацює однаково, коли провели оплату і коли менеджер виправив друкарську помилку в коментарі, а на Order::whereIn('id', $ids)->update(['status' => 'paid']) не спрацює зовсім, бо model events фіксуються лише на екземплярі моделі. Слухач такої події отримує «рядок змінився» і мусить сам відгадувати, яка операція відбулася, зазвичай через wasChanged('status'). Доменна подія знімає це відгадування: її народжує конкретний метод конкретної бізнес-операції, і вона несе рівно ті дані, які до цієї операції належать. Обсервери при цьому не зникають, у них є своя робота рівня рядка: ULID, нормалізація полів, інвалідація кешу, аудит.

Місце народження і місце публікації свідомо розведені. Сутність записує подію у власний приватний масив під час бізнес-методу, а releaseEvents() віддає накопичене й одразу очищає, щоб друге збереження не відправило все вдруге. Публікує застосунковий шар, і публікує після коміту. Спокуса викликати диспатчер прямо з методу сутності через статичний фасад закінчується знайомо: транзакція відкотилась, а лист про оплату вже в дорозі, повернути його нічим. Сутність із глобальним диспатчером усередині ще й погано тестується і тягне за собою інфраструктуру в домен. У Laravel коміту можна дочекатися і без ручного циклу, реалізувавши на класі події Illuminate\Contracts\Events\ShouldDispatchAfterCommit; у Symfony з Doctrine той самий результат дає слухач на postFlush, який обходить UnitOfWork і збирає події з сутностей, а DispatchAfterCurrentBusMiddleware у Messenger відкладає відправлення до кінця обробки поточного повідомлення. Автоматичний збір зручний, явний виклик у команді читабельніший.

Коли подія має покинути процес, зʼявляється третій вид. Інтеграційна подія - це публічний контракт, і власник у неї не той самий, що у внутрішнього класу: внутрішній клас належить вашому модулю й змінюється разом з рефакторингом, а billing.order.paid читають сервіси, які ви не деплоїте і, можливо, навіть не знаєте в обличчя. Тому між ними ставлять транслятор в одному місці: на вході доменна подія, на виході повідомлення з власним іменем, версією, message_id, ключем агрегату й свідомо зібраним payload. Ніякого json_encode($domainEvent): він прив'язує чужий код до ваших приватних полів і заодно вивозить назовні внутрішні ідентифікатори й персональні дані. Формат конверта краще взяти готовий, CloudEvents 1.0 з полями id, source, type, time, subject, data розуміють сторонні інструменти. Далі цей контракт живе за правилами API: нове поле можна додати, прибрати чи перейменувати не можна, нове значення enum ламає споживача зі строгою схемою. Окупається цей шар на першому серйозному рефакторингу, коли ви розділили агрегат на два, а публічна подія лишилася байт-в-байт тією самою.

Технічно інтеграційні події тримаються на outbox: рядок з повідомленням пишеться тією ж транзакцією, що й зміна стану, тож або є обидва записи, або немає жодного, а релей публікує вже після коміту й неминуче дає at-least-once, тому дедуплікація за message_id на споживачі є частиною домовленості. Внутрішні події мають ту саму хворобу в мініатюрі: диспатч у памʼяті після коміту губиться разом з процесом, який помер між COMMIT і викликом слухача. Звідси й сортування реакцій: необовʼязкові (прогрів кешу, метрика) лишаються синхронними, обовʼязкові (лист, нарахування, перебудова read-моделі) їдуть через чергу. Останнє, за що зазвичай і ставлять плюс: події задають межу транзакції. Одна транзакція змінює один агрегат, решта доганяє стан асинхронно, і це означає eventual consistency навіть у моноліті з однією базою. Якщо ж слухач доменної події лізе правити ще три агрегати в тій самій транзакції, ви отримали довгі блокування, дедлоки і ту саму зв'язність, яку намагалися розвʼязати, плюс порваний стек-трейс на додачу.

// Домен: сутність фіксує факт і нікуди його не відправляє
final class Order
{
    /** @var list<object> */
    private array $recordedEvents = [];

    public function markPaid(Money $amount, DateTimeImmutable $at): void
    {
        if ($this->status === OrderStatus::Paid) {
            return; // повторний виклик не породжує другу подію
        }

        $this->status = OrderStatus::Paid;
        // назва у минулому часі: факт, який уже не скасувати
        $this->recordedEvents[] = new OrderPaid($this->id, $amount, $at);
    }

    /** @return list<object> Забрати й очистити, щоб не надіслати двічі */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }
}

// Застосунковий шар: стан і те, що піде назовні, в одному COMMIT
final readonly class PayOrder
{
    public function __construct(
        private OrderRepository $orders,
        private Outbox $outbox,
        private IntegrationEventMap $public,
    ) {}

    public function __invoke(PayOrderCommand $command): void
    {
        $events = DB::transaction(function () use ($command): array {
            $order = $this->orders->getById($command->orderId);
            $order->markPaid($command->amount, $command->paidAt);
            $this->orders->save($order);

            $events = $order->releaseEvents();
            foreach ($events as $event) {
                // доменна подія -> окрема публічна схема billing.order.paid
                if ($message = $this->public->translate($event)) {
                    $this->outbox->add($message);
                }
            }

            return $events;
        });

        // Внутрішні слухачі вже після коміту, поза транзакцією
        foreach ($events as $event) {
            event($event);
        }
    }
}
Що подія - це факт, який уже стався: минулий час у назві, незмінний payload, нуль або більше підписників, і її не можна «відхилити», на відміну від команди, яку обробник має право не виконати.
Що доменна подія називається мовою домену й відповідає бізнес-операції, а `eloquent.updated` чи Doctrine `postUpdate` не знають, оплату провели чи адресу поправили, і мовчать на масових операціях через білдер.
Схему «record then release»: сутність накопичує події у власному масиві, репозиторій їх не публікує, застосунковий шар забирає і диспатчить після успішного коміту.
Що доменна подія лишається внутрішньою і може змінюватися разом з кодом, а назовні йде окрема інтеграційна подія з власною стабільною схемою, іменем і версією.
Що інтеграційну подію пишуть у outbox тією ж транзакцією, що й зміну стану, і що доставка виходить at-least-once, тому споживач дедуплікує за id повідомлення.
Правило межі: одна транзакція змінює один агрегат, решта агрегатів і контекстів доганяє стан через події, тобто eventual consistency всередині моноліту теж нормальна.
Називати подію в наказовому способі (`SendInvoice`, `CreateShipment`): це команда з одним обробником, яку можна відхилити, і назва приховує, що саме сталося в домені.
Вважати доменною подією хук ORM: `OrderUpdated` з обсервера спрацює й на зміні коментаря до замовлення, не спрацює на `Order::whereIn(...)->update([...])` і не скаже слухачеві, яка бізнес-операція відбулася.
Диспатчити подію прямо з методу сутності через статичний фасад або глобальний диспатчер: лист про оплату піде всередині транзакції, а `ROLLBACK` уже нічого не скасує.
Публікувати внутрішній клас події в Kafka як є: перейменування приватного поля в домені стає breaking change для чужого сервісу, а в payload заодно виїжджають персональні дані, яких споживачу не обіцяли.
Класти в інтеграційну подію лише `order_id` і вчити споживача дочитувати решту через HTTP: він отримає стан на момент читання, а не на момент події, плюс жорстка залежність від доступності джерела.
У слухачі доменної події змінювати ще три агрегати в тій самій транзакції: транзакція розтягується, зростає час блокувань і взаємні deadlock, а сенс події як точки розвʼязування зникає.
Робити подію там, де підписник один і назавжди: зʼявляється рівень непрямості, стек-трейс рветься, а зв'язність лишається тією самою.
ПОРАДА

Дайте три рівні одним реченням і назвіть різницю в часі життя: подія фреймворку живе один запит і описує рядок або HTTP-цикл, доменна подія живе в межах процесу й може змінюватися разом з рефакторингом, інтеграційна подія живе роками в чужих сервісах, тому версіонується як API. Далі скажіть, де кожна зʼявляється: сутність записує, застосунковий шар публікує після коміту, назовні йде окремий транслятор через outbox.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Доменна подія - частина внутрішньої моделі, і вона мусить лишатися вільною для рефакторингу. Щойно її серіалізований вигляд читає чужий сервіс, вона перетворюється на публічний контракт: додане поле `version` не робить схему стабільною, бо структура все одно повторює приватну модель, а разом з нею назовні їдуть внутрішні ідентифікатори й персональні дані. Робочий варіант - транслятор в одному місці, який збирає окреме повідомлення з полів, які ви готові підтримувати роками.