Поведение Entity: методы как чистые функции
Функциональный подход к Rich Domain Model: поведение как (Entity, params) → Effect<Entity, Error>. Четыре категории поведения: конструкторы, мутаторы, переходы состояний, запросы. Namespace паттерн, pipe-композиция. Границы — что НЕ принадлежит Entity.
Rich Domain Model vs Anemic Domain Model
Мартин Фаулер описал Anemic Domain Model как антипаттерн: Entity содержит только данные, а вся логика живёт в «сервисах» за пределами домена. Это приводит к разбросанной логике, дублированию, и нарушению инкапсуляции.
Rich Domain Model — противоположный подход: Entity знает своё поведение. Но в ООП это реализуется через мутабельные методы:
// ООП Rich Domain Model — мутация
class Todo {
private status: TodoStatus
complete(): void {
if (this.status === "completed") throw new Error("Already completed")
this.status = "completed" // МУТАЦИЯ 😟
this.completedAt = new Date() // МУТАЦИЯ 😟
}
}
В функциональном мире мы берём идею Rich Domain Model, но реализуем её иначе: поведение — это чистые функции, которые принимают Entity и возвращают новый экземпляр.
Принципы функционального поведения Entity
Принцип 1: Поведение — это функция Entity → Entity
Каждая операция над Entity — это функция, которая принимает текущее состояние и возвращает новое:
// Базовая форма поведения Entity
type EntityBehavior<E> = (entity: Entity) => Effect.Effect<Entity, E>
Принцип 2: Entity неизменна
Функция никогда не мутирует входной объект. Она создаёт новый экземпляр с изменёнными полями. Идентификатор остаётся тем же:
const updateTitle = (todo: Todo, newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.gen(function* () {
const now = yield* DateTime.now
return new Todo({
...todo, // Все поля из исходного объекта
title: newTitle, // Перезаписываем только title
updatedAt: now, // И обновляем временную метку
})
// todo.id остаётся прежним — это та же Entity
})
Принцип 3: Ошибки — часть сигнатуры
Если операция может завершиться неудачей из-за бизнес-правила, это отражено в E-канале Effect:
// Тип явно говорит: complete может вернуть AlreadyCompleted
const complete: (todo: Todo) => Effect.Effect<Todo, AlreadyCompleted>
// Тип явно говорит: archive может вернуть InvalidTransition
const archive: (todo: Todo) => Effect.Effect<Todo, InvalidTransition>
// Тип говорит: updateTitle может вернуть TodoArchived
const updateTitle: (todo: Todo, title: TodoTitle) => Effect.Effect<Todo, TodoArchived>
Принцип 4: Побочные эффекты явные
Если поведение требует текущего времени, случайных чисел или внешних сервисов — это видно в возвращаемом типе:
// Чистая функция — не нужен Effect
const isOverdue = (todo: Todo, now: DateTime.Utc): boolean =>
Option.isSome(todo.dueDate) &&
DateTime.greaterThan(now, Option.getOrThrow(todo.dueDate)) &&
todo.status !== TodoStatus.Completed
// Нужен Effect — требует Clock
const complete = (todo: Todo): Effect.Effect<Todo, AlreadyCompleted> =>
Effect.gen(function* () {
const now = yield* DateTime.now // побочный эффект: clock
// ...
})
Каталог поведений Entity
Поведение Entity можно классифицировать на несколько категорий:
Категория 1: Конструкторы (Создание)
Фабричные методы для создания Entity в валидном начальном состоянии:
// Основной конструктор
static readonly create = (params: {
readonly title: TodoTitle
readonly description?: string
readonly priority?: Priority
}): Effect.Effect<Todo> =>
Effect.gen(function* () {
const now = yield* DateTime.now
const id = yield* generateTodoId
return new Todo({
id,
title: params.title,
description: Option.fromNullable(params.description),
priority: params.priority ?? Priority.Medium,
status: TodoStatus.Pending,
createdAt: now,
updatedAt: now,
completedAt: Option.none(),
})
})
// Восстановление из персистентного хранилища
// (не генерирует id и время — берёт из БД)
static readonly fromPersisted = Schema.decode(Todo)
Категория 2: Мутаторы (Изменение атрибутов)
Функции, меняющие конкретные атрибуты с проверкой инвариантов:
// Обновление заголовка
const updateTitle = (todo: Todo, newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, title: newTitle, updatedAt: now })
)
// Обновление приоритета
const updatePriority = (
todo: Todo,
newPriority: Priority,
): Effect.Effect<Todo, TodoArchived | InvalidPriorityChange> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: todo.status === TodoStatus.Completed
? Effect.fail(new InvalidPriorityChange({
todoId: todo.id,
reason: "Нельзя менять приоритет завершённой задачи"
}))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, priority: newPriority, updatedAt: now })
)
// Обновление описания
const updateDescription = (
todo: Todo,
description: Option.Option<string>,
): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, description, updatedAt: now })
)
Категория 3: Переходы состояний (State Transitions)
Функции, меняющие статус Entity с полной проверкой допустимости перехода:
// Завершение задачи
const complete = (todo: Todo): Effect.Effect<Todo, AlreadyCompleted> =>
todo.status === TodoStatus.Completed
? Effect.fail(new AlreadyCompleted({ todoId: todo.id }))
: todo.status === TodoStatus.Archived
? Effect.fail(new AlreadyCompleted({ todoId: todo.id }))
: Effect.gen(function* () {
const now = yield* DateTime.now
return new Todo({
...todo,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
})
// Архивирование задачи
const archive = (todo: Todo): Effect.Effect<Todo, InvalidTransition> =>
todo.status === TodoStatus.Archived
? Effect.fail(new InvalidTransition({
todoId: todo.id,
from: todo.status,
to: TodoStatus.Archived,
}))
: Effect.map(DateTime.now, (now) =>
new Todo({
...todo,
status: TodoStatus.Archived,
updatedAt: now,
})
)
// «Переоткрытие» задачи (completed → pending)
const reopen = (todo: Todo): Effect.Effect<Todo, InvalidTransition> =>
todo.status !== TodoStatus.Completed
? Effect.fail(new InvalidTransition({
todoId: todo.id,
from: todo.status,
to: TodoStatus.Pending,
}))
: Effect.map(DateTime.now, (now) =>
new Todo({
...todo,
status: TodoStatus.Pending,
completedAt: Option.none(),
updatedAt: now,
})
)
Категория 4: Запросы (Queries)
Чистые функции, возвращающие вычисляемые свойства. Не требуют Effect, потому что не имеют побочных эффектов:
// Чистые функции-запросы
const isCompleted = (todo: Todo): boolean =>
todo.status === TodoStatus.Completed
const isArchived = (todo: Todo): boolean =>
todo.status === TodoStatus.Archived
const isPending = (todo: Todo): boolean =>
todo.status === TodoStatus.Pending
const isHighPriority = (todo: Todo): boolean =>
todo.priority === Priority.High
const hasDescription = (todo: Todo): boolean =>
Option.isSome(todo.description)
// Запросы с параметрами
const isOverdue = (todo: Todo, now: DateTime.Utc): boolean =>
Option.match(todo.dueDate, {
onNone: () => false,
onSome: (due) =>
DateTime.greaterThan(now, due) && todo.status !== TodoStatus.Completed,
})
const age = (todo: Todo, now: DateTime.Utc): Duration.Duration =>
DateTime.distance(todo.createdAt, now)
const daysUntilDue = (todo: Todo, now: DateTime.Utc): Option.Option<number> =>
Option.map(todo.dueDate, (due) => {
const diff = DateTime.distance(now, due)
return Duration.toMillis(diff) / (1000 * 60 * 60 * 24)
})
Организация поведения: namespace паттерн
Для удобства и читаемости все функции поведения Entity собираются в namespace:
// domain/entities/Todo.ts
import { Schema, Effect, DateTime, Option, Equal, Hash } from "effect"
// ─── Entity Definition ─────────────────────────────────
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
status: TodoStatus,
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
}) {
[Equal.symbol](that: Equal.Equal): boolean {
return that instanceof Todo && this.id === that.id
}
[Hash.symbol](): number {
return Hash.string(this.id)
}
}
// ─── Behavior Namespace ────────────────────────────────
const Todo_ = {
// Конструкторы
create: (params: {
readonly title: TodoTitle
readonly description?: string
readonly priority?: Priority
}): Effect.Effect<Todo> =>
Effect.gen(function* () {
const now = yield* DateTime.now
const id = yield* generateTodoId
return new Todo({
id,
title: params.title,
description: Option.fromNullable(params.description),
priority: params.priority ?? Priority.Medium,
status: TodoStatus.Pending,
createdAt: now,
updatedAt: now,
completedAt: Option.none(),
})
}),
// Мутаторы
updateTitle: (todo: Todo, newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, title: newTitle, updatedAt: now })
),
updatePriority: (todo: Todo, priority: Priority): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, priority, updatedAt: now })
),
// Переходы состояний
complete: (todo: Todo): Effect.Effect<Todo, AlreadyCompleted> =>
todo.status === TodoStatus.Completed
? Effect.fail(new AlreadyCompleted({ todoId: todo.id }))
: Effect.gen(function* () {
const now = yield* DateTime.now
return new Todo({
...todo,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
}),
archive: (todo: Todo): Effect.Effect<Todo, InvalidTransition> =>
todo.status === TodoStatus.Archived
? Effect.fail(new InvalidTransition({
todoId: todo.id,
from: todo.status,
to: TodoStatus.Archived,
}))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...todo, status: TodoStatus.Archived, updatedAt: now })
),
// Запросы
isCompleted: (todo: Todo): boolean =>
todo.status === TodoStatus.Completed,
isOverdue: (todo: Todo, now: DateTime.Utc): boolean =>
Option.match(todo.dueDate, {
onNone: () => false,
onSome: (due) =>
DateTime.greaterThan(now, due) &&
todo.status !== TodoStatus.Completed,
}),
} as const
export { Todo, Todo_ as TodoBehavior }
Альтернатива: методы прямо в классе
Effect-ts позволяет определять методы прямо в Schema.Class. Это компактнее, но менее гибко для композиции:
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
priority: Priority,
status: TodoStatus,
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
}) {
[Equal.symbol](that: Equal.Equal): boolean {
return that instanceof Todo && this.id === that.id
}
[Hash.symbol](): number {
return Hash.string(this.id)
}
// Методы прямо в классе
get isCompleted(): boolean {
return this.status === TodoStatus.Completed
}
get isArchived(): boolean {
return this.status === TodoStatus.Archived
}
complete(): Effect.Effect<Todo, AlreadyCompleted> {
return this.status === TodoStatus.Completed
? Effect.fail(new AlreadyCompleted({ todoId: this.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({
...this,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
)
}
updateTitle(newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> {
return this.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: this.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...this, title: newTitle, updatedAt: now })
)
}
}
// Использование
const program = Effect.gen(function* () {
const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
const completed = yield* todo.complete()
// completed.isCompleted === true
})
Оба подхода валидны. Методы в классе — более привычны для ООП-разработчиков. Отдельный namespace — более «функционален» и лучше поддаётся композиции через pipe.
Композиция поведений через pipe
Функциональное поведение прекрасно композируется с Effect pipe:
// Цепочка действий над Entity
const processNewTodo = (title: string, priority: Priority) =>
Effect.gen(function* () {
// Создаём
const todo = yield* Todo.create({
title: TodoTitle.make(title),
priority,
})
// Обновляем описание
const withDescription = yield* TodoBehavior.updateDescription(
todo,
Option.some("Important task to complete")
)
// Логируем
yield* Effect.log(`Created todo: ${withDescription.id}`)
return withDescription
})
// Пакетное обновление
const completeBatch = (todos: ReadonlyArray<Todo>) =>
Effect.forEach(todos, (todo) =>
TodoBehavior.complete(todo).pipe(
Effect.catchTag("AlreadyCompleted", () =>
Effect.succeed(todo) // уже завершена — пропускаем
)
)
)
Паттерн «Smart Constructor + Dumb Update»
Рекомендуемый паттерн для Entity в Effect:
┌─────────────────────────────────────────────────┐
│ Smart Constructor (create) │
│ ─ Генерирует ID │
│ ─ Проставляет начальные значения │
│ ─ Гарантирует все инварианты │
│ ─ Возвращает Effect (побочные эффекты) │
├─────────────────────────────────────────────────┤
│ Smart Updaters (complete, archive, updateTitle) │
│ ─ Проверяют бизнес-правила │
│ ─ Поддерживают инварианты │
│ ─ Возвращают Effect с доменной ошибкой │
├─────────────────────────────────────────────────┤
│ Dumb Queries (isCompleted, age, isOverdue) │
│ ─ Чистые функции, без Effect │
│ ─ Вычисляют производные свойства │
│ ─ Никаких побочных эффектов │
└─────────────────────────────────────────────────┘
Когда поведение НЕ принадлежит Entity
Не всё поведение, связанное с задачами, должно находиться в Entity:
Нужен доступ к репозиторию → Application Service
// ❌ Entity не знает о репозитории
class Todo {
async checkDuplicate(): Promise<boolean> {
const existing = await TodoRepo.findByTitle(this.title) // НЕЛЬЗЯ!
return existing !== null
}
}
// ✅ Application Service обращается к порту
const createTodo = (title: TodoTitle) =>
Effect.gen(function* () {
const existing = yield* TodoRepository.findByTitle(title)
if (Option.isSome(existing)) {
return yield* Effect.fail(new DuplicateTitle({ title }))
}
return yield* Todo.create({ title })
})
Нужны несколько агрегатов → Domain Service
// ❌ Entity не знает о других Entity
class Todo {
reassign(fromUser: User, toUser: User): void { ... } // НЕЛЬЗЯ!
}
// ✅ Domain Service координирует между агрегатами
const reassignTodo = (todo: Todo, fromUser: User, toUser: User) =>
Effect.gen(function* () {
const updatedTodo = yield* TodoBehavior.setAssignee(todo, toUser.id)
// ... уведомления, логирование — через порты
return updatedTodo
})
Нужна инфраструктура → Adapter
// ❌ Entity не знает о файловой системе
class Todo {
saveAttachment(file: File): void { ... } // НЕЛЬЗЯ!
}
// ✅ Порт определяет контракт, адаптер реализует
interface FileStoragePort {
readonly save: (todoId: TodoId, file: File) => Effect.Effect<FileUrl, StorageError>
}
Тестирование поведения
Поведение Entity — чистые функции, поэтому тестировать их проще простого:
import { describe, it, expect } from "bun:test"
import { Effect, Option, TestClock } from "effect"
describe("Todo.complete", () => {
it("переводит pending → completed", () =>
Effect.gen(function* () {
const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
const completed = yield* TodoBehavior.complete(todo)
expect(completed.status).toBe(TodoStatus.Completed)
expect(Option.isSome(completed.completedAt)).toBe(true)
expect(completed.id).toBe(todo.id) // та же Entity
}).pipe(Effect.runPromise)
)
it("отклоняет повторное завершение", () =>
Effect.gen(function* () {
const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
const completed = yield* TodoBehavior.complete(todo)
const result = yield* TodoBehavior.complete(completed).pipe(Effect.either)
expect(result._tag).toBe("Left")
}).pipe(Effect.runPromise)
)
})
describe("Todo.updateTitle", () => {
it("обновляет заголовок и updatedAt", () =>
Effect.gen(function* () {
const todo = yield* Todo.create({ title: TodoTitle.make("Old") })
const updated = yield* TodoBehavior.updateTitle(todo, TodoTitle.make("New"))
expect(updated.title).toBe(TodoTitle.make("New"))
expect(updated.id).toBe(todo.id) // та же Entity
}).pipe(Effect.runPromise)
)
it("отклоняет обновление архивированной задачи", () =>
Effect.gen(function* () {
const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
const archived = yield* TodoBehavior.archive(todo)
const result = yield* TodoBehavior.updateTitle(
archived,
TodoTitle.make("New")
).pipe(Effect.either)
expect(result._tag).toBe("Left")
}).pipe(Effect.runPromise)
)
})
Ключевые выводы
- Поведение Entity — чистые функции вида
(Entity, ...params) → Effect<Entity, Error> - Никакой мутации — каждая операция возвращает новый экземпляр
- Ошибки в типах — E-канал Effect отражает все возможные доменные ошибки
- Побочные эффекты явные — Clock, Random видны в сигнатуре Effect
- Три категории: конструкторы, мутаторы, запросы
- Entity не знает о репозиториях, HTTP, файлах — это зона Application Service и портов
- Тестирование тривиально — чистые функции, предсказуемые результаты