Реализация Aggregate через Effect: иммутабельный подход
Философия Aggregate как чистой функции (State, Command) → Effect<(NewState, Events), Error>. Полная реализация: Value Objects, Domain Errors, Domain Events, Child Entity, Aggregate Root с Guard Methods, Factory, Commands и Queries. Паттерн Event Accumulation. Использование в Application Layer. Тестирование.
Философия: Aggregate как чистая функция
В классическом ООП-подходе Aggregate — это объект с мутабельным состоянием:
// ❌ ООП-подход (мутабельный)
class TodoList {
private items: TodoItem[] = []
addItem(item: TodoItem): void {
if (this.items.length >= this.maxItems) throw new Error("Full")
this.items.push(item) // мутация!
}
}
В функциональном подходе с Effect-ts Aggregate — это иммутабельная структура данных, каждая операция над которой возвращает новую копию агрегата:
(CurrentState, Command) → Effect<(NewState, Events), DomainError>
Это даёт:
- Предсказуемость — состояние не может измениться «из-под ног»
- Тестируемость — чистые функции легко тестировать
- Безопасность — нет race conditions при доступе к состоянию
- Отмена — если транзакция не завершилась, старое состояние всё ещё валидно
- Снепшоты — можно хранить предыдущие версии
Архитектура Aggregate в Effect-ts
┌─────────────────────────────────────────────────────────┐
│ AGGREGATE MODULE │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Value Objects │ │ Domain Errors │ │
│ │ ─────────────── │ │ ──────────────────────── │ │
│ │ TodoListId │ │ TodoListFull │ │
│ │ TodoItemId │ │ DuplicateTitle │ │
│ │ TodoTitle │ │ TodoNotInList │ │
│ │ ListName │ │ ListArchived │ │
│ │ Priority │ │ InvalidTransition │ │
│ └──────────────────┘ └────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Child Entities │ │ Domain Events │ │
│ │ ─────────────── │ │ ──────────────────────── │ │
│ │ TodoItem │ │ TodoItemAdded │ │
│ │ │ │ TodoItemCompleted │ │
│ └──────────────────┘ │ TodoItemRemoved │ │
│ │ TodoListArchived │ │
│ ┌──────────────────┐ └────────────────────────────┘ │
│ │ Aggregate Root │ │
│ │ ─────────────── │ │
│ │ TodoList │ │
│ │ (all behavior) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
Полная реализация
Шаг 1: Value Objects
import { Schema } from "effect"
// ── Идентификаторы ──────────────────────────────
class TodoListId extends Schema.TaggedClass<TodoListId>()("TodoListId", {
value: Schema.String.pipe(Schema.brand("TodoListId"))
}) {}
class TodoItemId extends Schema.TaggedClass<TodoItemId>()("TodoItemId", {
value: Schema.String.pipe(Schema.brand("TodoItemId"))
}) {}
// ── Доменные значения ───────────────────────────
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
}) {}
class ListName extends Schema.TaggedClass<ListName>()("ListName", {
value: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(100),
Schema.brand("ListName")
)
}) {}
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
const TodoItemStatus = Schema.Literal("active", "completed")
type TodoItemStatus = typeof TodoItemStatus.Type
const TodoListStatus = Schema.Literal("active", "archived")
type TodoListStatus = typeof TodoListStatus.Type
Шаг 2: Domain Errors
import { Schema } from "effect"
// Каждая ошибка — отдельный тип с payload
class TodoListFull extends Schema.TaggedError<TodoListFull>()(
"TodoListFull",
{
todoListId: Schema.String,
maxSize: Schema.Number,
currentSize: Schema.Number
}
) {}
class DuplicateTitle extends Schema.TaggedError<DuplicateTitle>()(
"DuplicateTitle",
{
todoListId: Schema.String,
title: Schema.String
}
) {}
class TodoNotInList extends Schema.TaggedError<TodoNotInList>()(
"TodoNotInList",
{
todoListId: Schema.String,
todoItemId: Schema.String
}
) {}
class TodoAlreadyCompleted extends Schema.TaggedError<TodoAlreadyCompleted>()(
"TodoAlreadyCompleted",
{
todoItemId: Schema.String
}
) {}
class ListArchived extends Schema.TaggedError<ListArchived>()(
"ListArchived",
{
todoListId: Schema.String
}
) {}
class InvalidPriorityChange extends Schema.TaggedError<InvalidPriorityChange>()(
"InvalidPriorityChange",
{
todoItemId: Schema.String,
currentPriority: Schema.String,
newPriority: Schema.String,
reason: Schema.String
}
) {}
// Union type для удобства в сигнатурах
type TodoListError =
| TodoListFull
| DuplicateTitle
| TodoNotInList
| TodoAlreadyCompleted
| ListArchived
| InvalidPriorityChange
Шаг 3: Domain Events
import { Schema } from "effect"
// Базовая метаинформация для всех событий
const EventMetadata = Schema.Struct({
occurredAt: Schema.DateFromSelf,
aggregateId: Schema.String,
version: Schema.Number
})
class TodoItemAdded extends Schema.TaggedClass<TodoItemAdded>()(
"TodoItemAdded",
{
...EventMetadata.fields,
todoItemId: Schema.String,
title: Schema.String,
priority: Priority
}
) {}
class TodoItemCompleted extends Schema.TaggedClass<TodoItemCompleted>()(
"TodoItemCompleted",
{
...EventMetadata.fields,
todoItemId: Schema.String,
completedAt: Schema.DateFromSelf
}
) {}
class TodoItemRemoved extends Schema.TaggedClass<TodoItemRemoved>()(
"TodoItemRemoved",
{
...EventMetadata.fields,
todoItemId: Schema.String
}
) {}
class TodoItemRenamed extends Schema.TaggedClass<TodoItemRenamed>()(
"TodoItemRenamed",
{
...EventMetadata.fields,
todoItemId: Schema.String,
oldTitle: Schema.String,
newTitle: Schema.String
}
) {}
class TodoItemPriorityChanged extends Schema.TaggedClass<TodoItemPriorityChanged>()(
"TodoItemPriorityChanged",
{
...EventMetadata.fields,
todoItemId: Schema.String,
oldPriority: Priority,
newPriority: Priority
}
) {}
class TodoListArchivedEvent extends Schema.TaggedClass<TodoListArchivedEvent>()(
"TodoListArchived",
{
...EventMetadata.fields,
itemCount: Schema.Number
}
) {}
class TodoListRenamed extends Schema.TaggedClass<TodoListRenamed>()(
"TodoListRenamed",
{
...EventMetadata.fields,
oldName: Schema.String,
newName: Schema.String
}
) {}
// Union всех событий
type TodoListEvent =
| TodoItemAdded
| TodoItemCompleted
| TodoItemRemoved
| TodoItemRenamed
| TodoItemPriorityChanged
| TodoListArchivedEvent
| TodoListRenamed
Шаг 4: Child Entity — TodoItem
import { Schema, Option } from "effect"
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"
}
}
Шаг 5: Результат мутации — новое состояние + события
// Каждая мутация возвращает обновлённый агрегат + список событий
interface MutationResult {
readonly list: TodoList
readonly events: ReadonlyArray<TodoListEvent>
}
Шаг 6: Aggregate Root — TodoList
import { Effect, Schema, Option, ReadonlyArray, pipe } from "effect"
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())
}) {
// ═══════════════════════════════════════════════
// Guard Methods (предусловия)
// ═══════════════════════════════════════════════
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 hasDuplicate = this.items.some(
(item, i) =>
i !== excludeIndex && item.title.value === title.value
)
return hasDuplicate
? new DuplicateTitle({
todoListId: this.id.value,
title: title.value
})
: Effect.void
}
private findItemWithIndex(
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)
}
// ═══════════════════════════════════════════════
// Helper: создание обновлённого агрегата
// ═══════════════════════════════════════════════
private updated(
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 eventMeta(now: Date) {
return {
occurredAt: now,
aggregateId: this.id.value,
version: this.version + 1
} as const
}
// ═══════════════════════════════════════════════
// Factory Method
// ═══════════════════════════════════════════════
static create(params: {
readonly id: TodoListId
readonly name: ListName
readonly ownerId: string & { readonly UserId: unique symbol }
readonly maxItems?: number
readonly now: Date
}): Effect.Effect<TodoList> {
return Effect.succeed(
new TodoList({
id: params.id,
name: params.name,
status: "active",
items: [],
maxItems: params.maxItems ?? 50,
ownerId: params.ownerId,
createdAt: params.now,
updatedAt: params.now,
version: 0
})
)
}
// ═══════════════════════════════════════════════
// 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* () {
const { itemId, title, priority, now } = params
// ── Проверка инвариантов ──
yield* this.ensureActive()
yield* this.ensureNotFull()
yield* this.ensureUniqueTitle(title)
// ── Создание дочерней Entity ──
const newItem = new TodoItem({
id: itemId,
title,
priority,
status: "active",
createdAt: now,
completedAt: Option.none()
})
// ── Новое состояние + событие ──
return {
list: this.updated({ items: [...this.items, newItem] }, now),
events: [
new TodoItemAdded({
...this.eventMeta(now),
todoItemId: itemId.value,
title: title.value,
priority
})
]
}
})
}
completeItem(params: {
readonly itemId: TodoItemId
readonly now: Date
}): Effect.Effect<
MutationResult,
TodoNotInList | TodoAlreadyCompleted | ListArchived
> {
return Effect.gen(this, function* () {
const { itemId, now } = params
yield* this.ensureActive()
const { item, index } = yield* this.findItemWithIndex(itemId)
if (item.isCompleted) {
return yield* new TodoAlreadyCompleted({
todoItemId: itemId.value
})
}
const completedItem = new TodoItem({
...item,
status: "completed",
completedAt: Option.some(now)
})
const updatedItems = ReadonlyArray.modify(
this.items,
index,
() => completedItem
)
return {
list: this.updated({ items: updatedItems }, now),
events: [
new TodoItemCompleted({
...this.eventMeta(now),
todoItemId: itemId.value,
completedAt: now
})
]
}
})
}
removeItem(params: {
readonly itemId: TodoItemId
readonly now: Date
}): Effect.Effect<
MutationResult,
TodoNotInList | ListArchived
> {
return Effect.gen(this, function* () {
const { itemId, now } = params
yield* this.ensureActive()
yield* this.findItemWithIndex(itemId)
const filteredItems = this.items.filter(
(item) => item.id.value !== itemId.value
)
return {
list: this.updated({ items: filteredItems }, now),
events: [
new TodoItemRemoved({
...this.eventMeta(now),
todoItemId: itemId.value
})
]
}
})
}
renameItem(params: {
readonly itemId: TodoItemId
readonly newTitle: TodoTitle
readonly now: Date
}): Effect.Effect<
MutationResult,
TodoNotInList | DuplicateTitle | ListArchived
> {
return Effect.gen(this, function* () {
const { itemId, newTitle, now } = params
yield* this.ensureActive()
const { item, index } = yield* this.findItemWithIndex(itemId)
// Проверка уникальности (исключая текущий элемент)
yield* this.ensureUniqueTitle(newTitle, index)
const oldTitle = item.title.value
const renamedItem = new TodoItem({ ...item, title: newTitle })
const updatedItems = ReadonlyArray.modify(
this.items,
index,
() => renamedItem
)
return {
list: this.updated({ items: updatedItems }, now),
events: [
new TodoItemRenamed({
...this.eventMeta(now),
todoItemId: itemId.value,
oldTitle,
newTitle: newTitle.value
})
]
}
})
}
changePriority(params: {
readonly itemId: TodoItemId
readonly newPriority: Priority
readonly now: Date
}): Effect.Effect<
MutationResult,
TodoNotInList | ListArchived
> {
return Effect.gen(this, function* () {
const { itemId, newPriority, now } = params
yield* this.ensureActive()
const { item, index } = yield* this.findItemWithIndex(itemId)
const oldPriority = item.priority
const updatedItem = new TodoItem({ ...item, priority: newPriority })
const updatedItems = ReadonlyArray.modify(
this.items,
index,
() => updatedItem
)
return {
list: this.updated({ items: updatedItems }, now),
events: [
new TodoItemPriorityChanged({
...this.eventMeta(now),
todoItemId: itemId.value,
oldPriority,
newPriority
})
]
}
})
}
rename(params: {
readonly newName: ListName
readonly now: Date
}): Effect.Effect<MutationResult, ListArchived> {
return Effect.gen(this, function* () {
const { newName, now } = params
yield* this.ensureActive()
const oldName = this.name.value
return {
list: this.updated({ name: newName }, now),
events: [
new TodoListRenamed({
...this.eventMeta(now),
oldName,
newName: newName.value
})
]
}
})
}
archive(now: Date): Effect.Effect<MutationResult, ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
return {
list: this.updated({ status: "archived" }, now),
events: [
new TodoListArchivedEvent({
...this.eventMeta(now),
itemCount: this.items.length
})
]
}
})
}
// ═══════════════════════════════════════════════
// Queries (чистые функции, без Effect)
// ═══════════════════════════════════════════════
get activeItems(): ReadonlyArray<TodoItem> {
return this.items.filter((item) => item.isActive)
}
get completedItems(): ReadonlyArray<TodoItem> {
return this.items.filter((item) => item.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)
}
findItem(itemId: TodoItemId): Option.Option<TodoItem> {
return Option.fromNullable(
this.items.find((item) => item.id.value === itemId.value)
)
}
itemsByPriority(priority: Priority): ReadonlyArray<TodoItem> {
return this.items.filter((item) => item.priority === priority)
}
}
Использование в Application Layer
Вот как агрегат используется в Application Service:
import { Effect, Context } from "effect"
// ── Порт (Repository) ──
class TodoListRepository extends Context.Tag("TodoListRepository")<
TodoListRepository,
{
readonly findById: (id: TodoListId) => Effect.Effect<TodoList, TodoListNotFound>
readonly save: (list: TodoList) => Effect.Effect<void>
}
>() {}
// ── Порт (Event Publisher) ──
class EventPublisher extends Context.Tag("EventPublisher")<
EventPublisher,
{
readonly publishAll: (events: ReadonlyArray<TodoListEvent>) => Effect.Effect<void>
}
>() {}
// ── Порт (Clock) ──
class Clock extends Context.Tag("Clock")<
Clock,
{ readonly now: Effect.Effect<Date> }
>() {}
// ── Application Service (Use Case) ──
const addTodoItem = (params: {
readonly listId: TodoListId
readonly itemId: TodoItemId
readonly title: TodoTitle
readonly priority: Priority
}): Effect.Effect<
TodoList,
TodoListNotFound | TodoListFull | DuplicateTitle | ListArchived,
TodoListRepository | EventPublisher | Clock
> =>
Effect.gen(function* () {
const repo = yield* TodoListRepository
const publisher = yield* EventPublisher
const clock = yield* Clock
// 1. Загрузить агрегат
const list = yield* repo.findById(params.listId)
// 2. Выполнить операцию (проверка инвариантов внутри)
const now = yield* clock.now
const { list: updatedList, events } = yield* list.addItem({
itemId: params.itemId,
title: params.title,
priority: params.priority,
now
})
// 3. Сохранить агрегат
yield* repo.save(updatedList)
// 4. Опубликовать события
yield* publisher.publishAll(events)
return updatedList
})
Паттерн: Event Accumulation
Иногда одна бизнес-операция порождает несколько мутаций и несколько событий. Для этого удобен паттерн аккумулирования событий:
import { Effect, pipe } from "effect"
// Batch-операция: завершить все активные задачи
const completeAllItems = (
list: TodoList,
now: Date
): Effect.Effect<
MutationResult,
ListArchived
> =>
Effect.gen(function* () {
yield* list.ensureActive()
const activeItems = list.activeItems
let currentList = list
const allEvents: TodoListEvent[] = []
for (const item of activeItems) {
const result = yield* currentList.completeItem({
itemId: item.id,
now
})
currentList = result.list
allEvents.push(...result.events)
}
return { list: currentList, events: allEvents }
})
Тестирование агрегата
import { describe, it, expect } from "bun:test"
import { Effect, Option } from "effect"
const runTest = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(Effect.either(effect))
describe("TodoList Aggregate", () => {
const now = new Date("2025-01-01T00:00:00Z")
const listId = new TodoListId({ value: "list-1" as any })
const itemId1 = new TodoItemId({ value: "item-1" as any })
const createList = () =>
Effect.runSync(
TodoList.create({
id: listId,
name: new ListName({ value: "Shopping" as any }),
ownerId: "user-1" as any,
maxItems: 3,
now
})
)
it("should add item to empty list", async () => {
const list = createList()
const title = new TodoTitle({ value: "Buy milk" as any })
const result = await runTest(
list.addItem({ itemId: itemId1, title, priority: "medium", now })
)
expect(result._tag).toBe("Right")
if (result._tag === "Right") {
expect(result.right.list.items.length).toBe(1)
expect(result.right.list.version).toBe(1)
expect(result.right.events.length).toBe(1)
expect(result.right.events[0]!._tag).toBe("TodoItemAdded")
}
})
it("should reject when list is full", async () => {
// Создаём список с maxItems=3 и добавляем 3 элемента
let list = createList()
for (let i = 0; i < 3; i++) {
const { list: updated } = await Effect.runPromise(
list.addItem({
itemId: new TodoItemId({ value: `item-${i}` as any }),
title: new TodoTitle({ value: `Task ${i}` as any }),
priority: "low",
now
})
)
list = updated
}
// Четвёртый элемент должен быть отклонён
const result = await runTest(
list.addItem({
itemId: new TodoItemId({ value: "item-extra" as any }),
title: new TodoTitle({ value: "Extra" as any }),
priority: "low",
now
})
)
expect(result._tag).toBe("Left")
if (result._tag === "Left") {
expect(result.left._tag).toBe("TodoListFull")
}
})
it("should complete an active item", async () => {
const list = createList()
const title = new TodoTitle({ value: "Buy milk" as any })
const { list: withItem } = await Effect.runPromise(
list.addItem({ itemId: itemId1, title, priority: "medium", now })
)
const completedAt = new Date("2025-01-02T00:00:00Z")
const result = await runTest(
withItem.completeItem({ itemId: itemId1, now: completedAt })
)
expect(result._tag).toBe("Right")
if (result._tag === "Right") {
const item = result.right.list.items[0]!
expect(item.status).toBe("completed")
expect(Option.isSome(item.completedAt)).toBe(true)
expect(result.right.events[0]!._tag).toBe("TodoItemCompleted")
}
})
it("should maintain immutability", async () => {
const list = createList()
const title = new TodoTitle({ value: "Buy milk" as any })
const { list: withItem } = await Effect.runPromise(
list.addItem({ itemId: itemId1, title, priority: "medium", now })
)
// Оригинальный список НЕ изменился!
expect(list.items.length).toBe(0)
expect(list.version).toBe(0)
// Новый список имеет элемент
expect(withItem.items.length).toBe(1)
expect(withItem.version).toBe(1)
})
})
Резюме
Реализация Aggregate в Effect-ts следует функциональным принципам:
- Иммутабельность — каждая мутация возвращает новый объект
- Typed Errors — каждое нарушение инварианта — типизированная ошибка в E-канале
- Domain Events — результат мутации =
(NewState, Events) - Guard Methods — переиспользуемые проверки предусловий
- Factory Methods — корректная инициализация через статические методы
- Pure Queries — чтение состояния через чистые функции без Effect
Агрегат не зависит от инфраструктуры (R = never в методах). Все зависимости (Repository, EventPublisher, Clock) подключаются на уровне Application Service, что полностью соответствует принципам гексагональной архитектуры.