Доменное событие (Domain Event)
Агрегат записывает, что с ним случилось, в виде неизменяемых фактов в прошедшем времени, а реагируют на эти факты другие части системы: агрегат не знает ни о почте, ни о биллинге.
Проблема
Сервис подписок: клиент переходит с тарифа basic на pro, и после этого биллинг пересчитывает счёт, почта шлёт письмо, аналитика пишет в воронку. Если Subscription сама вызывает billing.recalculate() и mailer.send(), доменная модель тянет за собой инфраструктуру: тест смены тарифа требует почту, а новая реакция означает правку агрегата.
Можно вынести реакции в сервис, который после changePlan сравнивает «было» и «стало». Но «было» к этому моменту уже потеряно, а сервису приходится заново выяснять, что произошло, хотя агрегат это знал точно.
Решение
Агрегат при каждом изменении создаёт событие: SubscriptionStarted, PlanChanged, SubscriptionCancelled. Имя — глагол в прошедшем времени, потому что это факт, который уже случился, его нельзя отменить или отклонить. Событие — значение: в нём только данные на момент события (какая подписка, с какого тарифа на какой, когда), без ссылок на сам агрегат. Через час тариф снова поменяется, а PlanChanged будет по-прежнему говорить basic -> pro.
События не уходят никуда сами. Агрегат копит их в списке, а тот, кто управляет операцией, забирает их методом releaseEvents и решает, кому и когда их передать. Агрегат остаётся чистой моделью без зависимостей.
Структура
Агрегат Subscription записывает событие только тогда, когда что-то действительно изменилось: смена на тот же тариф ничего не записывает. Проверка правил идёт до записи: отменённая подписка отказывает в cancel и changePlan исключением, и в списке не появляется событие о том, чего не было. Время приходит параметром at, а не берётся из системных часов: так событие проверяется в тесте точным значением.
Как это собирается
События забирает обработчик запроса, а не агрегат. Каждый запрос к подписке проходит один путь: обработчик вызывает команду (changePlan, cancel), в приложении сохраняет агрегат через репозиторий и только потом вызывает releaseEvents и раздаёт события слушателям. В примере слушателей заменяет одна функция react: биллинг пересчитывает счёт на PlanChanged, почта пишет об отмене на SubscriptionCancelled. В приложении это подписчики шины событий, которых регистрирует контейнер.
Повторная смена на тот же тариф отвечает 200, но слушателям нечего делать: агрегат ничего не записал. Повторная отмена падает с SubscriptionAlreadyCancelled, обработчик переводит её в 409, и до releaseEvents дело не доходит: ни счёта, ни письма о том, чего не было.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Запись событий вынесена из агрегата в общий кусок: recordThat добавляет событие в список, releaseEvents отдаёт список и очищает его. Повторный вызов вернёт пустоту, поэтому одно событие нельзя опубликовать дважды по ошибке.
| Язык | Особенность |
|---|---|
| PHP | События — final readonly class, общий контракт DomainEvent описан свойствами интерфейса (PHP 8.4); запись — трейт RecordsEvents |
| Go | События — структуры со встроенным EventMeta, который реализует интерфейс Event; встроенный recorder отдаёт агрегату свой ReleaseEvents |
| Python | События — @dataclass(frozen=True), объединение DomainEvent; запись — базовый класс AggregateRoot |
| TypeScript | События — размеченное объединение с полем type и readonly полями; AggregateRoot<E> замораживает событие Object.freeze |
Запуск: make test T=patterns/enterprise/domain-event
Плюсы и минусы
Тесты одинаковы во всех языках, пять случаев: start записывает SubscriptionStarted с тарифом и временем; releaseEvents отдаёт события один раз; две смены тарифа подряд дают два события, и каждое хранит свои «было» и «стало»; смена на тот же тариф ничего не записывает; отменённая подписка отказывает в повторной отмене с понятным текстом и не записывает событие.
- ✅ Агрегат не зависит от реакций: новый потребитель события не требует правки модели.
- ✅ Событие точно описывает, что произошло, включая «было», которое в состоянии агрегата уже потеряно.
- ✅ Тест агрегата проверяет поведение одним сравнением списка событий, без моков.
- ❌ Появляется ещё один слой: кто-то должен забрать события и доставить их, иначе они просто пропадут.
- ❌ Порядок «сохранить, затем опубликовать» и что делать при сбое между ними — отдельная задача. Это разбирает практика.
- ❌ Событие — публичный контракт: переименовать поле, на которое подписаны три команды, уже не так просто.
Когда не применять
- Простой CRUD: запись меняется, и никого это не интересует. События будут создаваться и выбрасываться.
- Реакция должна вернуть результат или запретить операцию: это проверка или вызов, а не событие. Событие нельзя отклонить, оно уже случилось.
- Реакция обязана выполниться в той же транзакции, что и изменение, и при её провале изменение отменяется: тогда это часть операции, её пишут явно в сервисе.
Отличия между языками
- PHP —
readonlyкласс делает событие неизменяемым на уровне языка. Свойства в интерфейсе (public string $subscriptionId { get; }) появились в PHP 8.4 и позволяют описать общий контракт событий без геттеров. Трейт — привычный способ подмешать запись событий в любой агрегат, так сделано в EventSauce (AggregateRootBehaviour). - Go — неизменяемых структур нет, но событие передаётся по значению, и копия у подписчика не связана с агрегатом. Встраивание
EventMetaдаёт каждому событию методы интерфейсаEvent, а встраиваниеrecorder— агрегату методReleaseEventsбез наследования. Отказ —errorс сентинеломErrAlreadyCancelled. - Python —
frozen=Trueзапрещает присваивание полям (FrozenInstanceError), а==у dataclass сравнивает события по значению, поэтому тест сравнивает списки целиком. ОбъединениеDomainEventчерезtypeдаёт проверку типов без общего базового класса. - TypeScript —
readonlyработает только при компиляции, поэтомуrecordThatдополнительно вызываетObject.freeze. Полеtypeделает события размеченным объединением:switch (event.type)сужает тип, и компилятор подскажет, если новый тип события не обработан.
Практика
Учебный агрегат отдаёт события, но не показывает, что с ними делать дальше. Практика: публикация событий после сохранения добавляет сервис приложения и диспетчер: события несут версию агрегата и время из внедряемых часов, публикуются только после успешного сохранения и не публикуются вовсе, если сохранение упало или агрегат отказал в операции.
Связанные темы
- Практика: публикация событий после сохранения — сервис приложения, версии, порядок «сохранить, потом опубликовать»
- Наблюдатель (Observer) — механизм доставки: подписчики получают уведомление и не знают друг о друге
- Состояние (State) — правила переходов агрегата; доменное событие фиксирует переход как факт