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

Проектирование событий: имя, 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
  }
}

Чеклист проектирования события

Перед добавлением нового события проверьте:

  1. Имя в прошедшем времени?TodoCompleted, не CompleteTodo
  2. Специфичное имя?TodoTitleChanged, не TodoUpdated
  3. Бизнес-язык?TodoArchived, не TodoSoftDeleted
  4. Все поля readonly? — иммутабельность гарантирована
  5. Достаточно данных для подписчиков? — self-contained
  6. Нет избыточных данных? — только релевантное
  7. Есть eventId? — для идемпотентности
  8. Есть occurredAt? — для хронологии
  9. Есть aggregateId? — для привязки к агрегату
  10. Есть correlationId/causationId? — для трассировки
  11. Указана schemaVersion? — для эволюции
  12. Нет инфраструктурных типов? — домен чист от SQL/HTTP
  13. Одно действие — одно событие? — не комбинированные события
  14. Для изменений: есть 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,
)

Итоги

  1. Имя события — прошедшее время, специфичное, на языке домена
  2. Структура — Header (ID, время, агрегат) + Payload (данные) + Metadata (трассировка)
  3. Гранулярность — сбалансированная: достаточно данных для подписчиков, но без избыточности
  4. Correlation/Causation ID — связывают цепочки событий в единую бизнес-операцию
  5. EventId — обеспечивает идемпотентность обработки
  6. Версионирование — schemaVersion + upcasting для обратной совместимости
  7. Антипаттерны — God Event, CRUD Events, Chatty Events, Secret Events, Mutable Events

Далее: 03-event-with-schema.md — Event как Schema.TaggedClass: типобезопасные события