Реализация Entity через Schema.Class с Id
Полный паттерн реализации Entity в Effect-ts: Branded Types для идентификаторов, Schema.Class для структуры, Equal/Hash для равенства по идентичности, фабричный метод create, Schema.TaggedClass для tagged unions, Encoded форма для сериализации на границах слоёв.
Стратегия реализации Entity в Effect
В классическом ООП Entity — это класс с полями, методами и мутабельным состоянием. В функциональном Effect-ts мы следуем другому подходу:
- Schema.Class — определяет структуру и автоматическую валидацию
- Branded Types — типобезопасные идентификаторы
- Equal + Hash — равенство по идентификатору
- Чистые функции — поведение через
pipeиEffect - 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 создаёт:
- TypeScript класс с типизированными полями
- Schema для валидации, encode/decode
- Конструктор, который проверяет данные при создании
- Поддержку
_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. Вместо этого мы определяем фабричный метод, который:
- Генерирует идентификатор
- Устанавливает начальные значения
- Проставляет временные метки
- Проверяет инварианты
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/type | Schema.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.
Ключевые выводы
- Schema.Class — основной инструмент для определения Entity в Effect-ts
- Branded Types для идентификаторов предотвращают смешивание ID разных Entity
- Equal по
id— Entity равны только по идентификатору, не по атрибутам - Фабричный метод
create— единственный способ создать Entity, возвращает Effect - Schema.TaggedClass — для Entity, участвующих в tagged unions
- Encoded форма автоматически доступна для сериализации на границах слоёв
- Модульная система позволяет скрыть конструктор, оставив только фабричный метод