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

Event как Schema.TaggedClass: типобезопасные события

Реализация Domain Events через Effect Schema.TaggedClass. Базовый класс DomainEvent. Discriminated Union из событий. Decode/Encode для сериализации. Schema.Union для паттерн-матчинга. Branded Types в событиях. Генерация Arbitrary для тестирования. Полный модуль событий Todo-домена с Effect-ts.

Введение: почему Schema для событий

В предыдущей статье мы спроектировали структуру событий — имена, payload, метаданные. Теперь нужно реализовать всё это в коде так, чтобы:

  1. Типизация — компилятор гарантирует корректность payload
  2. Сериализация — события свободно переводятся в JSON и обратно
  3. Валидация — некорректные данные отсеиваются на этапе создания
  4. Паттерн-матчинг — Discriminated Union позволяет обрабатывать каждый тип события
  5. Тестирование — генерация случайных событий для property-based testing

Effect Schema.TaggedClass решает все пять задач единственным определением. Это тот же подход «Single Source of Truth», который мы использовали для Entity и Value Objects в Модулях 11–13.


Напоминание: Schema.TaggedClass

Schema.TaggedClass создаёт класс с автоматическим дискриминантом _tag, встроенной валидацией и сериализацией:

import { Schema } from "effect"

class MyEvent extends Schema.TaggedClass<MyEvent>()(
  "MyEvent",       // значение _tag
  {
    field1: Schema.String,
    field2: Schema.Number,
  }
) {}

// Результат:
// - Тип MyEvent с полями { _tag: "MyEvent", field1: string, field2: number }
// - Автоматический decode/encode
// - readonly на всех полях
// - Встроенная валидация

Базовые компоненты событийной системы

Event ID — уникальный идентификатор события

import { Schema, Brand } from "effect"

// Branded type для Event ID
type EventId = string & Brand.Brand<"EventId">

const EventId = Schema.String.pipe(
  Schema.minLength(1),
  Schema.brand("EventId")
)

// Генератор Event ID (чистая обёртка над crypto)
const generateEventId = (): EventId =>
  crypto.randomUUID() as EventId

Базовые поля — общие для всех событий

Определяем набор полей, которые присутствуют в каждом событии:

// ─── Базовые поля, общие для ВСЕХ доменных событий ─────────

const BaseEventFields = {
  /** Уникальный идентификатор события */
  eventId: EventId,
  
  /** ID агрегата, к которому относится событие */
  aggregateId: Schema.String.pipe(Schema.minLength(1)),
  
  /** Версия агрегата ПОСЛЕ применения этого события */
  aggregateVersion: Schema.Number.pipe(
    Schema.int(),
    Schema.greaterThanOrEqualTo(1)
  ),
  
  /** Временная метка — когда событие произошло */
  occurredAt: Schema.Date,
  
  /** Версия схемы события — для эволюции */
  schemaVersion: Schema.Number.pipe(
    Schema.int(),
    Schema.greaterThanOrEqualTo(1)
  ),
} as const

// ─── Метаданные трассировки ─────────────────────────────────

const EventMetadataFields = {
  /** ID бизнес-операции (общий для всей цепочки) */
  correlationId: Schema.String.pipe(Schema.minLength(1)),
  
  /** ID непосредственной причины (команда или событие) */
  causationId: Schema.String.pipe(Schema.minLength(1)),
  
  /** Кто инициировал действие */
  triggeredBy: Schema.String.pipe(Schema.minLength(1)),
} as const

Комбинация базовых полей

// Все поля, которые наследует каждое событие
const AllBaseFields = {
  ...BaseEventFields,
  ...EventMetadataFields,
} as const

Определение конкретных событий

TodoCreated

