Проектирование событий: имя, payload, метаданные
Правила именования доменных событий. Структура события: заголовок, payload, метаданные. Гранулярность событий — тонкие vs толстые. Envelope Pattern. Correlation и Causation ID. Версионирование. EventId и идемпотентность. Практические правила проектирования. Антипаттерны. Полные примеры из Todo-домена с Effect-ts.
Введение: почему проектирование событий критически важно
Доменное событие — это контракт. Как только вы опубликовали событие, подписчики начали на него полагаться. Изменение структуры события ломает всех потребителей. Поэтому проектирование событий требует такой же тщательности, как проектирование API: продумайте именование, содержимое и эволюцию до того, как событие будет использовано в production.
В этой статье мы разберём все аспекты проектирования доменных событий: от правил именования до метаданных и стратегий версионирования.
Правила именования событий
Формат имени
Имя события должно однозначно сообщать, что произошло. Формат:
{AggregateType}{Action} — в PastParticiple
Примеры:
// Агрегат: Todo
"TodoCreated" // задача создана
"TodoCompleted" // задача завершена
"TodoArchived" // задача перемещена в архив
"TodoTitleChanged" // заголовок задачи изменён
"TodoPriorityChanged" // приоритет задачи изменён
"TodoDueDateSet" // установлен срок выполнения
"TodoDueDateRemoved" // срок выполнения удалён
"TodoReopened" // задача переоткрыта
"TodoDeleted" // задача удалена
// Агрегат: TodoList
"TodoListCreated" // список задач создан
"TodoListRenamed" // список переименован
"TodoAddedToList" // задача добавлена в список
"TodoRemovedFromList" // задача удалена из списка
Принципы именования
1. Прошедшее время (Past Tense)
Всегда. Без исключений.
// ✅ Факт — прошедшее время
"TodoCompleted"
"TodoTitleChanged"
// ❌ Императив — это команда, не событие
"CompleteTodo"
"ChangeTodoTitle"
// ❌ Настоящее время — это процесс, не факт
"TodoCompleting"
"TodoChanging"
2. Специфичность — называйте конкретное действие
// ❌ Слишком обобщённо — что именно обновилось?
"TodoUpdated"
"TodoChanged"
"TodoModified"
// ✅ Специфично — сразу понятно, что произошло
"TodoTitleChanged"
"TodoPriorityChanged"
"TodoDueDateSet"
"TodoDescriptionUpdated"
Обобщённое событие TodoUpdated бесполезно для подписчиков. Подписчику на обновление статистики нужен TodoCompleted, а не TodoUpdated с полем changedField: "status".
3. Бизнес-язык, а не технический
// ❌ Технический язык
"TodoRowInserted"
"TodoFieldPatched"
"TodoRecordMerged"
// ✅ Бизнес-язык (Ubiquitous Language)
"TodoCreated"
"TodoTitleChanged"
"TodoCompleted"
4. Избегайте отрицаний в именах
// ❌ Отрицание — неестественно
"TodoNotCompleted"
"TodoUndeleted"
// ✅ Позитивная формулировка
"TodoReopened" // вместо TodoNotCompleted
"TodoRestored" // вместо TodoUndeleted
5. Одно событие — одно бизнес-действие
// ❌ Одно событие для двух действий
interface TodoCompletedAndArchived {
readonly _tag: "TodoCompletedAndArchived"
readonly completedAt: Date
readonly archivedAt: Date
}
// ✅ Два отдельных события
interface TodoCompleted {
readonly _tag: "TodoCompleted"
readonly completedAt: Date
}
interface TodoArchived {
readonly _tag: "TodoArchived"
readonly archivedAt: Date
}
Структура события: три уровня
Каждое доменное событие состоит из трёх логических частей:
┌─────────────────────────────────────┐
│ Event Envelope │
│ ┌───────────────────────────────┐ │
│ │ Event Header │ │
│ │ eventId, _tag, occurredAt, │ │
│ │ aggregateId, version │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Event Payload │ │
│ │ Специфичные данные события │ │
│ │ title, priority, completedBy │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Event Metadata │ │
│ │ correlationId, causationId, │ │
│ │ triggeredBy, schemaVersion │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
1. Event Header — заголовок
Заголовок одинаков для всех событий в системе:
import { Schema } from "effect"
// Базовый заголовок — общий для всех событий
const EventHeaderFields = {
// Уникальный идентификатор этого конкретного события
eventId: Schema.String.pipe(
Schema.brand("EventId")
),
// К какому агрегату относится событие
aggregateId: Schema.String,
// Версия агрегата после применения этого события
// Критично для Event Sourcing и оптимистичной блокировки
aggregateVersion: Schema.Number.pipe(
Schema.int(),
Schema.positive()
),
// Когда событие произошло (серверное время)
occurredAt: Schema.Date,
} as const
2. Event Payload — полезная нагрузка
Payload содержит данные, специфичные для конкретного типа события:
// Payload для TodoCreated
const TodoCreatedPayload = {
todoId: TodoId,
title: TodoTitle,
priority: Priority,
dueDate: Schema.optional(Schema.Date),
} as const
// Payload для TodoCompleted
const TodoCompletedPayload = {
todoId: TodoId,
title: TodoTitle, // дублируем для self-containment
completedAt: Schema.Date,
completedBy: UserId,
wasOverdue: Schema.Boolean,
} as const
// Payload для TodoTitleChanged
const TodoTitleChangedPayload = {
todoId: TodoId,
oldTitle: TodoTitle, // что было
newTitle: TodoTitle, // что стало
changedAt: Schema.Date,
changedBy: UserId,
} as const
3. Event Metadata — метаданные
Метаданные содержат техническую информацию для трассировки, отладки и аудита:
const EventMetadataFields = {
// ID команды, которая вызвала это событие
causationId: Schema.String,
// ID бизнес-операции (группа связанных команд/событий)
correlationId: Schema.String,
// Кто инициировал действие
triggeredBy: UserId,
// Версия схемы события (для эволюции)
schemaVersion: Schema.Number.pipe(
Schema.int(),
Schema.positive()
),
} as const
Envelope Pattern — объединение всех частей
Envelope Pattern оборачивает событие в «конверт», разделяя инфраструктурные метаданные и бизнес-данные:
import { Schema, Effect } from "effect"
import * as Crypto from "node:crypto"
// ─── Генерация уникальных ID ───────────────────────────────
const generateEventId = (): string =>
Crypto.randomUUID()
// ─── Базовый тип конверта ───────────────────────────────────
interface EventEnvelope<Tag extends string, Payload> {
// Header
readonly eventId: string
readonly _tag: Tag
readonly aggregateId: string
readonly aggregateVersion: number
readonly occurredAt: Date
// Payload
readonly payload: Payload
// Metadata
readonly metadata: EventMetadata
}
interface EventMetadata {
readonly correlationId: string
readonly causationId: string
readonly triggeredBy: string
readonly schemaVersion: number
}
Однако на практике в Effect-ts удобнее «сплющить» (flatten) структуру, поместив все поля на один уровень. Это упрощает паттерн-матчинг и работу с Schema.TaggedClass. Мы рассмотрим этот подход подробно в следующей статье.
Гранулярность событий: тонкие vs толстые
Тонкие события (Thin Events)
Содержат минимум данных — только изменившиеся поля:
// Thin Event — минимум данных
interface TodoTitleChangedThin {
readonly _tag: "TodoTitleChanged"
readonly todoId: TodoId
readonly newTitle: TodoTitle
readonly occurredAt: Date
}
Плюсы: компактность, простота, меньше данных в транспорте.
Минусы: подписчику может потребоваться дополнительный запрос для получения контекста:
// Подписчику нужен старый заголовок для логирования
// Но в тонком событии его нет — придётся запрашивать
const handleTitleChanged = (event: TodoTitleChangedThin) =>
Effect.gen(function* () {
// ❌ Дополнительный запрос к репозиторию
const todo = yield* todoRepo.findById(event.todoId)
yield* logger.info(
`Title changed from "${todo.previousTitle}" to "${event.newTitle}"`
)
})
Толстые события (Fat Events)
Содержат полный контекст — всё, что может понадобиться подписчикам:
// Fat Event — полный контекст
interface TodoTitleChangedFat {
readonly _tag: "TodoTitleChanged"
readonly todoId: TodoId
readonly oldTitle: TodoTitle // что было
readonly newTitle: TodoTitle // что стало
readonly currentPriority: Priority // текущий приоритет
readonly currentStatus: TodoStatus // текущий статус
readonly todoCreatedAt: Date // когда задача создана
readonly changedAt: Date
readonly changedBy: UserId
}
Плюсы: подписчики самодостаточны, не нужны дополнительные запросы.
Минусы: больший объём данных, возможное дублирование информации.
Рекомендуемый баланс
На практике оптимальный подход — событие должно содержать достаточно данных, чтобы типичный подписчик мог обработать его без дополнительных запросов, но не больше:
// ✅ Сбалансированное событие
interface TodoTitleChanged {
readonly _tag: "TodoTitleChanged"
readonly todoId: TodoId
readonly oldTitle: TodoTitle // что было — нужно для аудита
readonly newTitle: TodoTitle // что стало — нужно для уведомлений
readonly changedAt: Date // когда — нужно для хронологии
readonly changedBy: UserId // кто — нужно для аудита
readonly occurredAt: Date
}
Правило: включайте «что было» и «что стало» для событий изменения. Это покрывает потребности большинства подписчиков.
Correlation ID и Causation ID
Зачем нужны
В реальной системе одно действие пользователя порождает каскад событий:
Пользователь нажимает "Complete" на задаче
└─→ CompleteTodoCommand (commandId: "cmd-1")
└─→ TodoCompleted (causedBy: "cmd-1")
├─→ UpdateStatsCommand (causedBy: TodoCompleted)
│ └─→ StatsUpdated
├─→ SendNotificationCommand (causedBy: TodoCompleted)
│ └─→ NotificationSent
└─→ UpdateDashboardCommand (causedBy: TodoCompleted)
└─→ DashboardUpdated
Без Correlation ID невозможно связать все эти события в единую цепочку. Без Causation ID непонятно, что именно вызвало каждое конкретное событие.
Определения
Correlation ID — идентификатор бизнес-операции верхнего уровня. Все события, порождённые одним действием пользователя, разделяют один Correlation ID:
// Все события от нажатия кнопки "Complete" имеют
// correlationId = "corr-abc-123"
// Это позволяет собрать полную картину:
// "что произошло, когда пользователь нажал Complete?"
Causation ID — идентификатор непосредственной причины этого события. Это ID команды или события, которое напрямую вызвало текущее:
// TodoCompleted вызвана CompleteTodoCommand
// causationId = "cmd-1" (ID команды)
// StatsUpdated вызвана TodoCompleted
// causationId = eventId TodoCompleted
Реализация
interface EventMetadata {
// Все события от одного действия пользователя
readonly correlationId: string
// Непосредственная причина этого события
readonly causationId: string
// Кто инициировал всю цепочку
readonly triggeredBy: string
}
// Создание метаданных из команды
const createMetadataFromCommand = (
command: { commandId: string; correlationId: string; userId: string }
): EventMetadata => ({
correlationId: command.correlationId,
causationId: command.commandId,
triggeredBy: command.userId,
})
// Создание метаданных для каскадного события
const createMetadataFromEvent = (
sourceEvent: { eventId: string; metadata: EventMetadata }
): EventMetadata => ({
// Correlation ID наследуется — цепочка остаётся единой
correlationId: sourceEvent.metadata.correlationId,
// Causation ID — ID события-причины
causationId: sourceEvent.eventId,
// Инициатор наследуется
triggeredBy: sourceEvent.metadata.triggeredBy,
})
Визуализация цепочки
correlationId: "corr-abc-123" (вся операция)
│
├─ CompleteTodoCommand (commandId: "cmd-1")
│ causationId: "user-action-xyz"
│
├─ TodoCompleted (eventId: "evt-1")
│ causationId: "cmd-1"
│
├─ StatsUpdated (eventId: "evt-2")
│ causationId: "evt-1"
│
├─ NotificationSent (eventId: "evt-3")
│ causationId: "evt-1"
│
└─ DashboardUpdated (eventId: "evt-4")
causationId: "evt-1"
EventId и идемпотентность
Зачем уникальный EventId
Каждое событие должно иметь глобально уникальный идентификатор:
import * as Crypto from "node:crypto"
const makeEventId = (): string => Crypto.randomUUID()
// "a7b3c8d9-e0f1-4a2b-8c3d-9e0f1a2b3c4d"
EventId решает несколько проблем:
1. Идемпотентность обработки
Если обработчик получил событие дважды (сбой сети, retry), он может проверить: «я уже обрабатывал событие с таким ID?»
const handleEvent = (event: TodoCompleted) =>
Effect.gen(function* () {
// Проверяем, не обрабатывали ли мы уже это событие
const alreadyProcessed = yield* processedEventsStore.has(event.eventId)
if (alreadyProcessed) {
yield* Effect.logDebug(`Event ${event.eventId} already processed, skipping`)
return
}
// Обрабатываем
yield* updateStats(event)
// Помечаем как обработанное
yield* processedEventsStore.add(event.eventId)
})
2. Трассировка и отладка
В логах вы всегда можете найти конкретное событие:
[2025-01-15T10:30:45Z] Event published: TodoCompleted eventId=evt-a7b3c8d9
[2025-01-15T10:30:45Z] Handler: StatsUpdater processing eventId=evt-a7b3c8d9
[2025-01-15T10:30:46Z] Handler: StatsUpdater completed eventId=evt-a7b3c8d9 (15ms)
3. Ссылки между событиями
Causation ID — это EventId события-причины:
// Событие A порождает событие B
// B.causationId = A.eventId
Что включать и не включать в payload
Включать
interface TodoCompleted {
readonly _tag: "TodoCompleted"
// ✅ Идентификатор агрегата
readonly todoId: TodoId
// ✅ Значения, которые изменились
readonly completedAt: Date
// ✅ Контекст для подписчиков (self-containment)
readonly title: TodoTitle
readonly completedBy: UserId
// ✅ Бизнес-данные, вычисленные в момент события
readonly wasOverdue: boolean
// ✅ «До» и «после» для событий изменения
// (для TodoCompleted не применимо, но для TodoPriorityChanged — да)
}
interface TodoPriorityChanged {
readonly _tag: "TodoPriorityChanged"
readonly todoId: TodoId
readonly oldPriority: Priority // ✅ что было
readonly newPriority: Priority // ✅ что стало
readonly changedAt: Date
readonly changedBy: UserId
}
Не включать
interface TodoCompletedBad {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
// ❌ Весь агрегат целиком — избыточно
readonly fullTodo: Todo
// ❌ Вычисляемые данные, не связанные с событием
readonly totalTodosInSystem: number
// ❌ Инфраструктурные детали
readonly sqlRowId: number
readonly httpRequestId: string
// ❌ Чувствительные данные (если не необходимо)
readonly userPassword: string
// ❌ Вложенные агрегаты
readonly ownerProfile: UserProfile
}
Правило: Include the delta, not the state
Для событий изменения включайте изменение (дельту), а не полное состояние:
// ❌ Полное состояние — избыточно, трудно понять что изменилось
interface TodoUpdated {
readonly _tag: "TodoUpdated"
readonly todo: {
readonly id: TodoId
readonly title: TodoTitle
readonly priority: Priority
readonly status: TodoStatus
readonly dueDate: Date | null
// ... все поля
}
}
// ✅ Дельта — только что изменилось
interface TodoTitleChanged {
readonly _tag: "TodoTitleChanged"
readonly todoId: TodoId
readonly oldTitle: TodoTitle
readonly newTitle: TodoTitle
readonly changedAt: Date
}
Версионирование событий
Со временем требования меняются, и структура события может эволюционировать. Версионирование — это способ управлять этими изменениями без потери обратной совместимости.
Стратегия версионирования
// Версия 1 — изначальная
interface TodoCreatedV1 {
readonly _tag: "TodoCreated"
readonly schemaVersion: 1
readonly todoId: string
readonly title: string
readonly createdAt: string // ISO string
}
// Версия 2 — добавлен приоритет
interface TodoCreatedV2 {
readonly _tag: "TodoCreated"
readonly schemaVersion: 2
readonly todoId: string
readonly title: string
readonly priority: "low" | "medium" | "high" // новое поле
readonly createdAt: string
}
// Версия 3 — добавлен createdBy
interface TodoCreatedV3 {
readonly _tag: "TodoCreated"
readonly schemaVersion: 3
readonly todoId: string
readonly title: string
readonly priority: "low" | "medium" | "high"
readonly createdBy: string // новое поле
readonly createdAt: string
}
Upcasting — трансформация старых версий
// Upcaster преобразует событие из старой версии в новую
const upcastTodoCreated = (raw: unknown): TodoCreatedV3 => {
const event = raw as Record<string, unknown>
const version = (event.schemaVersion as number) ?? 1
switch (version) {
case 1:
return {
...(event as TodoCreatedV1),
schemaVersion: 3,
priority: "medium" as const, // default для v1
createdBy: "system" as string, // default для v1, v2
}
case 2:
return {
...(event as TodoCreatedV2),
schemaVersion: 3,
createdBy: "system" as string, // default для v2
}
case 3:
return event as TodoCreatedV3
default:
throw new Error(`Unknown schema version: ${version}`)
}
}
Подробно версионирование событий рассматривается в Модуле 42 (Event Sourcing — продвинутые паттерны).
Правила обратной совместимости
При эволюции событий:
Можно:
- Добавлять новые optional-поля (с default-значением)
- Добавлять новые типы событий
- Добавлять новые значения в enum (с осторожностью)
Нельзя:
- Удалять существующие поля
- Переименовывать поля
- Менять типы полей
- Изменять _tag (имя) события
- Менять семантику существующих полей
// ✅ Безопасная эволюция — добавление optional-поля
// v1:
interface TodoCreatedV1 {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
}
// v2 — добавили optional-поле с default
interface TodoCreatedV2 {
readonly _tag: "TodoCreated"
readonly todoId: TodoId
readonly title: TodoTitle
readonly priority?: Priority // optional, default "medium"
}
// ❌ Опасная эволюция — переименование поля
interface TodoCreatedBroken {
readonly _tag: "TodoCreated"
readonly id: TodoId // было todoId → стало id — ЛОМАЕТ подписчиков!
readonly name: TodoTitle // было title → стало name — ЛОМАЕТ подписчиков!
}
Антипаттерны проектирования событий
1. «God Event» — событие-бог
// ❌ Одно событие на все случаи жизни
interface TodoChanged {
readonly _tag: "TodoChanged"
readonly todoId: TodoId
readonly changeType: "created" | "completed" | "archived" | "titleChanged"
readonly oldValue?: unknown
readonly newValue?: unknown
readonly changedField?: string
}
// Подписчики вынуждены парсить changeType и changedField
// Теряется типизация, теряется специфичность
2. «CRUD Events» — события как CRUD
// ❌ События повторяют CRUD-операции
interface TodoInserted { readonly _tag: "TodoInserted" }
interface TodoUpdated { readonly _tag: "TodoUpdated" }
interface TodoDeleted { readonly _tag: "TodoDeleted" }
// Нет бизнес-смысла — это инфраструктурные операции
3. «Chatty Events» — слишком много мелких событий
// ❌ Каждое поле — отдельное событие
interface TodoField1Changed { /* ... */ }
interface TodoField2Changed { /* ... */ }
interface TodoField3Changed { /* ... */ }
// 50 типов событий для одного агрегата — overhead
4. «Secret Events» — событие без контекста
// ❌ Событие без достаточных данных
interface TodoCompleted {
readonly _tag: "TodoCompleted"
readonly todoId: TodoId
// Всё. Ни title, ни completedBy, ни completedAt.
// Подписчик вынужден делать запрос к репозиторию.
}
5. «Mutable Events» — изменяемые события
// ❌ Изменяемые поля
class TodoCompleted {
public todoId: TodoId // mutable!
public completedAt: Date // mutable!
setCompletedAt(date: Date) { // мутирующий метод!
this.completedAt = date
}
}
Чеклист проектирования события
Перед добавлением нового события проверьте:
- Имя в прошедшем времени? —
TodoCompleted, неCompleteTodo - Специфичное имя? —
TodoTitleChanged, неTodoUpdated - Бизнес-язык? —
TodoArchived, неTodoSoftDeleted - Все поля readonly? — иммутабельность гарантирована
- Достаточно данных для подписчиков? — self-contained
- Нет избыточных данных? — только релевантное
- Есть eventId? — для идемпотентности
- Есть occurredAt? — для хронологии
- Есть aggregateId? — для привязки к агрегату
- Есть correlationId/causationId? — для трассировки
- Указана schemaVersion? — для эволюции
- Нет инфраструктурных типов? — домен чист от SQL/HTTP
- Одно действие — одно событие? — не комбинированные события
- Для изменений: есть old/new значения? — для аудита
Полный пример: события Todo-домена
Применяя все правила, спроектируем полный набор событий:
import { Schema } from "effect"
// ─── Базовые поля всех событий ─────────────────────────────
const BaseEventFields = {
eventId: Schema.String,
aggregateId: Schema.String,
aggregateVersion: Schema.Number.pipe(Schema.int(), Schema.positive()),
occurredAt: Schema.Date,
correlationId: Schema.String,
causationId: Schema.String,
triggeredBy: Schema.String,
schemaVersion: Schema.Number.pipe(Schema.int(), Schema.positive()),
} as const
// ─── TodoCreated ────────────────────────────────────────────
class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
"TodoCreated",
{
...BaseEventFields,
todoId: Schema.String,
title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
priority: Schema.Literal("low", "medium", "high"),
dueDate: Schema.optional(Schema.Date),
createdBy: Schema.String,
}
) {}
// ─── TodoCompleted ──────────────────────────────────────────
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
"TodoCompleted",
{
...BaseEventFields,
todoId: Schema.String,
title: Schema.String,
completedAt: Schema.Date,
completedBy: Schema.String,
wasOverdue: Schema.Boolean,
}
) {}
// ─── TodoTitleChanged ───────────────────────────────────────
class TodoTitleChanged extends Schema.TaggedClass<TodoTitleChanged>()(
"TodoTitleChanged",
{
...BaseEventFields,
todoId: Schema.String,
oldTitle: Schema.String,
newTitle: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
changedBy: Schema.String,
}
) {}
// ─── TodoPriorityChanged ────────────────────────────────────
class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()(
"TodoPriorityChanged",
{
...BaseEventFields,
todoId: Schema.String,
oldPriority: Schema.Literal("low", "medium", "high"),
newPriority: Schema.Literal("low", "medium", "high"),
changedBy: Schema.String,
}
) {}
// ─── Объединённый тип всех событий ─────────────────────────
type TodoEvent =
| TodoCreated
| TodoCompleted
| TodoTitleChanged
| TodoPriorityChanged
// ... остальные события
const TodoEvent = Schema.Union(
TodoCreated,
TodoCompleted,
TodoTitleChanged,
TodoPriorityChanged,
)
Итоги
- Имя события — прошедшее время, специфичное, на языке домена
- Структура — Header (ID, время, агрегат) + Payload (данные) + Metadata (трассировка)
- Гранулярность — сбалансированная: достаточно данных для подписчиков, но без избыточности
- Correlation/Causation ID — связывают цепочки событий в единую бизнес-операцию
- EventId — обеспечивает идемпотентность обработки
- Версионирование — schemaVersion + upcasting для обратной совместимости
- Антипаттерны — God Event, CRUD Events, Chatty Events, Secret Events, Mutable Events
Далее: 03-event-with-schema.md — Event как Schema.TaggedClass: типобезопасные события