Типобезопасный домен: Гексагональная архитектура на базе Effect События Todo: Created, Completed, Archived, TitleChanged
Глава

События Todo: Created, Completed, Archived, TitleChanged

Полная реализация всех доменных событий Todo-приложения. Каждое событие: мотивация, Schema.TaggedClass определение, фабричная функция, apply-handler, пример использования. Todo Aggregate с порождением событий. Модуль событий. Integration Events для межконтекстной коммуникации. Полный пример потока данных: HTTP → UseCase → Aggregate → Events → Handlers.

Введение: собираем всё вместе

В предыдущих статьях мы изучили теорию Domain Events, правила проектирования, реализацию через Schema.TaggedClass, порождение из агрегатов и механизм dispatch. Теперь соберём всё в единую, production-ready реализацию событий для Todo-домена.

Для каждого события мы рассмотрим:

  1. Бизнес-мотивацию — зачем это событие нужно
  2. Schema-определение — полная типобезопасная реализация
  3. apply-функцию — как событие меняет состояние агрегата
  4. Пример потока — от команды до обработчиков

Доменные типы (повторение)

Для полноты картины напомним основные типы домена:

import { Schema, Option, Effect } 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 TodoStatus = Schema.Literal("active", "completed", "archived")
type TodoStatus = typeof TodoStatus.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

// ─── Состояние агрегата ─────────────────────────────────────

interface Todo {
  readonly id: TodoId
  readonly title: TodoTitle
  readonly description: string
  readonly status: TodoStatus
  readonly priority: Priority
  readonly dueDate: Option.Option<Date>
  readonly completedAt: Option.Option<Date>
  readonly archivedAt: Option.Option<Date>
  readonly version: number
  readonly createdAt: Date
  readonly createdBy: UserId
}

Событие 1: TodoCreated

Бизнес-мотивация

TodoCreated — начало жизненного цикла задачи. Без этого события задачи не существует. Событие фиксирует:

  • Кто создал задачу
  • С каким заголовком и приоритетом
  • Был ли установлен дедлайн при создании

Подписчики используют это событие для: обновления счётчиков, отправки уведомлений в Slack, обновления дашборда.

Определение

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

Apply

const applyTodoCreated = (_state: Todo | null, event: TodoCreated): Todo => ({
  id: event.todoId,
  title: event.title,
  description: event.description,
  status: "active" as const,
  priority: event.priority,
  dueDate: event.dueDate,
  completedAt: Option.none(),
  archivedAt: Option.none(),
  version: event.aggregateVersion,
  createdAt: event.createdAt,
  createdBy: event.createdBy,
})

Порождение из агрегата

const createTodo = (params: {
  readonly todoId: TodoId
  readonly title: TodoTitle
  readonly description: string
  readonly priority: Priority
  readonly dueDate: Option.Option<Date>
  readonly createdBy: UserId
  readonly now: Date
  readonly ctx: EventContext
}): TodoAggregate => {
  const event = new TodoCreated({
    eventId: generateEventId(),
    aggregateId: params.todoId,
    aggregateVersion: 1,
    occurredAt: params.now,
    schemaVersion: 1,
    correlationId: params.ctx.correlationId,
    causationId: params.ctx.causationId,
    triggeredBy: params.createdBy,
    todoId: params.todoId,
    title: params.title,
    description: params.description,
    priority: params.priority,
    dueDate: params.dueDate,
    createdBy: params.createdBy,
    createdAt: params.now,
  })
  
  const state = applyTodoCreated(null, event)
  return { state, uncommittedEvents: [event] }
}

Событие 2: TodoCompleted

Бизнес-мотивация

TodoCompleted — ключевое бизнес-событие. Задача выполнена. Это запускает:

  • Обновление статистики (% завершения, среднее время выполнения)
  • Уведомление менеджера (если задача просрочена)
  • Разблокировку зависимых задач
  • Начисление очков в геймификации

Определение

export class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    ...BaseEventFields,
    
    todoId: TodoId,
    title: TodoTitle,
    completedAt: Schema.Date,
    completedBy: UserId,
    wasOverdue: Schema.Boolean,
    
    // Метрика: сколько времени задача была в работе
    durationMs: Schema.Number.pipe(Schema.greaterThanOrEqualTo(0)),
  }
) {}

Apply

const applyTodoCompleted = (state: Todo, event: TodoCompleted): Todo => ({
  ...state,
  status: "completed" as const,
  completedAt: Option.some(event.completedAt),
  version: event.aggregateVersion,
})

Порождение из агрегата

const completeTodo = (
  aggregate: TodoAggregate,
  completedBy: UserId,
  now: Date,
  ctx: EventContext,
): Effect.Effect<TodoAggregate, InvalidTransition> => {
  const { state } = aggregate
  
  // Инвариант: только активная задача может быть завершена
  if (state.status !== "active") {
    return Effect.fail(new InvalidTransition({
      from: state.status,
      to: "completed",
      reason: `Cannot complete todo in status "${state.status}"`,
    }))
  }
  
  const wasOverdue = Option.match(state.dueDate, {
    onNone: () => false,
    onSome: (due) => due < now,
  })
  
  const durationMs = now.getTime() - state.createdAt.getTime()
  
  const event = new TodoCompleted({
    eventId: generateEventId(),
    aggregateId: state.id,
    aggregateVersion: state.version + 1,
    occurredAt: now,
    schemaVersion: 1,
    correlationId: ctx.correlationId,
    causationId: ctx.causationId,
    triggeredBy: completedBy,
    todoId: state.id,
    title: state.title,
    completedAt: now,
    completedBy,
    wasOverdue,
    durationMs,
  })
  
  return Effect.succeed(emitEvent(aggregate, event))
}

Событие 3: TodoTitleChanged

Бизнес-мотивация

Заголовок — основное описание задачи. Его изменение может повлиять на:

  • Отображение в списках и уведомлениях
  • Поиск (переиндексация)
  • Аудит (кто и когда менял формулировку)

Мы сохраняем oldTitle и newTitle для полной истории.

Определение

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

Apply

const applyTodoTitleChanged = (state: Todo, event: TodoTitleChanged): Todo => ({
  ...state,
  title: event.newTitle,
  version: event.aggregateVersion,
})

Порождение из агрегата

const changeTodoTitle = (
  aggregate: TodoAggregate,
  newTitle: TodoTitle,
  changedBy: UserId,
  now: Date,
  ctx: EventContext,
): Effect.Effect<TodoAggregate, InvalidTransition> => {
  const { state } = aggregate
  
  // Инвариант: архивированные задачи нельзя редактировать
  if (state.status === "archived") {
    return Effect.fail(new InvalidTransition({
      from: "archived",
      to: "archived",
      reason: "Cannot change title of archived todo",
    }))
  }
  
  // Нет изменения — нет события
  if (state.title === newTitle) {
    return Effect.succeed(aggregate)
  }
  
  const event = new TodoTitleChanged({
    eventId: generateEventId(),
    aggregateId: state.id,
    aggregateVersion: state.version + 1,
    occurredAt: now,
    schemaVersion: 1,
    correlationId: ctx.correlationId,
    causationId: ctx.causationId,
    triggeredBy: changedBy,
    todoId: state.id,
    oldTitle: state.title,
    newTitle,
    changedBy,
  })
  
  return Effect.succeed(emitEvent(aggregate, event))
}

Событие 4: TodoPriorityChanged

Определение

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

Apply

const applyTodoPriorityChanged = (state: Todo, event: TodoPriorityChanged): Todo => ({
  ...state,
  priority: event.newPriority,
  version: event.aggregateVersion,
})

Событие 5: TodoDueDateSet

Определение

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

Apply

const applyTodoDueDateSet = (state: Todo, event: TodoDueDateSet): Todo => ({
  ...state,
  dueDate: Option.some(event.dueDate),
  version: event.aggregateVersion,
})

Событие 6: TodoDueDateRemoved

Определение

export class TodoDueDateRemoved extends Schema.TaggedClass<TodoDueDateRemoved>()(
  "TodoDueDateRemoved",
  {
    ...BaseEventFields,
    
    todoId: TodoId,
    removedDueDate: Schema.Date,
    removedBy: UserId,
  }
) {}

Apply

const applyTodoDueDateRemoved = (state: Todo, _event: TodoDueDateRemoved): Todo => ({
  ...state,
  dueDate: Option.none(),
  version: _event.aggregateVersion,
})

Событие 7: TodoArchived

Бизнес-мотивация

Архивация — мягкое удаление. Задача больше не активна, но сохраняется для истории. Архивированные задачи не отображаются в основном списке.

Определение

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

Apply

const applyTodoArchived = (state: Todo, event: TodoArchived): Todo => ({
  ...state,
  status: "archived" as const,
  archivedAt: Option.some(event.archivedAt),
  version: event.aggregateVersion,
})

Порождение из агрегата

const archiveTodo = (
  aggregate: TodoAggregate,
  archivedBy: UserId,
  reason: string | undefined,
  now: Date,
  ctx: EventContext,
): Effect.Effect<TodoAggregate, InvalidTransition> => {
  const { state } = aggregate
  
  // Инвариант: нельзя архивировать уже архивированную задачу
  if (state.status === "archived") {
    return Effect.fail(new InvalidTransition({
      from: "archived",
      to: "archived",
      reason: "Todo is already archived",
    }))
  }
  
  const event = new TodoArchived({
    eventId: generateEventId(),
    aggregateId: state.id,
    aggregateVersion: state.version + 1,
    occurredAt: now,
    schemaVersion: 1,
    correlationId: ctx.correlationId,
    causationId: ctx.causationId,
    triggeredBy: archivedBy,
    todoId: state.id,
    title: state.title,
    statusBeforeArchive: state.status as "active" | "completed",
    archivedAt: now,
    archivedBy,
    reason,
  })
  
  return Effect.succeed(emitEvent(aggregate, event))
}

Событие 8: TodoReopened

Бизнес-мотивация

Переоткрытие задачи — отмена завершения. Задача возвращается в статус active. Используется когда задача была завершена ошибочно или требования изменились.

Определение

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

Apply

const applyTodoReopened = (state: Todo, event: TodoReopened): Todo => ({
  ...state,
  status: "active" as const,
  completedAt: Option.none(),
  version: event.aggregateVersion,
})

Сводная функция applyEvent

Объединяем все apply-функции в одну:

// ─── Единая функция apply для всех событий ──────────────────

export const applyTodoEvent = (state: Todo | null, event: TodoEvent): Todo => {
  switch (event._tag) {
    case "TodoCreated":
      return applyTodoCreated(state, event)
    
    case "TodoCompleted":
      if (!state) throw new Error("Cannot apply TodoCompleted to null state")
      return applyTodoCompleted(state, event)
    
    case "TodoTitleChanged":
      if (!state) throw new Error("Cannot apply TodoTitleChanged to null state")
      return applyTodoTitleChanged(state, event)
    
    case "TodoPriorityChanged":
      if (!state) throw new Error("Cannot apply TodoPriorityChanged to null state")
      return applyTodoPriorityChanged(state, event)
    
    case "TodoDueDateSet":
      if (!state) throw new Error("Cannot apply TodoDueDateSet to null state")
      return applyTodoDueDateSet(state, event)
    
    case "TodoDueDateRemoved":
      if (!state) throw new Error("Cannot apply TodoDueDateRemoved to null state")
      return applyTodoDueDateRemoved(state, event)
    
    case "TodoArchived":
      if (!state) throw new Error("Cannot apply TodoArchived to null state")
      return applyTodoArchived(state, event)
    
    case "TodoReopened":
      if (!state) throw new Error("Cannot apply TodoReopened to null state")
      return applyTodoReopened(state, event)
  }
}

// ─── Rehydration ────────────────────────────────────────────

export const rehydrateTodo = (events: ReadonlyArray<TodoEvent>): Todo | null =>
  events.reduce<Todo | null>(applyTodoEvent, null)

Полный Union тип

// ─── Объединённый тип ───────────────────────────────────────

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

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

export type TodoEvents = ReadonlyArray<TodoEvent>

// ─── Утилиты сериализации ───────────────────────────────────

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

Полный поток данных: HTTP → Event Handlers

┌─────────────────────────────────────────────────────────────────┐
│ HTTP Request: POST /todos/:id/complete                          │
│ Body: { completedBy: "user-123" }                               │
└──────────────────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ HTTP Adapter: парсит request, создаёт CompleteTodoCommand       │
└──────────────────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ Application Service: completeTodoUseCase                        │
│ 1. repo.findById(todoId)                                        │
│ 2. aggregate = createTodoAggregate(todo)                        │
│ 3. updated = completeTodo(aggregate, userId, now, ctx)          │
│ 4. repo.save(updated.state)                                     │
│ 5. eventBus.publishAll(updated.uncommittedEvents)               │
└──────────────────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ Todo Aggregate: completeTodo()                                  │
│ - Проверяет: status === "active"                                │
│ - Вычисляет: wasOverdue, durationMs                             │
│ - Порождает: TodoCompleted event                                │
│ - Применяет: applyTodoCompleted → новое состояние               │
│ - Возвращает: { state, uncommittedEvents: [TodoCompleted] }     │
└──────────────────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ EventBus: publishAll([TodoCompleted])                           │
│                                                                  │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ StatsHandler    │ │ NotifyHandler   │ │ AuditHandler       │ │
│ │ increment       │ │ send push       │ │ log action         │ │
│ │ completed count │ │ notification    │ │ to audit trail     │ │
│ └─────────────────┘ └─────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ HTTP Response: 200 OK                                           │
│ Body: { id: "todo-123", status: "completed" }                   │
└─────────────────────────────────────────────────────────────────┘

Диаграмма жизненного цикла Todo через события

                    TodoCreated


              ┌─────────────────┐
              │     ACTIVE      │
              └────────┬────────┘

         ┌─────────────┼─────────────┐
         │             │             │
    TitleChanged  PriorityChanged  DueDateSet
    TitleChanged  PriorityChanged  DueDateRemoved
         │             │             │
         └─────────────┼─────────────┘

              ┌────────┴────────┐
              │                 │
        TodoCompleted     TodoArchived
              │                 │
              ▼                 ▼
    ┌─────────────────┐  ┌────────────┐
    │   COMPLETED     │  │  ARCHIVED  │
    └────────┬────────┘  └────────────┘

       ┌─────┴─────┐
       │            │
  TodoReopened  TodoArchived
       │            │
       ▼            ▼
    ACTIVE       ARCHIVED

Структура файлов

src/
  domain/
    events/
      base.ts              ← BaseEventFields, EventMetadataFields
      todo-created.ts      ← TodoCreated class
      todo-completed.ts    ← TodoCompleted class
      todo-title-changed.ts
      todo-priority-changed.ts
      todo-due-date-set.ts
      todo-due-date-removed.ts
      todo-archived.ts
      todo-reopened.ts
      todo-event.ts        ← Union type, encode/decode
      apply.ts             ← applyTodoEvent, rehydrateTodo
      index.ts             ← barrel export

Итоги

  1. 8 событий покрывают полный жизненный цикл Todo: создание → редактирование → завершение → архивация → переоткрытие
  2. Каждое событие реализовано через Schema.TaggedClass с полным набором базовых полей
  3. Функция apply для каждого события — чистая, без побочных эффектов
  4. Единый Union тип с exhaustive matching гарантирует обработку всех вариантов
  5. Полный поток: HTTP → UseCase → Aggregate → Event → EventBus → Handlers
  6. Нет изменения → нет события — только реальные изменения порождают факты

Далее: 07-exercises.md — Упражнения: реализуй события для Todo-домена