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

Реализация Entity через Schema.Class с Id

Полный паттерн реализации Entity в Effect-ts: Branded Types для идентификаторов, Schema.Class для структуры, Equal/Hash для равенства по идентичности, фабричный метод create, Schema.TaggedClass для tagged unions, Encoded форма для сериализации на границах слоёв.

Стратегия реализации Entity в Effect

В классическом ООП Entity — это класс с полями, методами и мутабельным состоянием. В функциональном Effect-ts мы следуем другому подходу:

  1. Schema.Class — определяет структуру и автоматическую валидацию
  2. Branded Types — типобезопасные идентификаторы
  3. Equal + Hash — равенство по идентификатору
  4. Чистые функции — поведение через pipe и Effect
  5. Schema.filter — инварианты на уровне типов

Рассмотрим каждый элемент и соберём их в единый паттерн.


Шаг 1: Branded Type для идентификатора

Прежде чем создать Entity, нам нужен типобезопасный идентификатор. Мы подробно разбирали Branded Types в модуле 11, здесь применяем их к идентификаторам Entity.

Почему не string?

// ❌ ОПАСНО: все ID — просто string
const findTodo = (id: string) => ...
const findUser = (id: string) => ...

// Компилятор не поймает эту ошибку:
const userId = "user_123"
findTodo(userId) // Ошибка! Но TypeScript молчит 😱

Branded Type через Schema

import { Schema } from "effect"

// Определяем branded type для TodoId
const TodoIdBrand = Symbol.for("TodoId")

const TodoId = Schema.String.pipe(
  Schema.brand(TodoIdBrand),
  Schema.annotations({
    identifier: "TodoId",
    title: "Todo Identifier",
    description: "Уникальный идентификатор задачи"
  })
)

type TodoId = typeof TodoId.Type
// type TodoId = string & Brand<typeof TodoIdBrand>

Генерация идентификаторов

Идентификатор Entity генерируется при создании и никогда не меняется. Это инвариант. Для генерации используем эффект, чтобы сохранить чистоту:

import { Effect } from "effect"

// Генерация нового TodoId с префиксом для читаемости
const generateTodoId: Effect.Effect<TodoId> = Effect.sync(() =>
  TodoId.make(`todo_${crypto.randomUUID()}`)
)

// Альтернатива: детерминированная генерация через порт
// (полезно для тестов)
interface IdGenerator {
  readonly generate: Effect.Effect<string>
}

Парсинг существующих идентификаторов

Когда ID приходит извне (из БД, HTTP запроса), его нужно валидировать:

import { Schema } from "effect"

const parseTodoId = Schema.decodeUnknown(TodoId)

// Безопасный парсинг из строки
const result = parseTodoId("todo_abc-123")
// Effect<TodoId, ParseError>

Шаг 2: Schema.Class для структуры Entity

Schema.Class — основной инструмент для определения Entity в Effect. Он даёт:

  • Автоматическую валидацию при создании
  • Encode/Decode для сериализации
  • Базовый конструктор

Базовая структура Entity

import { Schema } from "effect"

class Todo extends Schema.Class<Todo>("Todo")({
  // Идентификатор — обязательное поле Entity
  id: TodoId,
  
  // Атрибуты — Value Objects
  title: TodoTitle,
  description: Schema.OptionFromNullOr(Schema.String),
  priority: Priority,
  status: TodoStatus,
  
  // Метаданные жизненного цикла
  createdAt: Schema.DateTimeUtc,
  updatedAt: Schema.DateTimeUtc,
  completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
}) {}

Что происходит «под капотом»

Schema.Class создаёт:

  1. TypeScript класс с типизированными полями
  2. Schema для валидации, encode/decode
  3. Конструктор, который проверяет данные при создании
  4. Поддержку _tag для tagged unions (при необходимости)
// Schema.Class автоматически создаёт:
// 1. Тип
type TodoType = {
  readonly id: TodoId
  readonly title: TodoTitle
  readonly description: Option<string>
  readonly priority: Priority
  readonly status: TodoStatus
  readonly createdAt: DateTime.Utc
  readonly updatedAt: DateTime.Utc
  readonly completedAt: Option<DateTime.Utc>
}

// 2. Конструктор с валидацией
const todo = new Todo({
  id: TodoId.make("todo_123"),
  title: TodoTitle.make("Buy milk"),
  // ... все поля обязательны
})

// 3. Schema для encode/decode
const TodoSchema: Schema.Schema<Todo, TodoEncoded> = Todo

Шаг 3: Равенство по идентификатору

По умолчанию Schema.Class использует ссылочное равенство (===). Для Entity нам нужно равенство по идентификатору. Переопределяем Equal и Hash:

import { Equal, Hash, Schema } from "effect"

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),
}) {
  // Равенство ТОЛЬКО по id
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof Todo && this.id === that.id
  }

  // Хеш ТОЛЬКО от id (для HashMap, HashSet)
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

Проверяем равенство

import { Equal } from "effect"

const todo1 = new Todo({
  id: TodoId.make("todo_123"),
  title: TodoTitle.make("Buy milk"),
  priority: Priority.High,
  status: TodoStatus.Pending,
  createdAt: DateTime.unsafeNow(),
  updatedAt: DateTime.unsafeNow(),
  completedAt: Option.none(),
})

const todo2 = new Todo({
  id: TodoId.make("todo_123"), // ТОТ ЖЕ id
  title: TodoTitle.make("Buy bread"), // ДРУГОЙ title
  priority: Priority.Low,             // ДРУГОЙ priority
  status: TodoStatus.Completed,       // ДРУГОЙ status
  createdAt: DateTime.unsafeNow(),
  updatedAt: DateTime.unsafeNow(),
  completedAt: Option.some(DateTime.unsafeNow()),
})

// Это ОДНА И ТА ЖЕ Entity (тот же id)
Equal.equals(todo1, todo2) // true ✅

const todo3 = new Todo({
  id: TodoId.make("todo_456"), // ДРУГОЙ id
  title: TodoTitle.make("Buy milk"), // Тот же title
  priority: Priority.High,           // Тот же priority
  // ...
})

// Это РАЗНЫЕ Entity (разные id)
Equal.equals(todo1, todo3) // false ✅

Влияние на коллекции

Правильная реализация Equal/Hash критична для работы с коллекциями Effect:

import { HashMap, HashSet } from "effect"

// HashSet Entity — дедупликация по id
const set = HashSet.make(todo1, todo2) // todo2 не добавится (тот же id)
HashSet.size(set) // 1

// HashMap с Entity как ключом
const map = HashMap.make(
  [todo1, "some value"],
  [todo2, "other value"]  // перезапишет по тому же id
)
HashMap.size(map) // 1

Шаг 4: Фабричный метод — create

Entity не создаётся напрямую через new. Вместо этого мы определяем фабричный метод, который:

  1. Генерирует идентификатор
  2. Устанавливает начальные значения
  3. Проставляет временные метки
  4. Проверяет инварианты
import { Effect, DateTime, Option } from "effect"

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)
  }

  // Фабричный метод для создания новой задачи
  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(),
      })
    })
}

Почему create — это Effect?

Обратите внимание: create возвращает Effect, а не просто Todo. Это потому что:

  • Генерация ID — побочный эффект (random)
  • Получение текущего времени — побочный эффект (clock)
  • Валидация — может завершиться ошибкой

В функциональном мире мы честно отражаем все эффекты в типах. Это ключевое отличие от ООП, где new Todo() скрывает побочные эффекты внутри конструктора.

// Использование
const program = Effect.gen(function* () {
  const todo = yield* Todo.create({
    title: TodoTitle.make("Implement Entity pattern"),
    priority: Priority.High,
  })
  
  console.log(todo.id)        // "todo_550e8400-..."
  console.log(todo.status)    // TodoStatus.Pending
  console.log(todo.createdAt) // 2024-01-15T10:30:00Z
})

Шаг 5: Альтернативный подход — Schema.TaggedClass

Для Entity, которые участвуют в union types (например, разные типы задач), удобно использовать Schema.TaggedClass:

// Задачи разных типов
class RegularTodo extends Schema.TaggedClass<RegularTodo>()("RegularTodo", {
  id: TodoId,
  title: TodoTitle,
  priority: Priority,
  status: TodoStatus,
  createdAt: Schema.DateTimeUtc,
  updatedAt: Schema.DateTimeUtc,
}) {
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof RegularTodo && this.id === that.id
  }
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

class RecurringTodo extends Schema.TaggedClass<RecurringTodo>()("RecurringTodo", {
  id: TodoId,
  title: TodoTitle,
  priority: Priority,
  status: TodoStatus,
  recurrence: RecurrenceRule,
  createdAt: Schema.DateTimeUtc,
  updatedAt: Schema.DateTimeUtc,
}) {
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof RecurringTodo && this.id === that.id
  }
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

// Tagged union
type TodoItem = RegularTodo | RecurringTodo

const TodoItem = Schema.Union(RegularTodo, RecurringTodo)

Schema.TaggedClass автоматически добавляет поле _tag со значением первого аргумента. Это позволяет использовать Match для pattern matching:

import { Match } from "effect"

const describe = Match.type<TodoItem>().pipe(
  Match.tag("RegularTodo", (todo) => `Regular: ${todo.title}`),
  Match.tag("RecurringTodo", (todo) => `Recurring: ${todo.title} (${todo.recurrence})`),
  Match.exhaustive
)

