Типобезопасный домен: Гексагональная архитектура на базе Effect Поведение Entity: методы как чистые функции
Глава

Поведение 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)
  )
})

Ключевые выводы

  1. Поведение Entity — чистые функции вида (Entity, ...params) → Effect<Entity, Error>
  2. Никакой мутации — каждая операция возвращает новый экземпляр
  3. Ошибки в типах — E-канал Effect отражает все возможные доменные ошибки
  4. Побочные эффекты явные — Clock, Random видны в сигнатуре Effect
  5. Три категории: конструкторы, мутаторы, запросы
  6. Entity не знает о репозиториях, HTTP, файлах — это зона Application Service и портов
  7. Тестирование тривиально — чистые функции, предсказуемые результаты