Доменна подія описує те, що вже сталося в предметній області: 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);
}
}
}
Дайте три рівні одним реченням і назвіть різницю в часі життя: подія фреймворку живе один запит і описує рядок або HTTP-цикл, доменна подія живе в межах процесу й може змінюватися разом з рефакторингом, інтеграційна подія живе роками в чужих сервісах, тому версіонується як API. Далі скажіть, де кожна зʼявляється: сутність записує, застосунковий шар публікує після коміту, назовні йде окремий транслятор через outbox.