class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    ...AllBaseFields,
    
    // Payload — специфичные данные
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    title: Schema.String.pipe(
      Schema.minLength(1),
      Schema.maxLength(255),
      Schema.brand("TodoTitle")
    ),
    priority: Schema.Literal("low", "medium", "high"),
    dueDate: Schema.optionalWith(Schema.Date, { as: "Option" }),
    createdBy: Schema.String.pipe(Schema.brand("UserId")),
  }
) {}

Что создаёт Schema.TaggedClass:

// Тип TodoCreated автоматически включает:
// {
//   readonly _tag: "TodoCreated"
//   readonly eventId: EventId
//   readonly aggregateId: string
//   readonly aggregateVersion: number
//   readonly occurredAt: Date
//   readonly schemaVersion: number
//   readonly correlationId: string
//   readonly causationId: string
//   readonly triggeredBy: string
//   readonly todoId: TodoId
//   readonly title: TodoTitle
//   readonly priority: "low" | "medium" | "high"
//   readonly dueDate: Option<Date>
//   readonly createdBy: UserId
// }

TodoCompleted

class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    title: Schema.String.pipe(Schema.brand("TodoTitle")),
    completedAt: Schema.Date,
    completedBy: Schema.String.pipe(Schema.brand("UserId")),
    wasOverdue: Schema.Boolean,
  }
) {}

TodoTitleChanged

class TodoTitleChanged extends Schema.TaggedClass<TodoTitleChanged>()(
  "TodoTitleChanged",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    oldTitle: Schema.String.pipe(Schema.brand("TodoTitle")),
    newTitle: Schema.String.pipe(
      Schema.minLength(1),
      Schema.maxLength(255),
      Schema.brand("TodoTitle")
    ),
    changedBy: Schema.String.pipe(Schema.brand("UserId")),
  }
) {}

TodoPriorityChanged

class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()(
  "TodoPriorityChanged",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    oldPriority: Schema.Literal("low", "medium", "high"),
    newPriority: Schema.Literal("low", "medium", "high"),
    changedBy: Schema.String.pipe(Schema.brand("UserId")),
  }
) {}

TodoDueDateSet

class TodoDueDateSet extends Schema.TaggedClass<TodoDueDateSet>()(
  "TodoDueDateSet",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    dueDate: Schema.Date,
    previousDueDate: Schema.optionalWith(Schema.Date, { as: "Option" }),
    setBy: Schema.String.pipe(Schema.brand("UserId")),
  }
) {}

TodoArchived

class TodoArchived extends Schema.TaggedClass<TodoArchived>()(
  "TodoArchived",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    title: Schema.String.pipe(Schema.brand("TodoTitle")),
    archivedAt: Schema.Date,
    archivedBy: Schema.String.pipe(Schema.brand("UserId")),
    reason: Schema.optional(Schema.String),
  }
) {}

TodoReopened

class TodoReopened extends Schema.TaggedClass<TodoReopened>()(
  "TodoReopened",
  {
    ...AllBaseFields,
    
    todoId: Schema.String.pipe(Schema.brand("TodoId")),
    reopenedAt: Schema.Date,
    reopenedBy: Schema.String.pipe(Schema.brand("UserId")),
    reason: Schema.optional(Schema.String),
  }
) {}

Discriminated Union — объединённый тип событий

Определение Union

// ─── Объединённый тип всех событий Todo ─────────────────────

// TypeScript union type
type TodoEvent =
  | TodoCreated
  | TodoCompleted
  | TodoTitleChanged
  | TodoPriorityChanged
  | TodoDueDateSet
  | TodoArchived
  | TodoReopened

// Schema union — для decode/encode
const TodoEvent = Schema.Union(
  TodoCreated,
  TodoCompleted,
  TodoTitleChanged,
  TodoPriorityChanged,
  TodoDueDateSet,
  TodoArchived,
  TodoReopened,
)

Паттерн-матчинг по событиям

Discriminated Union + _tag позволяют TypeScript’у делать exhaustive matching:

import { Match } from "effect"

// ─── Exhaustive pattern matching ────────────────────────────

