Доменные события — это не просто «запись в лог». Это язык, на котором агрегат рассказывает миру, что случилось.
Если вы уже разбирались с основами DDD, то знаете: агрегат — это бастион консистентности. Он охраняет инварианты и делает вид, что за его границами ничего нет. Но бизнес-процессы редко заканчиваются внутри одного агрегата. Разместили заказ — нужно зарезервировать товар на складе, отправить уведомление клиенту, запустить биллинг. Как агрегат сообщает миру о том, что произошло, не расползаясь в спагетти-код? Ответ — доменные события.
И вот тут первый подводный камень. Программисты часто путают доменные события с техническими (файл загрузился, кнопка нажалась) или сразу бросаются в Kafka. Доменное событие — нечто другое. Это факт, который уже свершился в предметной области и имеет значение для бизнеса. OrderPlaced. PaymentReceived. ClaimApproved. Не ButtonClicked.
Что такое доменное событие и чем оно отличается от интеграционного
Доменное событие (Domain Event) — иммутабельный факт о том, что в домене что-то произошло. Оно рождается внутри агрегата, называется в прошедшем времени (
Placed, неPlace) и содержит ровно столько данных, сколько нужно обработчикам для реакции.
Проведу черту, потому что путаница здесь стоит дорого:
| Доменное событие | Интеграционное событие | |
|---|---|---|
| Где живёт | Внутри Bounded Context | Между Bounded Context’ами или системами |
| Кто создаёт | Агрегат | Специальный обработчик доменного события |
| Формат | Объект в коде, часть доменной модели | Контракт API: Avro, Protobuf, JSON Schema |
| Гарантии доставки | Внутри процесса — синхронно/в памяти | Через брокер: at-least-once, идемпотентность |
| Пример | OrderPlaced(orderId, customerId, total) | order.placed.v2 в Kafka-топике |
| Изменения | Меняется вместе с доменом | Версионируется как внешний контракт |
Коротко: доменное событие — это внутреннее дело вашего контекста. Интеграционное — внешний API в асинхронном исполнении. Хорошая архитектура разделяет их явно, а не надеется, что «само как-то выйдет».
Как доменное событие рождается внутри агрегата
Разберу на e-commerce примере. Пользователь размещает заказ:
public class Order : AggregateRoot{ public void Place(CustomerId customerId, Money total, List<OrderLine> lines) { Id = OrderId.New(); CustomerId = customerId; Total = total; _lines = lines; Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, customerId, total)); }}Два ключевых момента. Первый: доменное событие регистрируется внутри метода агрегата, а не снаружи. Не «кто-то вызвал Place, а потом кто-то снаружи создал событие». Сам агрегат решает, какое событие породить, и это часть его бизнес-логики. Второй: AddDomainEvent не отправляет событие сразу. Оно копится в коллекции внутри агрегата и диспатчится только после того, как транзакция закоммичена. Это принципиально: событие не должно уходить в мир до того, как состояние агрегата сохранено в БД.
Механика обычно такая: репозиторий сохраняет агрегат, собирает накопившиеся события и передаёт их диспетчеру. И только диспетчер раскидывает события по обработчикам. Если вы диспатчите событие прямо из метода агрегата — вы нарушаете атомарность: обработчик отработает, а сохранение агрегата упадёт.
На схеме — полный жизненный цикл. Агрегат регистрирует OrderPlaced, событие уходит в БД вместе с состоянием заказа (та же транзакция), после коммита диспетчер раздаёт его трём обработчикам. Два из них — чисто внутренние, в рамках контекста «Заказы». Третий — IntegrationHandler — публикует факт заказа во внешний мир через брокер. Только на этом этапе доменное событие превращается в интеграционное.
Разделяйте доменные и интеграционные события явно
Самый дорогой архитектурный грех, который я видел: разработчики кладут доменное событие напрямую в Kafka. Через месяц меняется поле в агрегате — ломаются три команды-потребителя. Ещё через месяц бизнес просит переименовать поле — и это breaking change для внешнего API, хотя внутри менять было безопасно.
Решение: тонкая прослойка-транслятор. IntegrationEventMapper получает доменное событие и превращает его в DTO интеграционного контракта. Контракт версионируется. Контракт живёт отдельно от доменной модели.
// Доменное событие — внутреннее, меняйте смелоpublic record OrderPlaced(OrderId OrderId, CustomerId CustomerId, Money Total);
// Интеграционное — внешний контракт, версионируйтеpublic record OrderPlacedV2( string orderId, string customerId, decimal totalAmount, string currency, DateTime placedAt);Ещё пара правил, выстраданных на реальных проектах:
- Доменное событие иммутабельно. Никаких сеттеров. Событие — свершившийся факт, редактировать историю нельзя. Если ошиблись — публикуете компенсирующее событие.
- Имя в прошедшем времени.
OrderShipped, неShipOrder. Событие констатирует факт, а не даёт команду. Разница принципиальна: если склад ещё не отгрузил —OrderShippedпубликовать нельзя. - Минимум данных, максимум идентификаторов. Событие должно содержать ровно столько информации, чтобы обработчик мог принять решение, не дёргая агрегат обратно. Обычно это ID затронутых сущностей плюс 1–2 критичных поля.
- Одно действие агрегата — одно событие. Если метод
PlaceOrderAndSendInvoiceгенерирует иOrderPlaced, иInvoiceCreated— вы что-то делаете не так. Метод агрегата должен делать одну вещь.
Связь с событийной архитектурой
Доменные события — естественный фундамент для Event-Driven Architecture. Я бы даже сказал — необходимый. Без них событийная архитектура превращается в асинхронный спагетти-код, где сообщения летят без понятной семантики.
Цепочка выглядит так:
- DDD даёт смыслы. Доменные события несут бизнес-значение.
OrderPlacedпонятен и разработчику, и продакт-менеджеру. - Агрегат обеспечивает консистентность. Событие публикуется атомарно с изменением состояния.
- Интеграционные события расширяют радиус действия. Другие контексты и внешние системы реагируют, не зная о внутренней кухне контекста-источника.
Событийная архитектура без DDD возможна — но это как строить дом без фундамента. Сообщения есть, а кто за что отвечает, какое событие что значит и почему оно должно быть именно таким — непонятно. С DDD вы получаете словарь событий, привязанный к модели домена.
И ещё: доменные события отлично ложатся в Event Sourcing. Если вы храните состояние агрегата как цепочку событий, а не как снапшот — доменные события становятся единственным источником истины. Перестроить проекцию, отладить баг, провести аудит — всё через поток событий. Отдельная тема, но стык здесь прямой.
Типичные ошибки
Три самые частые, которые я вижу в коде и на ревью:
- Диспатч до коммита. Вызвали
dispatcher.Dispatch(events)доSaveChanges— и обработчик ушёл отправлять письмо клиенту, пока заказ ещё не в БД. ЕслиSaveChangesупадёт — клиент получил письмо, заказа нет. - Тяжёлая логика в обработчике. Обработчик доменного события должен быть быстрым. Если нужно слать HTTP-запрос в три сторонних сервиса — выносите в интеграционное событие и обрабатывайте асинхронно.
- Зависимость между обработчиками. EmailHandler ждёт, пока отработает WarehouseHandler. Порядок обработки не должен быть важен. Если есть порядок — это один обработчик, а не два.
Что делать дальше
- Если ещё не разбирали DDD-базу — начните с обзора bounded context, aggregate и ubiquitous language.
- Когда поймёте события — копайте в событийную архитектуру. Там про брокеры, гарантии доставки и когда EDA оправдана.
- В своём коде: найдите один агрегат и добавьте ему доменное событие на ключевое действие. Не переписывайте всё — просто одно событие с одним обработчиком, чтобы почувствовать механику на практике.