Event как Schema.TaggedClass: типобезопасные события
Реализация Domain Events через Effect Schema.TaggedClass. Базовый класс DomainEvent. Discriminated Union из событий. Decode/Encode для сериализации. Schema.Union для паттерн-матчинга. Branded Types в событиях. Генерация Arbitrary для тестирования. Полный модуль событий Todo-домена с Effect-ts.
Введение: почему Schema для событий
В предыдущей статье мы спроектировали структуру событий — имена, payload, метаданные. Теперь нужно реализовать всё это в коде так, чтобы:
- Типизация — компилятор гарантирует корректность payload
- Сериализация — события свободно переводятся в JSON и обратно
- Валидация — некорректные данные отсеиваются на этапе создания
- Паттерн-матчинг — Discriminated Union позволяет обрабатывать каждый тип события
- Тестирование — генерация случайных событий для 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.TaggedClass | Domain Events (основной выбор) | ✅ Автоматическая | ✅ Встроенная | ✅ Поддержка |
Data.TaggedError | Domain Errors | ❌ Ручная | ❌ Ручная | ❌ Нет |
interface | Промежуточные DTO | ❌ Ручная | ❌ Ручная | ❌ Нет |
Schema.Class | Entities, Value Objects | ✅ Автоматическая | ✅ Встроенная | ✅ Поддержка |
Для Domain Events Schema.TaggedClass — оптимальный выбор, поскольку события нуждаются в сериализации (для Event Store, Message Queue, логирования) и валидации (при десериализации из хранилища).
Итоги
- Schema.TaggedClass — единый инструмент для определения, валидации и сериализации событий
- BaseEventFields — общие поля выносятся в переиспользуемый объект
- Discriminated Union через Schema.Union — безопасный паттерн-матчинг с exhaustive checking
- Match.exhaustive — компилятор гарантирует обработку всех типов событий
- Encode/Decode — автоматическая сериализация в JSON и обратно
- Schema.Arbitrary — автоматическая генерация тестовых данных
- Фабрика событий — автоматическое заполнение базовых полей через Effect
Далее: 04-event-sourcing-preview.md — Агрегат → Event: события как результат поведения