const describeEvent = (event: TodoEvent): string =>
  Match.value(event).pipe(
    Match.tag("TodoCreated", (e) =>
      `Todo "${e.title}" created with ${e.priority} priority`
    ),
    Match.tag("TodoCompleted", (e) =>
      `Todo "${e.title}" completed${e.wasOverdue ? " (was overdue!)" : ""}`
    ),
    Match.tag("TodoTitleChanged", (e) =>
      `Todo title changed from "${e.oldTitle}" to "${e.newTitle}"`
    ),
    Match.tag("TodoPriorityChanged", (e) =>
      `Todo priority changed from ${e.oldPriority} to ${e.newPriority}`
    ),
    Match.tag("TodoDueDateSet", (e) =>
      `Due date set to ${e.dueDate.toISOString()}`
    ),
    Match.tag("TodoArchived", (e) =>
      `Todo "${e.title}" archived`
    ),
    Match.tag("TodoReopened", (_e) =>
      `Todo reopened`
    ),
    Match.exhaustive  // компилятор проверяет, что все варианты покрыты
  )

Если вы добавите новый тип события в Union, но забудете обработать его в Match.exhaustive, TypeScript выдаст ошибку компиляции.

Обработка событий с побочными эффектами

import { Effect } from "effect"

const handleTodoEvent = (event: TodoEvent): Effect.Effect<void, never, StatsService | NotificationService> =>
  Match.value(event).pipe(
    Match.tag("TodoCreated", (e) =>
      Effect.gen(function* () {
        const stats = yield* StatsService
        yield* stats.incrementTotal()
        yield* Effect.logInfo(`New todo created: ${e.title}`)
      })
    ),
    Match.tag("TodoCompleted", (e) =>
      Effect.gen(function* () {
        const stats = yield* StatsService
        const notifications = yield* NotificationService
        yield* stats.incrementCompleted()
        if (e.wasOverdue) {
          yield* notifications.send(
            e.triggeredBy,
            `Overdue task "${e.title}" finally completed!`
          )
        }
      })
    ),
    Match.tag("TodoArchived", (_e) =>
      Effect.gen(function* () {
        const stats = yield* StatsService
        yield* stats.incrementArchived()
      })
    ),
    // Остальные события не требуют побочных эффектов
    Match.orElse(() => Effect.void),
  )

Сериализация и десериализация

Encode: Event → JSON

import { Schema, Effect } from "effect"

// Сериализация события в JSON
const encodeTodoEvent = Schema.encode(TodoEvent)

const serializeEvent = (event: TodoEvent) =>
  Effect.gen(function* () {
    // encode преобразует Date в ISO string, Brand-типы в обычные строки
    const json = yield* encodeTodoEvent(event)
    return JSON.stringify(json)
  })

// Пример:
// TodoCompleted { todoId: "abc" as TodoId, completedAt: new Date("2025-01-15"), ... }
// → '{"_tag":"TodoCompleted","todoId":"abc","completedAt":"2025-01-15T00:00:00.000Z",...}'

Decode: JSON → Event

const decodeTodoEvent = Schema.decode(TodoEvent)

const deserializeEvent = (jsonString: string) =>
  Effect.gen(function* () {
    const raw = JSON.parse(jsonString)
    // decode валидирует структуру, проверяет типы, создаёт branded values
    const event = yield* decodeTodoEvent(raw)
    return event  // типизированный TodoEvent
  })

Decode с паттерн-матчингом по _tag

// Schema.Union автоматически использует _tag для выбора правильной схемы
const result = yield* decodeTodoEvent({
  _tag: "TodoCompleted",
  eventId: "evt-123",
  aggregateId: "todo-456",
  aggregateVersion: 3,
  occurredAt: "2025-01-15T10:30:00Z",
  schemaVersion: 1,
  correlationId: "corr-789",
  causationId: "cmd-012",
  triggeredBy: "user-abc",
  todoId: "todo-456",
  title: "Write article",
  completedAt: "2025-01-15T10:30:00Z",
  completedBy: "user-abc",
  wasOverdue: false,
})

