Типобезопасный домен: Гексагональная архитектура на базе Effect Domain Event: факт, произошедший в прошлом
Глава

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

ХарактеристикаCommandDomain Event
ВремяБудущее (императив)Прошлое (свершившийся факт)
ИменованиеCreateTodo, CompleteTodoTodoCreated, 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)   │
  └───────────┘ └───────────┘       └───────────┘
  1. Создание: Команда поступает в агрегат
  2. Порождение: Агрегат проверяет инварианты и порождает событие
  3. Персистентность: Событие сохраняется (вместе с состоянием или вместо него)
  4. Публикация: Событие рассылается подписчикам
  5. Обработка: Каждый подписчик выполняет свою реакцию

Паттерн «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)→ События как единственный источник истины

Итоги

  1. Domain Event — это факт, произошедший в прошлом, выраженный на языке домена
  2. Иммутабелен — событие не может быть изменено после создания
  3. Self-contained — содержит всю необходимую информацию для обработки
  4. Прошедшее времяTodoCompleted, не CompleteTodo
  5. Отличается от Command (намерение), Query (запрос), System Event (инфраструктура)
  6. Декаплинг — агрегаты общаются через события, не через прямые вызовы
  7. Аудит — цепочка событий = полная история изменений
  8. Основа для CQRS и Event Sourcing — события связывают Write и Read стороны

Далее: 02-event-design.md — Проектирование событий: имя, payload, метаданные