Шаг 6: Полный паттерн Entity — собираем всё вместе

Вот каноническая структура Entity в Effect-ts для нашего курса:

import { Schema, Equal, Hash, Effect, DateTime, Option } from "effect"

// ─── Идентификатор ───────────────────────────────────────────
const TodoIdBrand = Symbol.for("TodoId")

const TodoId = Schema.String.pipe(
  Schema.brand(TodoIdBrand),
  Schema.annotations({ identifier: "TodoId" })
)
type TodoId = typeof TodoId.Type

const generateTodoId: Effect.Effect<TodoId> = Effect.sync(() =>
  TodoId.make(`todo_${crypto.randomUUID()}`)
)

// ─── Value Objects (импортируются из отдельных модулей) ──────
// TodoTitle, Priority, TodoStatus — определены в модуле 12

// ─── Entity ──────────────────────────────────────────────────
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)
  }

  // ─── Фабрика ──────────────────────────────────────────────
  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(),
      })
    })

  // ─── Поведение (подробнее в статье 04) ─────────────────────
  // ... будет рассмотрено позже
}

Schema.Class vs обычный интерфейс: почему Schema.Class?

АспектОбычный interface/typeSchema.Class
ВалидацияНет, нужно вручнуюАвтоматическая при decode
СериализацияНужно писать рукамиАвтоматическая encode/decode
ArbitraryНетАвтоматическая генерация для тестов
ДокументацияЧерез JSDocЧерез Schema.annotations
Pattern MatchingЧерез discriminantЧерез _tag в TaggedClass
Совместимость с EffectТребует обёрткиНативная интеграция
Runtime проверкиНетПри каждом decode

Для доменных Entity Schema.Class — безоговорочный выбор, потому что Entity пересекает границы слоёв (домен → порт → адаптер), и на каждой границе нужна валидация и трансформация.


Продвинутый паттерн: Entity с приватным конструктором

В некоторых случаях вы хотите запретить создание Entity через new, принуждая всех использовать фабричный метод. В TypeScript нет приватных конструкторов для Schema.Class, но мы можем добиться этого через модульную систему:

// файл: domain/entities/Todo.ts

// Не экспортируем класс напрямую
class TodoEntity extends Schema.Class<TodoEntity>("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 TodoEntity && this.id === that.id
  }
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

// Экспортируем ТИП (для использования в сигнатурах)
export type Todo = TodoEntity

// Экспортируем SCHEMA (для decode/encode на границах)
export const TodoSchema: Schema.Schema<Todo> = TodoEntity

// Экспортируем только фабричный метод
export const createTodo = (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 TodoEntity({
      id,
      title: params.title,
      description: Option.fromNullable(params.description),
      priority: params.priority ?? Priority.Medium,
      status: TodoStatus.Pending,
      createdAt: now,
      updatedAt: now,
      completedAt: Option.none(),
    })
  })

Этот подход гарантирует, что Entity создаётся только через контролируемую точку входа, где выполняются все инварианты.


Работа с Encoded формой: граница слоёв

При пересечении границы домен ↔ инфраструктура Entity нужно сериализовать. Schema.Class автоматически предоставляет Encoded форму:

// Encoded форма — то, что хранится в БД / передаётся по HTTP
type TodoEncoded = typeof Todo.Encoded
// {
//   readonly id: string           // без бренда
//   readonly title: string        // без валидации
//   readonly priority: string     // "high" | "medium" | "low"
//   readonly status: string       // "pending" | "completed" | ...
//   readonly createdAt: string    // ISO строка
//   readonly updatedAt: string    // ISO строка
//   readonly completedAt: string | null
// }

// Encode: Todo → TodoEncoded (для записи в БД)
const encode = Schema.encode(Todo)
const encoded = encode(todo)
// Effect<TodoEncoded, ParseError>

// Decode: TodoEncoded → Todo (для чтения из БД)
const decode = Schema.decode(Todo)
const decoded = decode(rawDbRow)
// Effect<Todo, ParseError>

Это критически важно для гексагональной архитектуры: адаптер использует encode/decode для преобразования между доменными типами и инфраструктурными представлениями. Домен работает только с Todo, адаптер работает с TodoEncoded.


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

  1. Schema.Class — основной инструмент для определения Entity в Effect-ts
  2. Branded Types для идентификаторов предотвращают смешивание ID разных Entity
  3. Equal по id — Entity равны только по идентификатору, не по атрибутам
  4. Фабричный метод create — единственный способ создать Entity, возвращает Effect
  5. Schema.TaggedClass — для Entity, участвующих в tagged unions
  6. Encoded форма автоматически доступна для сериализации на границах слоёв
  7. Модульная система позволяет скрыть конструктор, оставив только фабричный метод