Домен Todo: первая модель — Task, Status, Priority
Полная практическая реализация доменной модели Todo: Value Objects (TodoId, TodoTitle, TodoDescription, Priority, TodoStatus, DueDate), Todo Entity с командами (complete, archive, changePriority, rename, setDueDate) и запросами (isOverdue, isActive, canBeCompleted), доменные ошибки (TodoNotFound, InvalidStatusTransition, DuplicateTitle), доменные события (TodoCreated, TodoCompleted, TodoArchived, TodoRenamed, TodoPriorityChanged), доменные сервисы (фильтрация, сортировка, статистика, валидация уникальности), структура файлов, barrel file, тесты домена
Введение: от теории к практике
В предыдущих главах мы разобрали теорию доменного моделирования: что такое домен, зачем нужна чистота, какие типы существуют, как строить Ubiquitous Language. Теперь пришло время применить все знания и создать первую доменную модель нашего Todo-приложения.
Это будет рабочий код, который мы будем развивать на протяжении всего курса. Каждый последующий модуль будет добавлять к нему новые слои и возможности.
Анализ домена: что мы моделируем
Вернёмся к нашему диалогу с бизнес-экспертом и формализуем требования:
Бизнес-правила Todo
- Задача (Todo) — единица работы
- Задача имеет заголовок (1-255 символов, не пустой)
- Задача имеет приоритет: Low, Medium, High, Critical
- Задача имеет статус: Active, Completed, Archived
- Задача создаётся в статусе Active
- Активную задачу можно завершить (Active → Completed)
- Задачу можно архивировать (Active → Archived, Completed → Archived)
- Архивная задача — конечное состояние (из Archived нельзя перейти никуда)
- Завершённую задачу нельзя завершить повторно
- У задачи есть дата создания и опциональная дата завершения
- У задачи есть опциональное описание
- У задачи может быть срок выполнения (due date)
- Если срок истёк, а задача активна — она просрочена
Диаграмма состояний
┌──────────────┐
│ │
create() │ Active │
──────────► │ │
└──────┬───────┘
│
┌──────────┼──────────┐
│ │
complete() archive()
│ │
▼ │
┌──────────────┐ │
│ │ │
│ Completed │ │
│ │ │
└──────┬───────┘ │
│ │
archive() │
│ │
▼ ▼
┌───────────────────────────┐
│ │
│ Archived │
│ (конечное состояние) │
│ │
└───────────────────────────┘
Шаг 1: Value Objects
Начнём с самых базовых строительных блоков — Value Objects. Каждый Value Object инкапсулирует одно понятие домена с валидацией.
TodoId — уникальный идентификатор задачи
// domain/value-objects/todo-id.ts
import { Schema } from "effect"
/**
* TodoId — уникальный идентификатор задачи.
* Branded type гарантирует, что обычная строка
* не может быть использована как TodoId без валидации.
*/
export const TodoIdBrand = Schema.String.pipe(
Schema.minLength(1, {
message: () => "TodoId cannot be empty"
}),
Schema.brand("TodoId")
)
export type TodoId = Schema.Schema.Type<typeof TodoIdBrand>
/**
* Создание TodoId из строки с валидацией.
* Возвращает Effect с ошибкой, если строка пуста.
*/
export const makeTodoId = Schema.decodeUnknown(TodoIdBrand)
TodoTitle — заголовок задачи
// domain/value-objects/todo-title.ts
import { Schema } from "effect"
/**
* TodoTitle — заголовок задачи.
* Бизнес-правила:
* - Не может быть пустым
* - Максимум 255 символов
* - Автоматически обрезает пробелы по краям
*/
export const TodoTitleBrand = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, {
message: () => "Заголовок задачи не может быть пустым"
}),
Schema.maxLength(255, {
message: () => "Заголовок задачи не может превышать 255 символов"
}),
Schema.brand("TodoTitle")
)
export type TodoTitle = Schema.Schema.Type<typeof TodoTitleBrand>
export const makeTodoTitle = Schema.decodeUnknown(TodoTitleBrand)
TodoDescription — описание задачи
// domain/value-objects/todo-description.ts
import { Schema } from "effect"
/**
* TodoDescription — опциональное описание задачи.
* Бизнес-правила:
* - Максимум 10000 символов
* - Автоматически обрезает пробелы по краям
*/
export const TodoDescriptionBrand = Schema.String.pipe(
Schema.trimmed(),
Schema.maxLength(10000, {
message: () => "Описание задачи не может превышать 10000 символов"
}),
Schema.brand("TodoDescription")
)
export type TodoDescription = Schema.Schema.Type<typeof TodoDescriptionBrand>
export const makeTodoDescription = Schema.decodeUnknown(TodoDescriptionBrand)
Priority — приоритет задачи
// domain/value-objects/priority.ts
import { Schema, Order } from "effect"
/**
* Priority — приоритет задачи.
* Бизнес-значения: Low, Medium, High, Critical.
*/
export const Priority = Schema.Literal("Low", "Medium", "High", "Critical")
export type Priority = Schema.Schema.Type<typeof Priority>
/**
* Числовой вес приоритета для сортировки и сравнения.
* Чистая функция — не зависит от внешних ресурсов.
*/
const PRIORITY_WEIGHT: Record<Priority, number> = {
Low: 0,
Medium: 1,
High: 2,
Critical: 3,
} as const
/**
* Order для сортировки приоритетов.
* Low < Medium < High < Critical
*/
export const PriorityOrder: Order.Order<Priority> = Order.mapInput(
Order.number,
(p: Priority) => PRIORITY_WEIGHT[p]
)
/**
* Проверка: является ли приоритет «высоким» (High или Critical).
*/
export const isHighPriority = (priority: Priority): boolean =>
priority === "High" || priority === "Critical"
/**
* Проверка: a выше по приоритету, чем b.
*/
export const isHigherThan = (a: Priority, b: Priority): boolean =>
PRIORITY_WEIGHT[a] > PRIORITY_WEIGHT[b]
TodoStatus — статус задачи
// domain/value-objects/todo-status.ts
import { Schema, HashMap, HashSet } from "effect"
/**
* TodoStatus — статус задачи.
* Active → Completed → Archived
* Active → Archived (прямое архивирование)
*/
export const TodoStatus = Schema.Literal("Active", "Completed", "Archived")
export type TodoStatus = Schema.Schema.Type<typeof TodoStatus>
/**
* Допустимые переходы между статусами.
* Определены как неизменяемая структура — это бизнес-правило.
*/
const VALID_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlySet<TodoStatus>> =
new Map([
["Active", new Set<TodoStatus>(["Completed", "Archived"])],
["Completed", new Set<TodoStatus>(["Archived"])],
["Archived", new Set<TodoStatus>()],
])
/**
* Проверяет, допустим ли переход из одного статуса в другой.
* Чистая функция — бизнес-правило без побочных эффектов.
*/
export const canTransition = (
from: TodoStatus,
to: TodoStatus
): boolean => {
const allowed = VALID_TRANSITIONS.get(from)
return allowed !== undefined && allowed.has(to)
}
/**
* Получить все допустимые переходы из текущего статуса.
*/
export const getValidTransitions = (
from: TodoStatus
): ReadonlySet<TodoStatus> =>
VALID_TRANSITIONS.get(from) ?? new Set()
/**
* Является ли статус конечным (нет допустимых переходов).
*/
export const isTerminal = (status: TodoStatus): boolean =>
getValidTransitions(status).size === 0
DueDate — срок выполнения
// domain/value-objects/due-date.ts
import { Schema, Option } from "effect"
/**
* DueDate — срок выполнения задачи.
* Опциональное значение (задача может не иметь срока).
*/
export const DueDate = Schema.DateFromSelf
export type DueDate = Schema.Schema.Type<typeof DueDate>
/**
* Проверяет, просрочен ли срок.
* Чистая функция: время передаётся как параметр.
*/
export const isPastDue = (dueDate: Date, now: Date): boolean =>
now > dueDate
/**
* Рассчитывает количество дней до/после срока.
* Положительное число — дней осталось.
* Отрицательное — дней просрочки.
*/
export const daysUntilDue = (dueDate: Date, now: Date): number => {
const diffMs = dueDate.getTime() - now.getTime()
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
}
Шаг 2: Domain Errors
Определим все ошибки, которые может порождать наш домен.
// domain/errors/todo-errors.ts
import { Data } from "effect"
/**
* Задача не найдена.
* Возникает при попытке получить задачу по несуществующему ID.
*/
export class TodoNotFoundError extends Data.TaggedError(
"TodoNotFoundError"
)<{
readonly todoId: string
}> {
get message() {
return `Задача с id "${this.todoId}" не найдена`
}
}
/**
* Недопустимый переход статуса.
* Возникает при попытке выполнить запрещённый переход
* (например, Completed → Active).
*/
export class InvalidStatusTransitionError extends Data.TaggedError(
"InvalidStatusTransitionError"
)<{
readonly from: string
readonly to: string
}> {
get message() {
return `Невозможно перевести задачу из "${this.from}" в "${this.to}"`
}
}
/**
* Дублирующийся заголовок.
* Возникает при попытке создать задачу с заголовком,
* который уже используется в данном контексте.
*/
export class DuplicateTitleError extends Data.TaggedError(
"DuplicateTitleError"
)<{
readonly title: string
}> {
get message() {
return `Задача с заголовком "${this.title}" уже существует`
}
}
/**
* Пустой заголовок.
* Возникает при попытке создать/переименовать задачу с пустым заголовком.
*/
export class EmptyTitleError extends Data.TaggedError(
"EmptyTitleError"
)<{}> {
get message() {
return "Заголовок задачи не может быть пустым"
}
}
/**
* Заголовок слишком длинный.
*/
export class TitleTooLongError extends Data.TaggedError(
"TitleTooLongError"
)<{
readonly title: string
readonly maxLength: number
}> {
get message() {
return `Заголовок "${this.title.slice(0, 50)}..." превышает ${this.maxLength} символов`
}
}
/**
* Union всех доменных ошибок Todo.
* Используется для типизации E-канала в Effect.
*/
export type TodoDomainError =
| TodoNotFoundError
| InvalidStatusTransitionError
| DuplicateTitleError
| EmptyTitleError
| TitleTooLongError
Шаг 3: Todo Entity
Теперь собираем всё вместе — создаём Entity Todo с полным поведением.
// domain/entities/todo.ts
import { Schema, Effect, Option, pipe } from "effect"
import type { TodoId } from "../value-objects/todo-id.js"
import { TodoTitleBrand, type TodoTitle } from "../value-objects/todo-title.js"
import type { TodoDescription } from "../value-objects/todo-description.js"
import { type Priority, isHighPriority } from "../value-objects/priority.js"
import {
type TodoStatus,
canTransition,
isTerminal,
} from "../value-objects/todo-status.js"
import { isPastDue, daysUntilDue } from "../value-objects/due-date.js"
import {
InvalidStatusTransitionError,
} from "../errors/todo-errors.js"
// ─────────────────────────────────────────────
// Todo Entity — полная реализация
// ─────────────────────────────────────────────
/**
* Todo — доменная сущность «Задача».
*
* Ключевые свойства:
* - Идентичность определяется полем `id` (TodoId)
* - Неизменяемость: каждая операция возвращает новый объект
* - Самовалидация: Schema гарантирует корректность данных
* - Инварианты: бизнес-правила защищены через методы
*
* Жизненный цикл:
* create → Active → complete → Completed → archive → Archived
* → archive → Archived (напрямую)
*/
export class Todo extends Schema.Class<Todo>("Todo")({
/** Уникальный идентификатор задачи */
id: Schema.String.pipe(Schema.brand("TodoId")),
/** Заголовок задачи (1-255 символов) */
title: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(255)
),
/** Опциональное описание задачи */
description: Schema.OptionFromNullOr(
Schema.String.pipe(Schema.trimmed(), Schema.maxLength(10000))
),
/** Текущий статус: Active | Completed | Archived */
status: Schema.Literal("Active", "Completed", "Archived"),
/** Приоритет: Low | Medium | High | Critical */
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
/** Дата создания (устанавливается при создании, не меняется) */
createdAt: Schema.DateFromSelf,
/** Дата завершения (устанавливается при переходе в Completed) */
completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
/** Срок выполнения (опциональный) */
dueDate: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {
// ─────────────────────────────────────────
// Commands (операции, изменяющие состояние)
// ─────────────────────────────────────────
/**
* Завершить задачу.
*
* Бизнес-правила:
* - Только активная задача может быть завершена
* - Устанавливается дата завершения
*
* @param now - текущее время (передаётся извне для чистоты)
*/
complete(
now: Date
): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (!canTransition(this.status, "Completed")) {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: "Completed",
})
)
}
return Effect.succeed(
new Todo({
...this,
status: "Completed",
completedAt: now,
})
)
}
/**
* Архивировать задачу.
*
* Бизнес-правила:
* - Активная или завершённая задача может быть архивирована
* - Архивная задача не может быть архивирована повторно
*/
archive(): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (!canTransition(this.status, "Archived")) {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: "Archived",
})
)
}
return Effect.succeed(
new Todo({
...this,
status: "Archived",
})
)
}
/**
* Изменить приоритет задачи.
*
* Бизнес-правила:
* - Можно менять приоритет только активной задачи
* - Завершённые и архивные задачи нельзя менять
*/
changePriority(
newPriority: Priority
): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (this.status !== "Active") {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: this.status, // Не переход статуса, а запрет модификации
})
)
}
return Effect.succeed(
new Todo({
...this,
priority: newPriority,
})
)
}
/**
* Переименовать задачу.
*
* Бизнес-правила:
* - Можно переименовать только активную задачу
* - Новый заголовок не может быть пустым
* - Новый заголовок не может превышать 255 символов
*/
rename(
newTitle: string
): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (this.status !== "Active") {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: this.status,
})
)
}
const trimmed = newTitle.trim()
// Валидация через Schema будет при decode,
// но базовую проверку делаем в методе
return Effect.succeed(
new Todo({
...this,
title: trimmed,
})
)
}
/**
* Установить срок выполнения.
*
* Бизнес-правила:
* - Можно установить срок только для активной задачи
*/
setDueDate(
dueDate: Date | null
): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (this.status !== "Active") {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: this.status,
})
)
}
return Effect.succeed(
new Todo({
...this,
dueDate,
})
)
}
/**
* Обновить описание задачи.
*
* Бизнес-правила:
* - Можно менять описание только активной задачи
*/
updateDescription(
description: string | null
): Effect.Effect<Todo, InvalidStatusTransitionError> {
if (this.status !== "Active") {
return Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: this.status,
})
)
}
return Effect.succeed(
new Todo({
...this,
description: description?.trim() ?? null,
})
)
}
// ─────────────────────────────────────
// Queries (запросы, не меняющие состояние)
// ─────────────────────────────────────
/**
* Является ли задача активной.
*/
get isActive(): boolean {
return this.status === "Active"
}
/**
* Является ли задача завершённой.
*/
get isCompleted(): boolean {
return this.status === "Completed"
}
/**
* Является ли задача архивной.
*/
get isArchived(): boolean {
return this.status === "Archived"
}
/**
* Просрочена ли задача.
*
* Задача просрочена, если:
* 1. Она активна (Active)
* 2. У неё установлен срок
* 3. Текущее время превышает срок
*
* @param now - текущее время (передаётся для чистоты)
*/
isOverdue(now: Date): boolean {
return (
this.status === "Active" &&
Option.isSome(this.dueDate) &&
isPastDue(this.dueDate.value, now)
)
}
/**
* Является ли задача высокоприоритетной (High или Critical).
*/
get isHighPriority(): boolean {
return isHighPriority(this.priority)
}
/**
* Количество дней до/после срока.
* Положительное — дней осталось. Отрицательное — дней просрочки.
* None — если срок не установлен.
*/
daysUntilDue(now: Date): Option.Option<number> {
return pipe(
this.dueDate,
Option.map((due) => daysUntilDue(due, now))
)
}
/**
* Можно ли данную задачу завершить.
*/
get canBeCompleted(): boolean {
return canTransition(this.status, "Completed")
}
/**
* Можно ли данную задачу архивировать.
*/
get canBeArchived(): boolean {
return canTransition(this.status, "Archived")
}
/**
* Находится ли задача в конечном состоянии.
*/
get isInTerminalState(): boolean {
return isTerminal(this.status)
}
}
Шаг 4: Фабричная функция создания Todo
Фабрика создания задачи — отдельная функция, поскольку создание может включать валидацию и генерацию данных.
// domain/entities/todo-factory.ts
import { Effect, Option } from "effect"
import { Todo } from "./todo.js"
import type { TodoId } from "../value-objects/todo-id.js"
import type { Priority } from "../value-objects/priority.js"
/**
* Входные данные для создания задачи.
* Это доменный тип, не DTO — нет инфраструктурных полей.
*/
export interface CreateTodoInput {
readonly id: TodoId
readonly title: string
readonly priority: Priority
readonly description?: string | null
readonly dueDate?: Date | null
readonly now: Date
}
/**
* Создаёт новую задачу в статусе Active.
*
* Это фабричная функция — единственный правильный способ
* создания нового Todo. Гарантирует начальные инварианты:
* - Статус = Active
* - completedAt = None
* - createdAt = переданное время
*
* Валидация title (minLength, maxLength) выполняется
* конструктором Schema.Class.
*/
export const createTodo = (
input: CreateTodoInput
): Effect.Effect<Todo, Schema.ParseError> =>
Effect.try({
try: () =>
new Todo({
id: input.id,
title: input.title.trim(),
description: input.description?.trim() ?? null,
status: "Active",
priority: input.priority,
createdAt: input.now,
completedAt: null,
dueDate: input.dueDate ?? null,
}),
catch: (error) => error as Schema.ParseError,
})
Шаг 5: Domain Events
Определим события, которые генерирует наш домен.
// domain/events/todo-events.ts
import { Schema } from "effect"
/**
* Задача создана.
*
* Генерируется при создании новой задачи.
* Содержит все основные данные задачи на момент создания.
*/
export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
"TodoCreated",
{
todoId: Schema.String,
title: Schema.String,
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
occurredAt: Schema.DateFromSelf,
}
) {}
/**
* Задача завершена.
*
* Генерируется при переходе Active → Completed.
*/
export class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
"TodoCompleted",
{
todoId: Schema.String,
completedAt: Schema.DateFromSelf,
occurredAt: Schema.DateFromSelf,
}
) {}
/**
* Задача архивирована.
*
* Генерируется при переходе в Archived.
*/
export class TodoArchived extends Schema.TaggedClass<TodoArchived>()(
"TodoArchived",
{
todoId: Schema.String,
previousStatus: Schema.Literal("Active", "Completed"),
occurredAt: Schema.DateFromSelf,
}
) {}
/**
* Заголовок задачи изменён.
*/
export class TodoRenamed extends Schema.TaggedClass<TodoRenamed>()(
"TodoRenamed",
{
todoId: Schema.String,
oldTitle: Schema.String,
newTitle: Schema.String,
occurredAt: Schema.DateFromSelf,
}
) {}
/**
* Приоритет задачи изменён.
*/
export class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()(
"TodoPriorityChanged",
{
todoId: Schema.String,
oldPriority: Schema.Literal("Low", "Medium", "High", "Critical"),
newPriority: Schema.Literal("Low", "Medium", "High", "Critical"),
occurredAt: Schema.DateFromSelf,
}
) {}
/**
* Union всех доменных событий Todo.
*/
export type TodoEvent =
| TodoCreated
| TodoCompleted
| TodoArchived
| TodoRenamed
| TodoPriorityChanged
Шаг 6: Domain Services
Доменные сервисы — чистые функции, работающие с коллекциями Entity.
// domain/services/todo-query-service.ts
import { type Todo } from "../entities/todo.js"
import type { Priority } from "../value-objects/priority.js"
import type { TodoStatus } from "../value-objects/todo-status.js"
import { PriorityOrder } from "../value-objects/priority.js"
import { Order } from "effect"
// ─────────────────────────────────────────
// Фильтрация
// ─────────────────────────────────────────
/**
* Фильтр задач по статусу.
*/
export const filterByStatus = (
todos: ReadonlyArray<Todo>,
status: TodoStatus
): ReadonlyArray<Todo> =>
todos.filter((t) => t.status === status)
/**
* Фильтр задач по приоритету.
*/
export const filterByPriority = (
todos: ReadonlyArray<Todo>,
priority: Priority
): ReadonlyArray<Todo> =>
todos.filter((t) => t.priority === priority)
/**
* Фильтр просроченных задач.
*/
export const filterOverdue = (
todos: ReadonlyArray<Todo>,
now: Date
): ReadonlyArray<Todo> =>
todos.filter((t) => t.isOverdue(now))
/**
* Фильтр активных высокоприоритетных задач.
*/
export const filterUrgent = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
todos.filter((t) => t.isActive && t.isHighPriority)
// ─────────────────────────────────────────
// Сортировка
// ─────────────────────────────────────────
/**
* Сортировка задач по приоритету (от высшего к низшему).
* Стабильная сортировка — порядок задач с одинаковым приоритетом сохраняется.
*/
export const sortByPriority = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> => {
const reversed = Order.reverse(PriorityOrder)
return [...todos].sort((a, b) =>
reversed(a.priority, b.priority)
)
}
/**
* Сортировка по дате создания (новые первыми).
*/
export const sortByCreatedAt = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
[...todos].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
/**
* Сортировка по сроку выполнения (ближайшие первыми).
* Задачи без срока — в конце.
*/
export const sortByDueDate = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
[...todos].sort((a, b) => {
const aDue = a.dueDate._tag === "Some" ? a.dueDate.value.getTime() : Infinity
const bDue = b.dueDate._tag === "Some" ? b.dueDate.value.getTime() : Infinity
return aDue - bDue
})
// ─────────────────────────────────────────
// Статистика
// ─────────────────────────────────────────
/**
* Статистика по коллекции задач.
* Чистая функция — вычисляет агрегированные данные.
*/
export interface TodoStats {
readonly total: number
readonly active: number
readonly completed: number
readonly archived: number
readonly overdue: number
readonly byPriority: Readonly<Record<Priority, number>>
readonly completionRate: number // 0.0 - 1.0
}
export const calculateStats = (
todos: ReadonlyArray<Todo>,
now: Date
): TodoStats => {
const active = todos.filter((t) => t.status === "Active").length
const completed = todos.filter((t) => t.status === "Completed").length
const archived = todos.filter((t) => t.status === "Archived").length
const overdue = todos.filter((t) => t.isOverdue(now)).length
const total = todos.length
const nonArchived = active + completed
const completionRate = nonArchived > 0 ? completed / nonArchived : 0
return {
total,
active,
completed,
archived,
overdue,
byPriority: {
Low: todos.filter((t) => t.priority === "Low").length,
Medium: todos.filter((t) => t.priority === "Medium").length,
High: todos.filter((t) => t.priority === "High").length,
Critical: todos.filter((t) => t.priority === "Critical").length,
},
completionRate,
}
}
// domain/services/todo-validation-service.ts
import { Effect } from "effect"
import type { Todo } from "../entities/todo.js"
import { DuplicateTitleError } from "../errors/todo-errors.js"
/**
* Проверяет уникальность заголовка среди существующих задач.
*
* Чистая доменная функция — принимает данные как параметры,
* не обращается к репозиторию/БД.
*/
export const checkTitleUniqueness = (
existingTodos: ReadonlyArray<Todo>,
newTitle: string
): Effect.Effect<void, DuplicateTitleError> => {
const normalizedTitle = newTitle.trim().toLowerCase()
const isDuplicate = existingTodos.some(
(t) =>
t.title.toLowerCase() === normalizedTitle &&
t.status !== "Archived" // Архивные задачи не учитываются
)
return isDuplicate
? Effect.fail(new DuplicateTitleError({ title: newTitle }))
: Effect.void
}
/**
* Проверяет уникальность заголовка, исключая конкретную задачу.
* Используется при переименовании — задача не должна конфликтовать сама с собой.
*/
export const checkTitleUniquenessExcluding = (
existingTodos: ReadonlyArray<Todo>,
newTitle: string,
excludeTodoId: string
): Effect.Effect<void, DuplicateTitleError> => {
const normalizedTitle = newTitle.trim().toLowerCase()
const isDuplicate = existingTodos.some(
(t) =>
t.id !== excludeTodoId &&
t.title.toLowerCase() === normalizedTitle &&
t.status !== "Archived"
)
return isDuplicate
? Effect.fail(new DuplicateTitleError({ title: newTitle }))
: Effect.void
}
Шаг 7: Структура файлов домена
src/
└── domain/
├── entities/
│ ├── todo.ts ← Todo Entity
│ └── todo-factory.ts ← Фабричная функция
├── value-objects/
│ ├── todo-id.ts ← TodoId
│ ├── todo-title.ts ← TodoTitle (branded)
│ ├── todo-description.ts ← TodoDescription (branded)
│ ├── priority.ts ← Priority + Order + helpers
│ ├── todo-status.ts ← TodoStatus + transitions
│ └── due-date.ts ← DueDate + helpers
├── errors/
│ └── todo-errors.ts ← Все доменные ошибки
├── events/
│ └── todo-events.ts ← Все доменные события
├── services/
│ ├── todo-query-service.ts ← Фильтрация, сортировка, статистика
│ └── todo-validation-service.ts ← Проверки (уникальность и т.д.)
└── index.ts ← Barrel file (публичный API домена)
Barrel file
// domain/index.ts — публичный API домена
// Entities
export { Todo } from "./entities/todo.js"
export { createTodo, type CreateTodoInput } from "./entities/todo-factory.js"
// Value Objects
export { TodoIdBrand, type TodoId, makeTodoId } from "./value-objects/todo-id.js"
export { TodoTitleBrand, type TodoTitle, makeTodoTitle } from "./value-objects/todo-title.js"
export { Priority, PriorityOrder, isHighPriority, isHigherThan } from "./value-objects/priority.js"
export { TodoStatus, canTransition, isTerminal } from "./value-objects/todo-status.js"
// Errors
export {
TodoNotFoundError,
InvalidStatusTransitionError,
DuplicateTitleError,
EmptyTitleError,
TitleTooLongError,
type TodoDomainError,
} from "./errors/todo-errors.js"
// Events
export {
TodoCreated,
TodoCompleted,
TodoArchived,
TodoRenamed,
TodoPriorityChanged,
type TodoEvent,
} from "./events/todo-events.js"
// Services
export {
filterByStatus,
filterByPriority,
filterOverdue,
filterUrgent,
sortByPriority,
sortByCreatedAt,
sortByDueDate,
calculateStats,
type TodoStats,
} from "./services/todo-query-service.js"
export {
checkTitleUniqueness,
checkTitleUniquenessExcluding,
} from "./services/todo-validation-service.js"
Проверка чистоты: что мы имеем
Давайте убедимся, что наш домен чист:
| Проверка | Результат |
|---|---|
Импорты из bun:sqlite? | ❌ Нет |
Импорты из @effect/platform? | ❌ Нет |
| Импорты HTTP-фреймворков? | ❌ Нет |
process.env? | ❌ Нет |
console.log? | ❌ Нет |
new Date() внутри функций? | ❌ Нет (время — параметр) |
fetch / сетевые вызовы? | ❌ Нет |
| R-канал = never? | ✅ Да, все функции чистые |
| Immutable данные? | ✅ Да, Schema.Class |
Только effect в зависимостях? | ✅ Да |
Результат: домен абсолютно чист. Его можно тестировать без инфраструктуры, переносить между платформами, рефакторить без риска.
Пример использования домена в тестах
// __tests__/domain/todo.test.ts
import { describe, it, expect } from "bun:test"
import { Effect, Option } from "effect"
import { Todo } from "../../src/domain/entities/todo.js"
import { createTodo } from "../../src/domain/entities/todo-factory.js"
import { calculateStats } from "../../src/domain/services/todo-query-service.js"
const NOW = new Date("2025-01-15T10:00:00Z")
const makeTodo = (overrides: Partial<Parameters<typeof Todo["make"]>[0]> = {}) =>
new Todo({
id: "test-1",
title: "Test todo",
description: null,
status: "Active",
priority: "Medium",
createdAt: NOW,
completedAt: null,
dueDate: null,
...overrides,
})
describe("Todo Entity", () => {
describe("complete", () => {
it("should complete an active todo", async () => {
const todo = makeTodo()
const result = await Effect.runPromise(todo.complete(NOW))
expect(result.status).toBe("Completed")
expect(Option.isSome(result.completedAt)).toBe(true)
})
it("should reject completing a completed todo", async () => {
const todo = makeTodo({ status: "Completed", completedAt: NOW })
const exit = await Effect.runPromiseExit(todo.complete(NOW))
expect(exit._tag).toBe("Failure")
})
it("should reject completing an archived todo", async () => {
const todo = makeTodo({ status: "Archived" })
const exit = await Effect.runPromiseExit(todo.complete(NOW))
expect(exit._tag).toBe("Failure")
})
})
describe("archive", () => {
it("should archive an active todo", async () => {
const todo = makeTodo()
const result = await Effect.runPromise(todo.archive())
expect(result.status).toBe("Archived")
})
it("should archive a completed todo", async () => {
const todo = makeTodo({ status: "Completed", completedAt: NOW })
const result = await Effect.runPromise(todo.archive())
expect(result.status).toBe("Archived")
})
it("should reject archiving an already archived todo", async () => {
const todo = makeTodo({ status: "Archived" })
const exit = await Effect.runPromiseExit(todo.archive())
expect(exit._tag).toBe("Failure")
})
})
describe("isOverdue", () => {
it("should be overdue when active and past due date", () => {
const todo = makeTodo({
dueDate: new Date("2025-01-10T00:00:00Z"), // 5 дней назад
})
expect(todo.isOverdue(NOW)).toBe(true)
})
it("should not be overdue when completed", () => {
const todo = makeTodo({
status: "Completed",
completedAt: NOW,
dueDate: new Date("2025-01-10T00:00:00Z"),
})
expect(todo.isOverdue(NOW)).toBe(false)
})
it("should not be overdue without due date", () => {
const todo = makeTodo()
expect(todo.isOverdue(NOW)).toBe(false)
})
})
})
describe("TodoStats", () => {
it("should calculate correct stats", () => {
const todos = [
makeTodo({ id: "1", status: "Active", priority: "High" }),
makeTodo({ id: "2", status: "Active", priority: "Low" }),
makeTodo({ id: "3", status: "Completed", priority: "Medium", completedAt: NOW }),
makeTodo({ id: "4", status: "Archived", priority: "Critical" }),
]
const stats = calculateStats(todos, NOW)
expect(stats.total).toBe(4)
expect(stats.active).toBe(2)
expect(stats.completed).toBe(1)
expect(stats.archived).toBe(1)
expect(stats.completionRate).toBeCloseTo(1 / 3)
expect(stats.byPriority.High).toBe(1)
})
})
Обратите внимание: ни одного мока, ни одного стаба, ни одной базы данных. Чистый домен тестируется мгновенно.
Резюме
В этой главе мы создали первую рабочую доменную модель Todo-приложения:
- 6 Value Objects: TodoId, TodoTitle, TodoDescription, Priority, TodoStatus, DueDate
- 1 Entity: Todo с полным набором команд и запросов
- 5 Domain Events: TodoCreated, TodoCompleted, TodoArchived, TodoRenamed, TodoPriorityChanged
- 5 Domain Errors: TodoNotFound, InvalidStatusTransition, DuplicateTitle, EmptyTitle, TitleTooLong
- 2 Domain Services: QueryService (фильтрация, сортировка, статистика) и ValidationService (уникальность)
Весь код — чистый TypeScript + Effect. Нулевые инфраструктурные зависимости. Мгновенная тестируемость. Полная типовая безопасность.
Эта модель будет развиваться в последующих модулях: мы добавим Aggregates (Модуль 15), более сложные Value Objects с Effect Schema (Модуль 11-12), и полноценные Entity с инвариантами (Модуль 13).