Глава
Todo Entity: полная реализация с инвариантами
Production-ready реализация Todo Entity: Value Objects, Branded Types, Schema.Class, Equal/Hash, фабричный метод, guards, переходы состояний, мутация атрибутов, кросс-полевые инварианты, Encoded форма, barrel file, примеры использования, чеклист.
Структура файлов
Прежде чем писать код, определим структуру. В hexagonal architecture доменные файлы расположены в центре:
src/
domain/
value-objects/
TodoId.ts
TodoTitle.ts
Priority.ts
TodoStatus.ts
DueDate.ts
entities/
Todo.ts ← эта статья
errors/
TodoErrors.ts
index.ts ← barrel file (публичный API домена)
Шаг 1: Value Objects (импорты)
Предполагаем, что Value Objects определены в модуле 12. Приведём их компактно для полноты картины:
// domain/value-objects/TodoId.ts
import { Schema } from "effect"
const TodoIdBrand = Symbol.for("TodoId")
export const TodoId = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.brand(TodoIdBrand),
Schema.annotations({ identifier: "TodoId" })
)
export type TodoId = typeof TodoId.Type
// domain/value-objects/TodoTitle.ts
const TodoTitleBrand = Symbol.for("TodoTitle")
export const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.nonEmptyString({ message: () => "Заголовок не может быть пустым" }),
Schema.maxLength(200, { message: () => "Заголовок не может быть длиннее 200 символов" }),
Schema.brand(TodoTitleBrand),
Schema.annotations({ identifier: "TodoTitle" })
)
export type TodoTitle = typeof TodoTitle.Type
// domain/value-objects/Priority.ts
export const Priority = Schema.Literal("low", "medium", "high")
export type Priority = typeof Priority.Type
export const PriorityValues = {
Low: "low" as Priority,
Medium: "medium" as Priority,
High: "high" as Priority,
} as const
// domain/value-objects/TodoStatus.ts
export const TodoStatus = Schema.Literal("pending", "completed", "archived")
export type TodoStatus = typeof TodoStatus.Type
export const TodoStatusValues = {
Pending: "pending" as TodoStatus,
Completed: "completed" as TodoStatus,
Archived: "archived" as TodoStatus,
} as const
// domain/value-objects/DueDate.ts
const DueDateBrand = Symbol.for("DueDate")
export const DueDate = Schema.DateTimeUtc.pipe(
Schema.brand(DueDateBrand),
Schema.annotations({ identifier: "DueDate" })
)
export type DueDate = typeof DueDate.Type
Шаг 2: Доменные ошибки
// domain/errors/TodoErrors.ts
import { Data } from "effect"
import type { TodoId } from "../value-objects/TodoId"
import type { TodoStatus } from "../value-objects/TodoStatus"
export class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: TodoId
}> {
get message() {
return `Задача ${this.todoId} не найдена`
}
}
export class AlreadyCompleted extends Data.TaggedError("AlreadyCompleted")<{
readonly todoId: TodoId
}> {
get message() {
return `Задача ${this.todoId} уже завершена`
}
}
export class AlreadyArchived extends Data.TaggedError("AlreadyArchived")<{
readonly todoId: TodoId
}> {
get message() {
return `Задача ${this.todoId} уже архивирована`
}
}
export class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
readonly todoId: TodoId
readonly from: TodoStatus
readonly to: TodoStatus
}> {
get message() {
return `Недопустимый переход для задачи ${this.todoId}: ${this.from} → ${this.to}`
}
}
export class TodoModificationForbidden extends Data.TaggedError("TodoModificationForbidden")<{
readonly todoId: TodoId
readonly reason: string
}> {
get message() {
return `Изменение задачи ${this.todoId} запрещено: ${this.reason}`
}
}
// Union всех доменных ошибок Todo
export type TodoError =
| TodoNotFound
| AlreadyCompleted
| AlreadyArchived
| InvalidTransition
| TodoModificationForbidden
Шаг 3: Полная реализация Todo Entity
// domain/entities/Todo.ts
import {
Schema,
Effect,
DateTime,
Option,
Equal,
Hash,
pipe,
} from "effect"
import { TodoId } from "../value-objects/TodoId"
import { TodoTitle } from "../value-objects/TodoTitle"
import { Priority, PriorityValues } from "../value-objects/Priority"
import { TodoStatus, TodoStatusValues } from "../value-objects/TodoStatus"
import { DueDate } from "../value-objects/DueDate"
import {
AlreadyCompleted,
AlreadyArchived,
InvalidTransition,
TodoModificationForbidden,
} from "../errors/TodoErrors"
// ─────────────────────────────────────────────────────────────
// Генерация ID
// ─────────────────────────────────────────────────────────────
const generateTodoId: Effect.Effect<TodoId> = Effect.sync(() =>
TodoId.make(`todo_${crypto.randomUUID()}`)
)
// ─────────────────────────────────────────────────────────────
// Допустимые переходы состояний
// ─────────────────────────────────────────────────────────────
const VALID_TRANSITIONS: ReadonlyArray<{
readonly from: TodoStatus
readonly to: TodoStatus
}> = [
{ from: "pending", to: "completed" },
{ from: "pending", to: "archived" },
{ from: "completed", to: "archived" },
] as const
const isValidTransition = (from: TodoStatus, to: TodoStatus): boolean =>
VALID_TRANSITIONS.some((t) => t.from === from && t.to === to)
// ─────────────────────────────────────────────────────────────
// Entity
// ─────────────────────────────────────────────────────────────
export class Todo extends Schema.Class<Todo>("Todo")({
// ─── Идентичность ──────────────────────────────────────
id: TodoId,
// ─── Атрибуты (Value Objects) ──────────────────────────
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
status: TodoStatus,
dueDate: Schema.OptionFromNullOr(DueDate),
// ─── Временные метки жизненного цикла ──────────────────
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
archivedAt: 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
readonly dueDate?: DueDate
}): 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 ?? PriorityValues.Medium,
status: TodoStatusValues.Pending,
dueDate: Option.fromNullable(params.dueDate),
createdAt: now,
updatedAt: now,
completedAt: Option.none(),
archivedAt: Option.none(),
})
})
// ═══════════════════════════════════════════════════════════
// Guards: проверки состояния
// ═══════════════════════════════════════════════════════════
get isPending(): boolean {
return this.status === TodoStatusValues.Pending
}
get isCompleted(): boolean {
return this.status === TodoStatusValues.Completed
}
get isArchived(): boolean {
return this.status === TodoStatusValues.Archived
}
get isHighPriority(): boolean {
return this.priority === PriorityValues.High
}
get hasDescription(): boolean {
return Option.isSome(this.description)
}
get hasDueDate(): boolean {
return Option.isSome(this.dueDate)
}
isOverdue(now: DateTime.Utc): boolean {
return Option.match(this.dueDate, {
onNone: () => false,
onSome: (due) =>
DateTime.greaterThan(now, due) && !this.isCompleted,
})
}
// ═══════════════════════════════════════════════════════════
// Переходы состояний
// ═══════════════════════════════════════════════════════════
/**
* Завершить задачу.
* Допустимо только из Pending.
*/
readonly complete = (): Effect.Effect<Todo, AlreadyCompleted | AlreadyArchived> => {
if (this.isCompleted) {
return Effect.fail(new AlreadyCompleted({ todoId: this.id }))
}
if (this.isArchived) {
return Effect.fail(new AlreadyArchived({ todoId: this.id }))
}
return Effect.map(DateTime.now, (now) =>
new Todo({
...this,
status: TodoStatusValues.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
)
}
/**
* Архивировать задачу.
* Допустимо из Pending и Completed.
*/
readonly archive = (): Effect.Effect<Todo, AlreadyArchived> => {
if (this.isArchived) {
return Effect.fail(new AlreadyArchived({ todoId: this.id }))
}
return Effect.map(DateTime.now, (now) =>
new Todo({
...this,
status: TodoStatusValues.Archived,
archivedAt: Option.some(now),
updatedAt: now,
})
)
}
// ═══════════════════════════════════════════════════════════
// Мутация атрибутов
// ═══════════════════════════════════════════════════════════
/**
* Общий guard: Entity можно менять только если она не архивирована
* и не завершена (для определённых операций).
*/
private ensureModifiable(): Effect.Effect<void, TodoModificationForbidden> {
if (this.isArchived) {
return Effect.fail(new TodoModificationForbidden({
todoId: this.id,
reason: "Архивированная задача не может быть изменена"
}))
}
return Effect.void
}
private ensurePending(): Effect.Effect<void, TodoModificationForbidden> {
if (!this.isPending) {
return Effect.fail(new TodoModificationForbidden({
todoId: this.id,
reason: `Операция доступна только для задач в статусе pending, текущий: ${this.status}`
}))
}
return Effect.void
}
/**
* Обновить заголовок.
* Доступно только для не-архивированных задач.
*/
readonly updateTitle = (
newTitle: TodoTitle,
): Effect.Effect<Todo, TodoModificationForbidden> =>
Effect.gen(this, function* () {
yield* this.ensureModifiable()
const now = yield* DateTime.now
return new Todo({ ...this, title: newTitle, updatedAt: now })
})
/**
* Обновить описание.
* Доступно только для не-архивированных задач.
*/
readonly updateDescription = (
description: Option.Option<string>,
): Effect.Effect<Todo, TodoModificationForbidden> =>
Effect.gen(this, function* () {
yield* this.ensureModifiable()
const now = yield* DateTime.now
return new Todo({ ...this, description, updatedAt: now })
})
/**
* Обновить приоритет.
* Доступно только для pending задач.
*/
readonly updatePriority = (
newPriority: Priority,
): Effect.Effect<Todo, TodoModificationForbidden> =>
Effect.gen(this, function* () {
yield* this.ensurePending()
const now = yield* DateTime.now
return new Todo({ ...this, priority: newPriority, updatedAt: now })
})
/**
* Установить/обновить дату выполнения.
* Доступно только для pending задач.
*/
readonly setDueDate = (
dueDate: Option.Option<DueDate>,
): Effect.Effect<Todo, TodoModificationForbidden> =>
Effect.gen(this, function* () {
yield* this.ensurePending()
const now = yield* DateTime.now
return new Todo({ ...this, dueDate, updatedAt: now })
})
}
Шаг 4: Schema с кросс-полевыми инвариантами
Для валидации данных из внешних источников (БД, HTTP) добавляем Schema.filter:
// domain/entities/Todo.ts (продолжение)
/**
* Schema с валидацией кросс-полевых инвариантов.
* Используется при decode данных из внешних источников.
*/
export const ValidatedTodo = Todo.pipe(
Schema.filter((todo) => {
const issues: Array<Schema.FilterIssue> = []
// Инвариант: completed → completedAt обязателен
if (
todo.status === TodoStatusValues.Completed &&
Option.isNone(todo.completedAt)
) {
issues.push({
path: ["completedAt"],
message: "Завершённая задача должна иметь completedAt"
})
}
// Инвариант: pending → completedAt отсутствует
if (
todo.status === TodoStatusValues.Pending &&
Option.isSome(todo.completedAt)
) {
issues.push({
path: ["completedAt"],
message: "Pending задача не может иметь completedAt"
})
}
// Инвариант: archived → archivedAt обязателен
if (
todo.status === TodoStatusValues.Archived &&
Option.isNone(todo.archivedAt)
) {
issues.push({
path: ["archivedAt"],
message: "Архивированная задача должна иметь archivedAt"
})
}
// Инвариант: updatedAt >= createdAt
if (DateTime.lessThan(todo.updatedAt, todo.createdAt)) {
issues.push({
path: ["updatedAt"],
message: "updatedAt не может быть раньше createdAt"
})
}
// Инвариант: completedAt >= createdAt
if (Option.isSome(todo.completedAt)) {
const completed = Option.getOrThrow(todo.completedAt)
if (DateTime.lessThan(completed, todo.createdAt)) {
issues.push({
path: ["completedAt"],
message: "completedAt не может быть раньше createdAt"
})
}
}
// Инвариант: archivedAt >= createdAt
if (Option.isSome(todo.archivedAt)) {
const archived = Option.getOrThrow(todo.archivedAt)
if (DateTime.lessThan(archived, todo.createdAt)) {
issues.push({
path: ["archivedAt"],
message: "archivedAt не может быть раньше createdAt"
})
}
}
return issues.length > 0 ? issues : undefined
})
)
Шаг 5: Barrel file — публичный API домена
// domain/index.ts
// Value Objects
export { TodoId } from "./value-objects/TodoId"
export type { TodoId } from "./value-objects/TodoId"
export { TodoTitle } from "./value-objects/TodoTitle"
export type { TodoTitle } from "./value-objects/TodoTitle"
export { Priority, PriorityValues } from "./value-objects/Priority"
export type { Priority } from "./value-objects/Priority"
export { TodoStatus, TodoStatusValues } from "./value-objects/TodoStatus"
export type { TodoStatus } from "./value-objects/TodoStatus"
export { DueDate } from "./value-objects/DueDate"
export type { DueDate } from "./value-objects/DueDate"
// Entities
export { Todo, ValidatedTodo } from "./entities/Todo"
// Errors
export {
TodoNotFound,
AlreadyCompleted,
AlreadyArchived,
InvalidTransition,
TodoModificationForbidden,
} from "./errors/TodoErrors"
export type { TodoError } from "./errors/TodoErrors"
Шаг 6: Примеры использования
Сценарий 1: Создание и завершение задачи
import { Effect, Option } from "effect"
import { Todo, TodoTitle, PriorityValues } from "./domain"
const scenario1 = Effect.gen(function* () {
// Создание
const todo = yield* Todo.create({
title: TodoTitle.make("Написать статью про Entity"),
priority: PriorityValues.High,
})
yield* Effect.log(`Создана задача: ${todo.id}`)
// Обновление описания
const withDesc = yield* todo.updateDescription(
Option.some("Подробная статья с примерами кода")
)
// Завершение
const completed = yield* withDesc.complete()
yield* Effect.log(`Задача завершена: ${completed.isCompleted}`) // true
// Попытка изменить завершённую задачу
const result = yield* completed.updatePriority(PriorityValues.Low).pipe(
Effect.either
)
// result = Left(TodoModificationForbidden)
// Архивирование
const archived = yield* completed.archive()
yield* Effect.log(`Задача архивирована: ${archived.isArchived}`) // true
return archived
})
Сценарий 2: Обработка ошибок
const scenario2 = (todoId: TodoId) =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const todo = yield* repo.findById(todoId)
// Попытка завершить — может быть уже завершена
const completed = yield* todo.complete().pipe(
Effect.catchTag("AlreadyCompleted", (err) =>
Effect.gen(function* () {
yield* Effect.logWarning(`Задача ${err.todoId} уже завершена`)
return todo // возвращаем как есть
})
),
Effect.catchTag("AlreadyArchived", (err) =>
Effect.fail(new TodoModificationForbidden({
todoId: err.todoId,
reason: "Невозможно завершить архивированную задачу"
}))
)
)
yield* repo.save(completed)
return completed
})
Сценарий 3: Работа с коллекциями Entity
import { ReadonlyArray, pipe, Option, DateTime, Effect } from "effect"
const getOverdueTodos = (todos: ReadonlyArray<Todo>) =>
Effect.gen(function* () {
const now = yield* DateTime.now
return pipe(
todos,
ReadonlyArray.filter((todo) => todo.isOverdue(now)),
ReadonlyArray.sort((a, b) => {
const aDue = Option.getOrElse(a.dueDate, () => DateTime.unsafeMake(0))
const bDue = Option.getOrElse(b.dueDate, () => DateTime.unsafeMake(0))
return DateTime.Order(aDue, bDue)
})
)
})
const getStats = (todos: ReadonlyArray<Todo>) => ({
total: todos.length,
pending: todos.filter((t) => t.isPending).length,
completed: todos.filter((t) => t.isCompleted).length,
archived: todos.filter((t) => t.isArchived).length,
highPriority: todos.filter((t) => t.isHighPriority).length,
})
Шаг 7: Сериализация — граница с инфраструктурой
// В адаптере (НЕ в домене)
import { Schema } from "effect"
import { Todo, ValidatedTodo } from "../domain"
// Encode: Todo → JSON-ready объект (для записи в БД)
const encodeTodo = Schema.encode(Todo)
// Decode: неизвестные данные → валидный Todo (для чтения из БД)
const decodeTodo = Schema.decodeUnknown(ValidatedTodo)
// Использование в SQLite-адаптере
const saveTodo = (todo: Todo) =>
Effect.gen(function* () {
const encoded = yield* encodeTodo(todo)
// encoded.id → string (без бренда)
// encoded.status → "pending" | "completed" | "archived"
// encoded.completedAt → string | null
yield* executeSql(
"INSERT INTO todos VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[encoded.id, encoded.title, /* ... */]
)
})
const loadTodo = (id: string) =>
Effect.gen(function* () {
const row = yield* querySql("SELECT * FROM todos WHERE id = ?", [id])
const todo = yield* decodeTodo(row)
// todo — полноценный Todo с инвариантами и поведением
return todo
})
Чеклист: правильно ли реализована Entity?
- Branded Type для идентификатора — предотвращает смешивание ID
- Equal по id — два экземпляра с одинаковым id равны
- Hash по id — для корректной работы с HashMap/HashSet
- Фабричный метод create — единственный способ создания нового экземпляра
- Все поля readonly — иммутабельность гарантирована
- Поведение возвращает новый экземпляр — никакой мутации
- Ошибки типизированы — E-канал Effect содержит доменные ошибки
- Кросс-полевые инварианты — через Schema.filter для внешних данных
- Нулевые зависимости — не импортирует инфраструктуру
- Метки времени — createdAt, updatedAt, completedAt проставляются автоматически
- State Machine — переходы проверяют допустимость
Ключевые выводы
- Todo Entity — центральный объект домена с полным поведением
- Все Value Objects — TodoId, TodoTitle, Priority, TodoStatus — типобезопасны
- Инварианты гарантируются конструктивно (поведение) и защитно (Schema.filter)
- Жизненный цикл — Pending → Completed → Archived с чёткими правилами
- Ошибки — типизированные, в E-канале Effect, с человекочитаемыми сообщениями
- Сериализация — автоматическая через Schema.encode/decode на границах
- Entity живёт в домене — ноль зависимостей от инфраструктуры