// result._tag === "TodoCompleted"  
// result: TodoCompleted (конкретный тип, не просто TodoEvent)

Фабрика создания событий

Для удобства создания событий определим фабричные функции, которые автоматически заполняют базовые поля:

import { Effect } from "effect"

// ─── Контекст создания события ──────────────────────────────

interface EventContext {
  readonly correlationId: string
  readonly causationId: string
  readonly triggeredBy: string
}

// ─── Фабричная функция ─────────────────────────────────────

const createEvent = <Tag extends string, Fields extends Record<string, unknown>>(
  EventClass: new (fields: any) => { readonly _tag: Tag },
  aggregateId: string,
  aggregateVersion: number,
  ctx: EventContext,
  payload: Fields,
) =>
  new (EventClass as any)({
    eventId: generateEventId(),
    aggregateId,
    aggregateVersion,
    occurredAt: new Date(),
    schemaVersion: 1,
    correlationId: ctx.correlationId,
    causationId: ctx.causationId,
    triggeredBy: ctx.triggeredBy,
    ...payload,
  })

// ─── Использование ──────────────────────────────────────────

const todoCreatedEvent = createEvent(
  TodoCreated,
  "todo-123",
  1,
  { correlationId: "corr-1", causationId: "cmd-1", triggeredBy: "user-1" },
  {
    todoId: "todo-123" as TodoId,
    title: "Write DDD article" as TodoTitle,
    priority: "high" as const,
    createdBy: "user-1" as UserId,
  },
)

Типизированная фабрика через Effect

Более функциональный подход — фабрика как Effect-функция:

import { Effect, Context } from "effect"

// ─── Сервис для генерации метаданных событий ────────────────

class EventMetadataProvider extends Context.Tag("EventMetadataProvider")<
  EventMetadataProvider,
  {
    readonly generateId: () => Effect.Effect<EventId>
    readonly now: () => Effect.Effect<Date>
  }
>() {}

// ─── Фабрика TodoCreated с Effect ───────────────────────────

const makeTodoCreated = (params: {
  readonly aggregateId: string
  readonly aggregateVersion: number
  readonly ctx: EventContext
  readonly todoId: string
  readonly title: string
  readonly priority: "low" | "medium" | "high"
  readonly dueDate?: Date
  readonly createdBy: string
}): Effect.Effect<TodoCreated, never, EventMetadataProvider> =>
  Effect.gen(function* () {
    const meta = yield* EventMetadataProvider
    const eventId = yield* meta.generateId()
    const now = yield* meta.now()
    
    return new TodoCreated({
      eventId,
      aggregateId: params.aggregateId,
      aggregateVersion: params.aggregateVersion,
      occurredAt: now,
      schemaVersion: 1,
      correlationId: params.ctx.correlationId,
      causationId: params.ctx.causationId,
      triggeredBy: params.ctx.triggeredBy,
      todoId: params.todoId,
      title: params.title,
      priority: params.priority,
      dueDate: params.dueDate,
      createdBy: params.createdBy,
    })
  })

Работа с коллекциями событий

Агрегат часто порождает несколько событий за одну операцию:

// Тип для коллекции событий
type TodoEvents = ReadonlyArray<TodoEvent>

// Агрегат возвращает обновлённое состояние + события
interface AggregateResult<State> {
  readonly state: State
  readonly events: TodoEvents
}

// Пример: завершение задачи может породить несколько событий
const completeTodo = (
  todo: Todo,
  completedBy: UserId,
  now: Date,
  ctx: EventContext,
): AggregateResult<Todo> => {
  const updatedTodo = { ...todo, status: "completed" as const, completedAt: now }
  
  const events: TodoEvents = [
    new TodoCompleted({
      eventId: generateEventId(),
      aggregateId: todo.id,
      aggregateVersion: todo.version + 1,
      occurredAt: now,
      schemaVersion: 1,
      correlationId: ctx.correlationId,
      causationId: ctx.causationId,
      triggeredBy: completedBy,
      todoId: todo.id,
      title: todo.title,
      completedAt: now,
      completedBy,
      wasOverdue: todo.dueDate !== undefined && todo.dueDate < now,
    }),
  ]
  
  return { state: updatedTodo, events }
}

