handbookНаписать

patterns/enterprise/domain-event/README.md · тема · 4 языка · 23 теста

Доменное событие (Domain Event) ​

Агрегат записывает, что с ним случилось, в виде неизменяемых фактов в прошедшем времени, а реагируют на эти факты другие части системы: агрегат не знает ни о почте, ни о биллинге.

Проблема ​

Сервис подписок: клиент переходит с тарифа basic на pro, и после этого биллинг пересчитывает счёт, почта шлёт письмо, аналитика пишет в воронку. Если Subscription сама вызывает billing.recalculate() и mailer.send(), доменная модель тянет за собой инфраструктуру: тест смены тарифа требует почту, а новая реакция означает правку агрегата.

Можно вынести реакции в сервис, который после changePlan сравнивает «было» и «стало». Но «было» к этому моменту уже потеряно, а сервису приходится заново выяснять, что произошло, хотя агрегат это знал точно.

Решение ​

Агрегат при каждом изменении создаёт событие: SubscriptionStarted, PlanChanged, SubscriptionCancelled. Имя — глагол в прошедшем времени, потому что это факт, который уже случился, его нельзя отменить или отклонить. Событие — значение: в нём только данные на момент события (какая подписка, с какого тарифа на какой, когда), без ссылок на сам агрегат. Через час тариф снова поменяется, а PlanChanged будет по-прежнему говорить basic -> pro.

События не уходят никуда сами. Агрегат копит их в списке, а тот, кто управляет операцией, забирает их методом releaseEvents и решает, кому и когда их передать. Агрегат остаётся чистой моделью без зависимостей.

Структура ​

records

records

records

SubscriptionStarted

+subscriptionId

+plan

+occurredAt

PlanChanged

+subscriptionId

+from

+to

+occurredAt

SubscriptionCancelled

+subscriptionId

+reason

+occurredAt

RecordsEvents

-recorded: list

#recordThat(event)

+releaseEvents() : list

Subscription

+id

+plan

-cancelled: bool

+start(id, plan, at) : Subscription

+changePlan(plan, at)

+cancel(reason, at)

Агрегат 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) сужает тип, и компилятор подскажет, если новый тип события не обработан.

Практика ​

Учебный агрегат отдаёт события, но не показывает, что с ними делать дальше. Практика: публикация событий после сохранения добавляет сервис приложения и диспетчер: события несут версию агрегата и время из внедряемых часов, публикуются только после успешного сохранения и не публикуются вовсе, если сохранение упало или агрегат отказал в операции.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS