События 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-домена.
Для каждого события мы рассмотрим:
- Бизнес-мотивацию — зачем это событие нужно
- Schema-определение — полная типобезопасная реализация
- apply-функцию — как событие меняет состояние агрегата
- Пример потока — от команды до обработчиков
Доменные типы (повторение)
Для полноты картины напомним основные типы домена:
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
Итоги
- 8 событий покрывают полный жизненный цикл Todo: создание → редактирование → завершение → архивация → переоткрытие
- Каждое событие реализовано через Schema.TaggedClass с полным набором базовых полей
- Функция apply для каждого события — чистая, без побочных эффектов
- Единый Union тип с exhaustive matching гарантирует обработку всех вариантов
- Полный поток: HTTP → UseCase → Aggregate → Event → EventBus → Handlers
- Нет изменения → нет события — только реальные изменения порождают факты
Далее: 07-exercises.md — Упражнения: реализуй события для Todo-домена