Domain Event: факт, произошедший в прошлом
Что такое Domain Event, зачем нужны доменные события, отличие от системных событий и сообщений. Философия событийного мышления. Связь с Ubiquitous Language. Роль событий в DDD: коммуникация между агрегатами, аудит, Event Sourcing preview. Паттерн 'tell, don't ask' через события. Иммутабельность и временная метка. Примеры из Todo-домена.
Введение: почему события — фундаментальная концепция
Представьте себе бухгалтерскую книгу. В ней записаны не текущие балансы счетов, а транзакции — факты, произошедшие в прошлом: «15 января поступил платёж от клиента X на сумму Y», «20 января оплачен счёт поставщику Z». Текущий баланс — это производная от всех записанных фактов. Вы можете пересчитать баланс с нуля, «проиграв» все транзакции заново.
Domain Event — это запись факта в вашей бизнес-системе. Не команда «сделай что-то», не запрос «покажи что-то», а уведомление о том, что уже произошло. Когда пользователь завершает задачу в Todo-приложении, возникает факт: TodoCompleted. Этот факт невозможно «отменить» — он произошёл. Можно создать новый факт (TodoReopened), но удалить запись о том, что задача когда-то была завершена, нельзя.
Это принципиальное отличие событийного мышления от императивного. В традиционном подходе мы меняем состояние напрямую: todo.status = "completed". В событийном — мы фиксируем факт: «Задача X была завершена в момент T пользователем U», а текущее состояние вычисляется из цепочки таких фактов.
Что такое Domain Event
Формальное определение
Domain Event — это объект, представляющий что-то значимое, что произошло в домене. Это запись о факте из прошлого, выраженная на языке домена (Ubiquitous Language).
Ключевые характеристики:
1. Произошёл в прошлом (Past Tense)
Событие всегда формулируется в прошедшем времени: TodoCreated, TodoCompleted, TodoArchived. Не CreateTodo (это команда), не TodoCompleting (это процесс), а именно TodoCompleted — свершившийся факт.
// ✅ Правильно — прошедшее время, факт
type TodoCreated = { readonly _tag: "TodoCreated" }
type TodoCompleted = { readonly _tag: "TodoCompleted" }
type TodoTitleChanged = { readonly _tag: "TodoTitleChanged" }
type TodoArchived = { readonly _tag: "TodoArchived" }
// ❌ Неправильно — императив, команда
type CreateTodo = { readonly _tag: "CreateTodo" }
type CompleteTodo = { readonly _tag: "CompleteTodo" }
// ❌ Неправильно — процесс, не факт
type TodoCompleting = { readonly _tag: "TodoCompleting" }
type TodoBeingCreated = { readonly _tag: "TodoBeingCreated" }
2. Иммутабелен (Immutable)
Событие — это запись в журнале. Журнальные записи не редактируются. После создания событие не может быть изменено. В TypeScript это означает readonly на всех полях и отсутствие методов мутации:
// Событие — полностью иммутабельный объект
interface TodoCreated {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
readonly createdAt: Date
readonly createdBy: UserId
}
3. Содержит всю необходимую информацию (Self-Contained)
Событие должно нести достаточно данных, чтобы подписчик мог обработать его без дополнительных запросов. Не ссылку на объект, а конкретные значения:
// ❌ Плохо — подписчику придётся делать запрос к БД
interface TodoCompletedBad {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId // только ID, нет деталей
}
// ✅ Хорошо — подписчик имеет всю нужную информацию
interface TodoCompletedGood {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
readonly title: TodoTitle
readonly completedAt: Date
readonly completedBy: UserId
readonly wasOverdue: boolean
}
4. Принадлежит домену (Domain-Owned)
Событие формулируется на языке домена, а не на языке инфраструктуры. Не RowInserted, не HttpRequestReceived, а TodoCreated. Доменное событие является частью Ubiquitous Language:
// ❌ Инфраструктурное событие — не часть домена
interface RowInserted {
readonly table: string
readonly rowId: number
}
// ✅ Доменное событие — часть Ubiquitous Language
interface TodoCreated {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
readonly priority: Priority
readonly createdAt: Date
}
5. Имеет временную метку (Timestamp)
Каждое событие фиксирует момент своего возникновения. Это критично для аудита, отладки и восстановления последовательности:
interface DomainEvent {
readonly occurredAt: Date
}
interface TodoCreated extends DomainEvent {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
readonly occurredAt: Date // когда именно это произошло
}
Domain Event vs другие типы сообщений
Важно чётко различать Domain Event от похожих, но принципиально отличающихся концепций.
Command vs Event
| Характеристика | Command | Domain Event |
|---|---|---|
| Время | Будущее (императив) | Прошлое (свершившийся факт) |
| Именование | CreateTodo, CompleteTodo | TodoCreated, TodoCompleted |
| Направленность | Один получатель | Много подписчиков |
| Отказ | Может быть отклонена | Уже произошло, отказ невозможен |
| Идемпотентность | Должна обеспечиваться | Естественно идемпотентен (факт) |
| Содержит | Намерение + данные для действия | Факт + данные о произошедшем |
// Command — намерение выполнить действие
// Может быть отклонена (задача не найдена, уже завершена)
interface CompleteTodoCommand {
readonly _tag: "CompleteTodoCommand"
readonly todoId: TodoId
readonly completedBy: UserId
}
// Event — результат выполненного действия
// Факт, который нельзя отменить
interface TodoCompletedEvent {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
readonly title: TodoTitle
readonly completedAt: Date
readonly completedBy: UserId
}
Типичный поток: Command → Aggregate обрабатывает → порождает Event:
CompleteTodoCommand → Todo.complete() → TodoCompleted
(намерение) (бизнес-логика) (факт)
Query vs Event
Query — это запрос данных. Event — уведомление о произошедшем. Query не меняет состояние, Event — следствие изменения:
// Query — «покажи мне» (чтение, без побочных эффектов)
interface GetTodoByIdQuery {
readonly todoId: TodoId
}
// Event — «произошло вот что» (следствие изменения)
interface TodoCompleted {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
readonly completedAt: Date
}
Integration Event vs Domain Event
Domain Event существует внутри одного ограниченного контекста (Bounded Context). Integration Event — это событие, пересекающее границы контекстов:
// Domain Event — внутри контекста "Todo Management"
interface TodoCompleted {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
readonly title: TodoTitle
readonly completedAt: Date
}
// Integration Event — для внешних контекстов
// Содержит минимум данных, использует примитивные типы
interface TaskFinished {
readonly _tag: "TaskFinished"
readonly taskId: string // не TodoId, а string
readonly taskTitle: string // не TodoTitle, а string
readonly finishedAt: string // ISO string, не Date
readonly sourceContext: "todo-management"
}
Domain Event использует доменные типы (TodoId, TodoTitle), Integration Event — примитивные типы (string, number), потому что внешний контекст не знает о ваших Value Objects.
System/Technical Event vs Domain Event
System Event — инфраструктурное событие, не связанное с бизнес-логикой:
// System Event — инфраструктура, не часть домена
interface DatabaseConnectionEstablished {
readonly host: string
readonly port: number
readonly timestamp: Date
}
interface HttpRequestReceived {
readonly method: string
readonly path: string
readonly statusCode: number
}
// Domain Event — бизнес-факт
interface TodoCreated {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
readonly createdAt: Date
}
System Event живёт в инфраструктурном слое и не должен проникать в домен. Domain Event живёт в доменном слое и не знает об инфраструктуре.
Зачем нужны Domain Events
1. Декаплинг между агрегатами
Без событий агрегаты общаются напрямую, создавая жёсткую связность:
// ❌ Без событий — жёсткая связанность
const completeTodo = (todoRepo: TodoRepository, statsService: StatsService) =>
Effect.gen(function* () {
const todo = yield* todoRepo.findById(id)
const completed = todo.complete()
yield* todoRepo.save(completed)
// Прямой вызов — жёсткая связанность с StatsService
yield* statsService.incrementCompleted(todo.id)
// А если добавить уведомления? Ещё один прямой вызов:
yield* notificationService.notifyCompleted(todo.id)
// А если добавить аудит? И ещё один:
yield* auditService.logAction("complete", todo.id)
})
С событиями — агрегат просто публикует факт, а кто его обработает — забота подписчиков:
// ✅ С событиями — слабая связанность
const completeTodo = (todoRepo: TodoRepository, eventBus: EventBus) =>
Effect.gen(function* () {
const todo = yield* todoRepo.findById(id)
const [completed, events] = todo.complete()
yield* todoRepo.save(completed)
// Публикуем факт — кто подпишется, тот и обработает
yield* eventBus.publishAll(events)
// StatsService, NotificationService, AuditService —
// все подписаны на TodoCompleted и реагируют самостоятельно
})
2. Аудит и история изменений (Audit Trail)
Последовательность событий — это полная история того, что произошло с объектом:
// История жизни задачи — цепочка событий
const todoHistory: ReadonlyArray<TodoEvent> = [
{ _tag: "TodoCreated", todoId, title: "Write article", createdAt: t1 },
{ _tag: "TodoTitleChanged", todoId, oldTitle: "Write article", newTitle: "Write DDD article", changedAt: t2 },
{ _tag: "TodoPriorityChanged", todoId, oldPriority: "medium", newPriority: "high", changedAt: t3 },
{ _tag: "TodoCompleted", todoId, completedAt: t4 },
{ _tag: "TodoArchived", todoId, archivedAt: t5 },
]
// Из этой истории можно узнать:
// - Когда задача была создана (t1)
// - Сколько раз менялся заголовок (1 раз)
// - Когда повысили приоритет (t3)
// - Сколько времени задача была в работе (t4 - t1)
// - Кто и когда архивировал (t5)
3. Реактивные побочные эффекты
События позволяют реагировать на изменения без модификации основного кода:
// Обработчик 1: обновление статистики
const onTodoCompleted = (event: TodoCompleted) =>
statsService.incrementCompleted(event.todoId)
// Обработчик 2: отправка уведомления
const onTodoCompleted2 = (event: TodoCompleted) =>
notificationService.notify(
`Task "${event.title}" completed!`
)
// Обработчик 3: обновление Read Model для дашборда
const onTodoCompleted3 = (event: TodoCompleted) =>
dashboardProjection.markCompleted(event.todoId, event.completedAt)
// Добавление нового обработчика НЕ требует изменения
// кода, который порождает событие TodoCompleted
4. Основа для Event Sourcing
Если хранить все события, состояние агрегата можно восстановить из цепочки событий:
// Вместо хранения текущего состояния в таблице:
// { id: "1", title: "Write article", status: "completed", priority: "high" }
//
// Хранятся события:
// 1. TodoCreated { title: "Write article", priority: "medium" }
// 2. TodoPriorityChanged { newPriority: "high" }
// 3. TodoCompleted { completedAt: ... }
//
// Текущее состояние = reduce(events, initialState)
const rehydrate = (events: ReadonlyArray<TodoEvent>): Todo =>
events.reduce(applyEvent, Todo.empty())
Это подробно рассматривается в Части IX (Модули 38–42).
5. Межконтекстная коммуникация
Разные Bounded Contexts общаются через Integration Events (производные от Domain Events):
Todo Context Analytics Context
┌─────────────────┐ ┌─────────────────┐
│ TodoCompleted │──transform──▶│ TaskFinished │
│ (Domain Event) │ │ (Integration) │
└─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Update dashboard │
│ metrics │
└─────────────────┘
Событийное мышление и Ubiquitous Language
Domain Events — это не просто технический паттерн. Это способ мышления о бизнес-процессах.
Event Storming
Метод «Event Storming» (Альберто Брандолини) предлагает начинать проектирование системы не с сущностей и таблиц, а с событий — что произошло?
Для Todo-приложения Event Storming даёт:
Оранжевые стикеры (события):
TodoCreated
TodoTitleChanged
TodoDescriptionUpdated
TodoPriorityChanged
TodoDueDateSet
TodoDueDateRemoved
TodoCompleted
TodoReopened
TodoArchived
TodoDeleted
TodoAssignedToUser
TodoUnassigned
TodoTagAdded
TodoTagRemoved
TodoMovedToList
TodoBecameOverdue
Синие стикеры (команды, вызывающие события):
CreateTodo → TodoCreated
ChangeTodoTitle → TodoTitleChanged
CompleteTodo → TodoCompleted
ArchiveTodo → TodoArchived
...
Жёлтые стикеры (агрегаты):
Todo — владеет большинством событий
TodoList — владеет TodoMovedToList
События формируют язык домена
Когда бизнес-аналитик говорит «задача была завершена», в коде должно быть TodoCompleted. Когда говорят «приоритет был повышен», в коде — TodoPriorityChanged. События — это глаголы Ubiquitous Language в прошедшем времени:
// Ubiquitous Language → Domain Events
// "Пользователь создал задачу" → TodoCreated
// "Задача была завершена" → TodoCompleted
// "Приоритет задачи изменился" → TodoPriorityChanged
// "Задача была перемещена в архив" → TodoArchived
// "Задача стала просроченной" → TodoBecameOverdue
// "К задаче прикрепили файл" → TodoAttachmentAdded
Анатомия Domain Event
Каждое доменное событие состоит из нескольких обязательных и опциональных частей:
Обязательные части
interface DomainEvent {
// 1. Тег — уникальный идентификатор типа события
readonly _tag: string
// 2. Идентификатор агрегата — к какому агрегату относится
readonly aggregateId: string
// 3. Временная метка — когда произошло
readonly occurredAt: Date
}
Рекомендуемые метаданные
interface DomainEventWithMetadata extends DomainEvent {
// 4. Уникальный идентификатор события
readonly eventId: EventId
// 5. Версия агрегата после применения события
readonly aggregateVersion: number
// 6. Кто инициировал (correlation)
readonly causationId: string // ID команды, вызвавшей событие
readonly correlationId: string // ID всей бизнес-операции
// 7. Кто выполнил действие
readonly triggeredBy: UserId
}
Payload — данные события
interface TodoCompleted extends DomainEventWithMetadata {
readonly _tag: "TodoCompleted"
// Payload — специфичные данные этого события
readonly todoId: TodoId
readonly title: TodoTitle
readonly completedAt: Date
readonly completedBy: UserId
readonly wasOverdue: boolean // бизнес-контекст
readonly timeSpentMs: number // вычисленная метрика
}
Жизненный цикл Domain Event
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Команда │───▶│ Агрегат │───▶│ Событие │───▶│ Публикация │
│ (Command) │ │ обрабатывает│ │ порождается │ │ (Dispatch) │
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘
│
┌───────────────────────────────────────────┘
│
┌───────────┼───────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Handler 1 │ │ Handler 2 │ ... │ Handler N │
│ (Stats) │ │ (Notify) │ │ (Audit) │
└───────────┘ └───────────┘ └───────────┘
- Создание: Команда поступает в агрегат
- Порождение: Агрегат проверяет инварианты и порождает событие
- Персистентность: Событие сохраняется (вместе с состоянием или вместо него)
- Публикация: Событие рассылается подписчикам
- Обработка: Каждый подписчик выполняет свою реакцию
Паттерн «Tell, Don’t Ask» через события
Традиционный подход «Ask» — запрашиваем состояние и принимаем решение снаружи:
// ❌ Ask — запрашиваем состояние, действуем снаружи
const todo = yield* todoRepo.findById(id)
if (todo.status === "active") {
yield* statsService.incrementActive(-1)
yield* statsService.incrementCompleted(1)
}
if (todo.dueDate && todo.dueDate < now) {
yield* notificationService.notifyOverdueCompleted(todo)
}
Событийный подход «Tell» — агрегат сообщает о факте, подписчики реагируют:
// ✅ Tell — агрегат сообщает факт, подписчики реагируют
const [completed, events] = todo.complete(now, userId)
yield* todoRepo.save(completed)
yield* eventBus.publishAll(events)
// TodoCompleted содержит wasOverdue, и каждый подписчик
// сам решает, что с этим делать
Связь с другими модулями курса
Domain Events связывают вместе несколько ключевых концепций:
Модуль 13 (Entity) → Агрегат порождает события при изменении состояния
Модуль 14 (Domain Errors) → Невозможность породить событие = доменная ошибка
Модуль 15 (Aggregates) → Агрегат — единственный источник событий
Модуль 16 (Repository) → Репозиторий может сохранять события вместе с состоянием
Модуль 20+ (Ports) → EventBus как Driven Port
Модуль 34+ (CQRS) → События связывают Write Side и Read Side
Модуль 38+ (Event Sourcing)→ События как единственный источник истины
Итоги
- Domain Event — это факт, произошедший в прошлом, выраженный на языке домена
- Иммутабелен — событие не может быть изменено после создания
- Self-contained — содержит всю необходимую информацию для обработки
- Прошедшее время —
TodoCompleted, неCompleteTodo - Отличается от Command (намерение), Query (запрос), System Event (инфраструктура)
- Декаплинг — агрегаты общаются через события, не через прямые вызовы
- Аудит — цепочка событий = полная история изменений
- Основа для CQRS и Event Sourcing — события связывают Write и Read стороны
Далее: 02-event-design.md — Проектирование событий: имя, payload, метаданные