Logo
Overview

Доменные события (Domain Events): связь DDD и событийной архитектуры

October 19, 2026
6 min read

Доменные события — это не просто «запись в лог». Это язык, на котором агрегат рассказывает миру, что случилось.

Если вы уже разбирались с основами 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 не отправляет событие сразу. Оно копится в коллекции внутри агрегата и диспатчится только после того, как транзакция закоммичена. Это принципиально: событие не должно уходить в мир до того, как состояние агрегата сохранено в БД.

Механика обычно такая: репозиторий сохраняет агрегат, собирает накопившиеся события и передаёт их диспетчеру. И только диспетчер раскидывает события по обработчикам. Если вы диспатчите событие прямо из метода агрегата — вы нарушаете атомарность: обработчик отработает, а сохранение агрегата упадёт.

100%
graph TD
  A["Пользователь
размещает заказ"] --> B["Заказ
(Агрегат)"]
  B -->|"регистрирует"| C["OrderPlaced
(Доменное событие)"]
  B -->|"сохраняется
в БД"| D["База данных
(таблица Orders)"]
  C --> D
  D -->|"dispatch
событий"| E["Event
Dispatcher"]
  E --> F["EmailHandler:
уведомить
клиента"]
  E --> G["WarehouseHandler:
зарезервировать
товар"]
  E --> H["IntegrationHandler:
опубликовать во
внешний мир"]
  H --> I["Kafka / RabbitMQ"]
  I --> J["Контекст
Биллинг"]
  I --> K["Контекст
Логистика"]
  
  style A fill:#e0e0e0,stroke:#999,color:#333
  style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style C fill:#f0a500,stroke:#c88400,color:#fff
  style D fill:#7b68ee,stroke:#5a4db2,color:#fff
  style E fill:#7b68ee,stroke:#5a4db2,color:#fff
  style F fill:#50c878,stroke:#3a9a5c,color:#fff
  style G fill:#50c878,stroke:#3a9a5c,color:#fff
  style H fill:#50c878,stroke:#3a9a5c,color:#fff
  style I fill:#f0a500,stroke:#c88400,color:#fff
  style J fill:#e0e0e0,stroke:#999,color:#333
  style K fill:#e0e0e0,stroke:#999,color:#333

На схеме — полный жизненный цикл. Агрегат регистрирует 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. Я бы даже сказал — необходимый. Без них событийная архитектура превращается в асинхронный спагетти-код, где сообщения летят без понятной семантики.

Цепочка выглядит так:

  1. DDD даёт смыслы. Доменные события несут бизнес-значение. OrderPlaced понятен и разработчику, и продакт-менеджеру.
  2. Агрегат обеспечивает консистентность. Событие публикуется атомарно с изменением состояния.
  3. Интеграционные события расширяют радиус действия. Другие контексты и внешние системы реагируют, не зная о внутренней кухне контекста-источника.

Событийная архитектура без DDD возможна — но это как строить дом без фундамента. Сообщения есть, а кто за что отвечает, какое событие что значит и почему оно должно быть именно таким — непонятно. С DDD вы получаете словарь событий, привязанный к модели домена.

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

Типичные ошибки

Три самые частые, которые я вижу в коде и на ревью:

  • Диспатч до коммита. Вызвали dispatcher.Dispatch(events) до SaveChanges — и обработчик ушёл отправлять письмо клиенту, пока заказ ещё не в БД. Если SaveChanges упадёт — клиент получил письмо, заказа нет.
  • Тяжёлая логика в обработчике. Обработчик доменного события должен быть быстрым. Если нужно слать HTTP-запрос в три сторонних сервиса — выносите в интеграционное событие и обрабатывайте асинхронно.
  • Зависимость между обработчиками. EmailHandler ждёт, пока отработает WarehouseHandler. Порядок обработки не должен быть важен. Если есть порядок — это один обработчик, а не два.

Что делать дальше

  • Если ещё не разбирали DDD-базу — начните с обзора bounded context, aggregate и ubiquitous language.
  • Когда поймёте события — копайте в событийную архитектуру. Там про брокеры, гарантии доставки и когда EDA оправдана.
  • В своём коде: найдите один агрегат и добавьте ему доменное событие на ключевое действие. Не переписывайте всё — просто одно событие с одним обработчиком, чтобы почувствовать механику на практике.