Batch-операции с множественными событиями

// Обработка списка событий
const processEvents = (events: TodoEvents) =>
  Effect.forEach(events, handleTodoEvent, { discard: true })

// Сериализация списка событий  
const serializeEvents = (events: TodoEvents) =>
  Effect.forEach(events, (event) =>
    Schema.encode(TodoEvent)(event)
  )

Тестирование событий через Schema.Arbitrary

Schema автоматически может генерировать случайные экземпляры для property-based testing:

import { Arbitrary, FastCheck } from "effect"

// Генератор случайных TodoCreated событий
const todoCreatedArbitrary = Arbitrary.make(TodoCreated)

// Генератор случайных TodoEvent (любого типа)
const todoEventArbitrary = Arbitrary.make(TodoEvent)

// Property-based тест: encode → decode = identity
FastCheck.assert(
  FastCheck.property(todoCreatedArbitrary, (event) => {
    const encoded = Schema.encodeSync(TodoCreated)(event)
    const decoded = Schema.decodeSync(TodoCreated)(encoded)
    
    return decoded.eventId === event.eventId
      && decoded._tag === "TodoCreated"
      && decoded.todoId === event.todoId
  })
)

Round-trip тест для всех событий

// Критически важный тест: serialization round-trip
const roundTripProperty = FastCheck.property(
  todoEventArbitrary,
  (event) => {
    const encoded = Schema.encodeSync(TodoEvent)(event)
    const decoded = Schema.decodeSync(TodoEvent)(encoded)
    
    // После encode → decode событие должно быть идентичным
    return decoded._tag === event._tag
      && decoded.eventId === event.eventId
      && decoded.aggregateId === event.aggregateId
  }
)

FastCheck.assert(roundTripProperty)

Полный модуль событий Todo-домена

Соберём всё вместе в единый модуль:

// ─── domain/events/todo-events.ts ───────────────────────────

import { Schema } from "effect"

// ─── Branded Types ──────────────────────────────────────────

type EventId = string & Schema.Brand<"EventId">
const EventId = Schema.String.pipe(Schema.minLength(1), Schema.brand("EventId"))

type TodoId = string & Schema.Brand<"TodoId">
const TodoId = Schema.String.pipe(Schema.minLength(1), Schema.brand("TodoId"))

type TodoTitle = string & Schema.Brand<"TodoTitle">
const TodoTitle = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(255),
  Schema.brand("TodoTitle")
)

type UserId = string & Schema.Brand<"UserId">
const UserId = Schema.String.pipe(Schema.minLength(1), Schema.brand("UserId"))

const Priority = Schema.Literal("low", "medium", "high")
type Priority = typeof Priority.Type

// ─── Базовые поля ───────────────────────────────────────────

const BaseEventFields = {
  eventId: EventId,
  aggregateId: Schema.String.pipe(Schema.minLength(1)),
  aggregateVersion: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1)),
  occurredAt: Schema.Date,
  schemaVersion: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1)),
  correlationId: Schema.String.pipe(Schema.minLength(1)),
  causationId: Schema.String.pipe(Schema.minLength(1)),
  triggeredBy: Schema.String.pipe(Schema.minLength(1)),
} as const

// ─── Конкретные события ─────────────────────────────────────

export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    ...BaseEventFields,
    todoId: TodoId,
    title: TodoTitle,
    priority: Priority,
    dueDate: Schema.optionalWith(Schema.Date, { as: "Option" }),
    createdBy: UserId,
  }
) {}

