TodoList Aggregate: список задач как единица согласованности
Полная production-ready реализация TodoList Aggregate для сквозного проекта: от бизнес-требований до barrel exports. 8 бизнес-правил, 8 операций, 8 событий, 0 инфраструктурных зависимостей. Проверка архитектурной чистоты: R=never, иммутабельность, типизированные ошибки.
Бизнес-требования
Начнём с того, что нужно бизнесу. Наше Todo-приложение имеет следующие требования:
Функциональные требования
- Пользователь может создавать списки задач (TodoList)
- Каждый список имеет название и владельца
- В список можно добавлять задачи с заголовком и приоритетом
- Задачу можно завершить, переименовать, изменить приоритет, удалить
- Список можно архивировать (после этого он становится read-only)
- Список можно переименовать
Бизнес-правила (инварианты)
| # | Правило | Категория |
|---|---|---|
| BR-1 | В списке не более 50 задач | Коллекция |
| BR-2 | Заголовки задач уникальны внутри списка | Коллекция |
| BR-3 | Завершённую задачу нельзя завершить повторно | Состояние |
| BR-4 | Архивированный список нельзя изменять | Состояние |
| BR-5 | Название списка — от 1 до 100 символов | Атрибут |
| BR-6 | Заголовок задачи — от 1 до 200 символов | Атрибут |
| BR-7 | Приоритет — low / medium / high / critical | Атрибут |
| BR-8 | При создании список пуст | Начальное состояние |
Проектирование агрегата
Шаг 1: Определяем границы
Применяем эвристики из предыдущей статьи:
Инварианты: BR-1 и BR-2 затрагивают и TodoList, и TodoItem[] → одни агрегат.
Жизненный цикл: TodoItem не существует без TodoList → один жизненный цикл.
Размер коллекции: Максимум 50 элементов → приемлемый размер.
Конкурентность: Todo-приложение — индивидуальное использование, низкий параллелизм → допустимо.
Решение: TodoList — Aggregate Root, TodoItem — дочерняя Entity.
Шаг 2: Определяем состояния
TodoList States:
┌────────┐ archive() ┌──────────┐
│ Active │ ──────────────▶ │ Archived │
└────────┘ └──────────┘
│
│ addItem / completeItem / renameItem / etc.
▼
┌────────┐
│ Active │ (обновлённая версия)
└────────┘
TodoItem States:
┌────────┐ complete() ┌───────────┐
│ Active │ ──────────────▶ │ Completed │
└────────┘ └───────────┘
Шаг 3: Определяем события
Каждая значимая операция порождает событие:
| Операция | Событие |
|---|---|
| Создание списка | TodoListCreated |
| Добавление задачи | TodoItemAdded |
| Завершение задачи | TodoItemCompleted |
| Удаление задачи | TodoItemRemoved |
| Переименование задачи | TodoItemRenamed |
| Изменение приоритета | TodoItemPriorityChanged |
| Переименование списка | TodoListRenamed |
| Архивация списка | TodoListArchived |
Полная реализация
Файл: domain/todo-list/value-objects.ts
import { Schema } from "effect"
// ── Идентификаторы ──
export class TodoListId extends Schema.TaggedClass<TodoListId>()(
"TodoListId",
{ value: Schema.String.pipe(Schema.brand("TodoListId")) }
) {}
export class TodoItemId extends Schema.TaggedClass<TodoItemId>()(
"TodoItemId",
{ value: Schema.String.pipe(Schema.brand("TodoItemId")) }
) {}
// ── Доменные значения ──
export class ListName extends Schema.TaggedClass<ListName>()(
"ListName",
{
value: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(100),
Schema.brand("ListName")
)
}
) {}
export class TodoTitle extends Schema.TaggedClass<TodoTitle>()(
"TodoTitle",
{
value: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
}
) {}
export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type
export const TodoItemStatus = Schema.Literal("active", "completed")
export type TodoItemStatus = typeof TodoItemStatus.Type
export const TodoListStatus = Schema.Literal("active", "archived")
export type TodoListStatus = typeof TodoListStatus.Type
Файл: domain/todo-list/errors.ts
import { Schema } from "effect"
export class TodoListNotFound extends Schema.TaggedError<TodoListNotFound>()(
"TodoListNotFound",
{ todoListId: Schema.String }
) {}
export class TodoListFull extends Schema.TaggedError<TodoListFull>()(
"TodoListFull",
{
todoListId: Schema.String,
maxSize: Schema.Number,
currentSize: Schema.Number
}
) {}
export class DuplicateTitle extends Schema.TaggedError<DuplicateTitle>()(
"DuplicateTitle",
{
todoListId: Schema.String,
title: Schema.String
}
) {}
export class TodoNotInList extends Schema.TaggedError<TodoNotInList>()(
"TodoNotInList",
{
todoListId: Schema.String,
todoItemId: Schema.String
}
) {}
export class TodoAlreadyCompleted extends Schema.TaggedError<TodoAlreadyCompleted>()(
"TodoAlreadyCompleted",
{ todoItemId: Schema.String }
) {}
export class ListArchived extends Schema.TaggedError<ListArchived>()(
"ListArchived",
{ todoListId: Schema.String }
) {}
// ── Тип-объединение всех ошибок ──
export type TodoListDomainError =
| TodoListNotFound
| TodoListFull
| DuplicateTitle
| TodoNotInList
| TodoAlreadyCompleted
| ListArchived
Файл: domain/todo-list/events.ts
import { Schema } from "effect"
import { Priority } from "./value-objects.js"
// ── Общие поля для всех событий ──
const BaseEvent = {
occurredAt: Schema.DateFromSelf,
aggregateId: Schema.String,
version: Schema.Number
} as const
// ── Конкретные события ──
export class TodoListCreated extends Schema.TaggedClass<TodoListCreated>()(
"TodoListCreated",
{ ...BaseEvent, name: Schema.String, ownerId: Schema.String, maxItems: Schema.Number }
) {}
export class TodoItemAdded extends Schema.TaggedClass<TodoItemAdded>()(
"TodoItemAdded",
{ ...BaseEvent, todoItemId: Schema.String, title: Schema.String, priority: Priority }
) {}
export class TodoItemCompleted extends Schema.TaggedClass<TodoItemCompleted>()(
"TodoItemCompleted",
{ ...BaseEvent, todoItemId: Schema.String, completedAt: Schema.DateFromSelf }
) {}
export class TodoItemRemoved extends Schema.TaggedClass<TodoItemRemoved>()(
"TodoItemRemoved",
{ ...BaseEvent, todoItemId: Schema.String, title: Schema.String }
) {}
export class TodoItemRenamed extends Schema.TaggedClass<TodoItemRenamed>()(
"TodoItemRenamed",
{ ...BaseEvent, todoItemId: Schema.String, oldTitle: Schema.String, newTitle: Schema.String }
) {}
export class TodoItemPriorityChanged extends Schema.TaggedClass<TodoItemPriorityChanged>()(
"TodoItemPriorityChanged",
{ ...BaseEvent, todoItemId: Schema.String, oldPriority: Priority, newPriority: Priority }
) {}
export class TodoListRenamed extends Schema.TaggedClass<TodoListRenamed>()(
"TodoListRenamed",
{ ...BaseEvent, oldName: Schema.String, newName: Schema.String }
) {}
export class TodoListArchivedEvent extends Schema.TaggedClass<TodoListArchivedEvent>()(
"TodoListArchived",
{ ...BaseEvent, itemCount: Schema.Number, activeItemCount: Schema.Number }
) {}
// ── Union всех событий ──
export type TodoListEvent =
| TodoListCreated
| TodoItemAdded
| TodoItemCompleted
| TodoItemRemoved
| TodoItemRenamed
| TodoItemPriorityChanged
| TodoListRenamed
| TodoListArchivedEvent
Файл: domain/todo-list/todo-item.ts
import { Schema, Option } from "effect"
import { TodoItemId, TodoTitle, Priority, TodoItemStatus } from "./value-objects.js"
export class TodoItem extends Schema.Class<TodoItem>("TodoItem")({
id: TodoItemId,
title: TodoTitle,
priority: Priority,
status: TodoItemStatus,
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {
get isActive(): boolean {
return this.status === "active"
}
get isCompleted(): boolean {
return this.status === "completed"
}
get isCritical(): boolean {
return this.priority === "critical"
}
get isHighPriority(): boolean {
return this.priority === "high" || this.priority === "critical"
}
}
Файл: domain/todo-list/todo-list.ts
import { Effect, Schema, Option, ReadonlyArray } from "effect"
import {
TodoListId, TodoItemId, ListName, TodoTitle,
Priority, TodoListStatus
} from "./value-objects.js"
import { TodoItem } from "./todo-item.js"
import {
TodoListFull, DuplicateTitle, TodoNotInList,
TodoAlreadyCompleted, ListArchived
} from "./errors.js"
import {
TodoListCreated, TodoItemAdded, TodoItemCompleted,
TodoItemRemoved, TodoItemRenamed, TodoItemPriorityChanged,
TodoListRenamed, TodoListArchivedEvent,
type TodoListEvent
} from "./events.js"
// ── Результат мутации ──
export interface MutationResult {
readonly list: TodoList
readonly events: ReadonlyArray<TodoListEvent>
}
// ── Aggregate Root ──
export class TodoList extends Schema.Class<TodoList>("TodoList")({
id: TodoListId,
name: ListName,
status: TodoListStatus,
items: Schema.Array(TodoItem),
maxItems: Schema.Number.pipe(Schema.int(), Schema.positive()),
ownerId: Schema.String.pipe(Schema.brand("UserId")),
createdAt: Schema.DateFromSelf,
updatedAt: Schema.DateFromSelf,
version: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
}) {
// ── Guards ──
private ensureActive(): Effect.Effect<void, ListArchived> {
return this.status === "archived"
? new ListArchived({ todoListId: this.id.value })
: Effect.void
}
private ensureNotFull(): Effect.Effect<void, TodoListFull> {
return this.items.length >= this.maxItems
? new TodoListFull({
todoListId: this.id.value,
maxSize: this.maxItems,
currentSize: this.items.length
})
: Effect.void
}
private ensureUniqueTitle(
title: TodoTitle,
excludeIndex?: number
): Effect.Effect<void, DuplicateTitle> {
const isDuplicate = this.items.some(
(item, i) => i !== excludeIndex && item.title.value === title.value
)
return isDuplicate
? new DuplicateTitle({
todoListId: this.id.value,
title: title.value
})
: Effect.void
}
private findItemAt(
itemId: TodoItemId
): Effect.Effect<
{ readonly item: TodoItem; readonly index: number },
TodoNotInList
> {
const index = this.items.findIndex(
(item) => item.id.value === itemId.value
)
return index === -1
? new TodoNotInList({
todoListId: this.id.value,
todoItemId: itemId.value
})
: Effect.succeed({ item: this.items[index]!, index } as const)
}
// ── Helpers ──
private next(
fields: Partial<{
readonly name: ListName
readonly status: TodoListStatus
readonly items: ReadonlyArray<TodoItem>
}>,
now: Date
): TodoList {
return new TodoList({
...this,
...fields,
updatedAt: now,
version: this.version + 1
})
}
private meta(now: Date) {
return {
occurredAt: now,
aggregateId: this.id.value,
version: this.version + 1
} as const
}
// ── Factory ──
static create(params: {
readonly id: TodoListId
readonly name: ListName
readonly ownerId: string & { readonly UserId: unique symbol }
readonly maxItems?: number
readonly now: Date
}): Effect.Effect<MutationResult> {
const maxItems = params.maxItems ?? 50
const list = new TodoList({
id: params.id,
name: params.name,
status: "active",
items: [],
maxItems,
ownerId: params.ownerId,
createdAt: params.now,
updatedAt: params.now,
version: 0
})
return Effect.succeed({
list,
events: [
new TodoListCreated({
occurredAt: params.now,
aggregateId: params.id.value,
version: 0,
name: params.name.value,
ownerId: params.ownerId,
maxItems
})
]
})
}
// ── Commands ──
addItem(params: {
readonly itemId: TodoItemId
readonly title: TodoTitle
readonly priority: Priority
readonly now: Date
}): Effect.Effect<MutationResult, TodoListFull | DuplicateTitle | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
yield* this.ensureNotFull()
yield* this.ensureUniqueTitle(params.title)
const newItem = new TodoItem({
id: params.itemId,
title: params.title,
priority: params.priority,
status: "active",
createdAt: params.now,
completedAt: Option.none()
})
return {
list: this.next({ items: [...this.items, newItem] }, params.now),
events: [
new TodoItemAdded({
...this.meta(params.now),
todoItemId: params.itemId.value,
title: params.title.value,
priority: params.priority
})
]
}
})
}
completeItem(params: {
readonly itemId: TodoItemId
readonly now: Date
}): Effect.Effect<MutationResult, TodoNotInList | TodoAlreadyCompleted | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
const { item, index } = yield* this.findItemAt(params.itemId)
if (item.isCompleted) {
return yield* new TodoAlreadyCompleted({ todoItemId: params.itemId.value })
}
const completed = new TodoItem({
...item,
status: "completed",
completedAt: Option.some(params.now)
})
return {
list: this.next({
items: ReadonlyArray.modify(this.items, index, () => completed)
}, params.now),
events: [
new TodoItemCompleted({
...this.meta(params.now),
todoItemId: params.itemId.value,
completedAt: params.now
})
]
}
})
}
removeItem(params: {
readonly itemId: TodoItemId
readonly now: Date
}): Effect.Effect<MutationResult, TodoNotInList | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
const { item } = yield* this.findItemAt(params.itemId)
return {
list: this.next({
items: this.items.filter((i) => i.id.value !== params.itemId.value)
}, params.now),
events: [
new TodoItemRemoved({
...this.meta(params.now),
todoItemId: params.itemId.value,
title: item.title.value
})
]
}
})
}
renameItem(params: {
readonly itemId: TodoItemId
readonly newTitle: TodoTitle
readonly now: Date
}): Effect.Effect<MutationResult, TodoNotInList | DuplicateTitle | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
const { item, index } = yield* this.findItemAt(params.itemId)
yield* this.ensureUniqueTitle(params.newTitle, index)
const renamed = new TodoItem({ ...item, title: params.newTitle })
return {
list: this.next({
items: ReadonlyArray.modify(this.items, index, () => renamed)
}, params.now),
events: [
new TodoItemRenamed({
...this.meta(params.now),
todoItemId: params.itemId.value,
oldTitle: item.title.value,
newTitle: params.newTitle.value
})
]
}
})
}
changePriority(params: {
readonly itemId: TodoItemId
readonly newPriority: Priority
readonly now: Date
}): Effect.Effect<MutationResult, TodoNotInList | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
const { item, index } = yield* this.findItemAt(params.itemId)
const updated = new TodoItem({ ...item, priority: params.newPriority })
return {
list: this.next({
items: ReadonlyArray.modify(this.items, index, () => updated)
}, params.now),
events: [
new TodoItemPriorityChanged({
...this.meta(params.now),
todoItemId: params.itemId.value,
oldPriority: item.priority,
newPriority: params.newPriority
})
]
}
})
}
rename(params: {
readonly newName: ListName
readonly now: Date
}): Effect.Effect<MutationResult, ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
return {
list: this.next({ name: params.newName }, params.now),
events: [
new TodoListRenamed({
...this.meta(params.now),
oldName: this.name.value,
newName: params.newName.value
})
]
}
})
}
archive(now: Date): Effect.Effect<MutationResult, ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
return {
list: this.next({ status: "archived" }, now),
events: [
new TodoListArchivedEvent({
...this.meta(now),
itemCount: this.items.length,
activeItemCount: this.activeItemCount
})
]
}
})
}
// ── Queries ──
get activeItems(): ReadonlyArray<TodoItem> {
return this.items.filter((i) => i.isActive)
}
get completedItems(): ReadonlyArray<TodoItem> {
return this.items.filter((i) => i.isCompleted)
}
get activeItemCount(): number {
return this.activeItems.length
}
get completedItemCount(): number {
return this.completedItems.length
}
get isEmpty(): boolean {
return this.items.length === 0
}
get isFull(): boolean {
return this.items.length >= this.maxItems
}
get isArchived(): boolean {
return this.status === "archived"
}
get completionPercentage(): number {
return this.items.length === 0
? 0
: Math.round((this.completedItemCount / this.items.length) * 100)
}
get criticalItems(): ReadonlyArray<TodoItem> {
return this.items.filter((i) => i.isCritical && i.isActive)
}
get highPriorityItems(): ReadonlyArray<TodoItem> {
return this.items.filter((i) => i.isHighPriority && i.isActive)
}
findItem(itemId: TodoItemId): Option.Option<TodoItem> {
return Option.fromNullable(
this.items.find((i) => i.id.value === itemId.value)
)
}
hasItemWithTitle(title: TodoTitle): boolean {
return this.items.some((i) => i.title.value === title.value)
}
}
Файл: domain/todo-list/index.ts (barrel export)
// ── Value Objects ──
export {
TodoListId, TodoItemId, ListName, TodoTitle,
Priority, TodoItemStatus, TodoListStatus
} from "./value-objects.js"
// ── Entities ──
export { TodoItem } from "./todo-item.js"
// ── Aggregate ──
export { TodoList, type MutationResult } from "./todo-list.js"
// ── Errors ──
export {
TodoListNotFound, TodoListFull, DuplicateTitle,
TodoNotInList, TodoAlreadyCompleted, ListArchived,
type TodoListDomainError
} from "./errors.js"
// ── Events ──
export {
TodoListCreated, TodoItemAdded, TodoItemCompleted,
TodoItemRemoved, TodoItemRenamed, TodoItemPriorityChanged,
TodoListRenamed, TodoListArchivedEvent,
type TodoListEvent
} from "./events.js"
Проверка архитектурной чистоты
Убедимся, что наш агрегат соблюдает все принципы:
✅ Нулевые зависимости от инфраструктуры
// Все импорты — только effect и локальные файлы домена
import { Effect, Schema, Option, ReadonlyArray } from "effect"
import { /* ... */ } from "./value-objects.js"
import { /* ... */ } from "./errors.js"
import { /* ... */ } from "./events.js"
// Нет импортов:
// ❌ sqlite, http, fs, config, logger
// ❌ express, fastify, prisma, drizzle
✅ R-канал = never
// Все методы агрегата: Effect<Result, Error, never>
// Нет внешних зависимостей в R-канале
addItem(/*...*/): Effect.Effect<MutationResult, TodoListFull | DuplicateTitle | ListArchived>
// R = never (опущен)
✅ Иммутабельность
// Каждый метод возвращает НОВЫЙ объект
return { list: this.next(/* ... */), events: [/* ... */] }
// this никогда не мутируется
✅ Типизированные ошибки
// Каждое нарушение инварианта — конкретный тип ошибки
// Компилятор заставит обработать каждую
Effect.catchTag(result, "TodoListFull", (e) => /* ... */)
Effect.catchTag(result, "DuplicateTitle", (e) => /* ... */)
Резюме
В этой статье мы создали полноценный TodoList Aggregate:
- 5 файлов — чёткое разделение Value Objects, Errors, Events, Child Entity, Aggregate Root
- 8 бизнес-правил — каждое реализовано и защищено
- 8 операций — create, addItem, completeItem, removeItem, renameItem, changePriority, rename, archive
- 8 событий — каждая операция порождает соответствующее событие
- 0 инфраструктурных зависимостей — чистый домен
Этот агрегат будет использоваться на протяжении всего курса — от Repository до HTTP-адаптеров и тестов. Его границы, инварианты и поведение полностью определены, и любое нарушение невозможно обойти благодаря типовой системе Effect-ts.