export class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    ...BaseEventFields,
    todoId: TodoId,
    title: TodoTitle,
    completedAt: Schema.Date,
    completedBy: UserId,
    wasOverdue: Schema.Boolean,
  }
) {}

export class TodoTitleChanged extends Schema.TaggedClass<TodoTitleChanged>()(
  "TodoTitleChanged",
  {
    ...BaseEventFields,
    todoId: TodoId,
    oldTitle: TodoTitle,
    newTitle: TodoTitle,
    changedBy: UserId,
  }
) {}

export class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()(
  "TodoPriorityChanged",
  {
    ...BaseEventFields,
    todoId: TodoId,
    oldPriority: Priority,
    newPriority: Priority,
    changedBy: UserId,
  }
) {}

export class TodoDueDateSet extends Schema.TaggedClass<TodoDueDateSet>()(
  "TodoDueDateSet",
  {
    ...BaseEventFields,
    todoId: TodoId,
    dueDate: Schema.Date,
    previousDueDate: Schema.optionalWith(Schema.Date, { as: "Option" }),
    setBy: UserId,
  }
) {}

export class TodoArchived extends Schema.TaggedClass<TodoArchived>()(
  "TodoArchived",
  {
    ...BaseEventFields,
    todoId: TodoId,
    title: TodoTitle,
    archivedAt: Schema.Date,
    archivedBy: UserId,
    reason: Schema.optional(Schema.String),
  }
) {}

export class TodoReopened extends Schema.TaggedClass<TodoReopened>()(
  "TodoReopened",
  {
    ...BaseEventFields,
    todoId: TodoId,
    reopenedAt: Schema.Date,
    reopenedBy: UserId,
    reason: Schema.optional(Schema.String),
  }
) {}

// ─── Union тип ──────────────────────────────────────────────

export type TodoEvent =
  | TodoCreated
  | TodoCompleted
  | TodoTitleChanged
  | TodoPriorityChanged
  | TodoDueDateSet
  | TodoArchived
  | TodoReopened

export const TodoEvent = Schema.Union(
  TodoCreated,
  TodoCompleted,
  TodoTitleChanged,
  TodoPriorityChanged,
  TodoDueDateSet,
  TodoArchived,
  TodoReopened,
)

// ─── Утилиты ────────────────────────────────────────────────

export type TodoEvents = ReadonlyArray<TodoEvent>

export const decodeTodoEvent = Schema.decodeUnknown(TodoEvent)
export const encodeTodoEvent = Schema.encode(TodoEvent)

Schema.TaggedClass vs Data.TaggedError vs обычный интерфейс

ПодходКогда использоватьСериализацияВалидацияBrand
Schema.TaggedClassDomain Events (основной выбор)✅ Автоматическая✅ Встроенная✅ Поддержка
Data.TaggedErrorDomain Errors❌ Ручная❌ Ручная❌ Нет
interfaceПромежуточные DTO❌ Ручная❌ Ручная❌ Нет
Schema.ClassEntities, Value Objects✅ Автоматическая✅ Встроенная✅ Поддержка

Для Domain Events Schema.TaggedClass — оптимальный выбор, поскольку события нуждаются в сериализации (для Event Store, Message Queue, логирования) и валидации (при десериализации из хранилища).


Итоги

  1. Schema.TaggedClass — единый инструмент для определения, валидации и сериализации событий
  2. BaseEventFields — общие поля выносятся в переиспользуемый объект
  3. Discriminated Union через Schema.Union — безопасный паттерн-матчинг с exhaustive checking
  4. Match.exhaustive — компилятор гарантирует обработку всех типов событий
  5. Encode/Decode — автоматическая сериализация в JSON и обратно
  6. Schema.Arbitrary — автоматическая генерация тестовых данных
  7. Фабрика событий — автоматическое заполнение базовых полей через Effect

Далее: 04-event-sourcing-preview.md — Агрегат → Event: события как результат поведения