Ошибки Todo: TodoNotFound, InvalidTransition, DuplicateTitle
Полная production-ready модель ошибок для Todo-домена. 8 ошибок с контекстом, message, Schema. Статусная машина и валидация переходов. Бизнес-правила для заголовков, дат, приоритетов, лимитов. HTTP-маппинг. Полный пример создания задачи со всеми проверками. Unit-тесты для каждой ошибки.
Введение: ошибки как зеркало бизнес-правил
Каждая доменная ошибка отражает бизнес-правило, которое было нарушено. Если вы не можете объяснить ошибку на языке бизнеса — вероятно, это не доменная ошибка.
Для Todo-домена мы идентифицируем следующие бизнес-правила и соответствующие ошибки:
| Бизнес-правило | Ошибка |
|---|---|
| Задача должна существовать для операций | TodoNotFound |
| Переходы между состояниями ограничены | InvalidStatusTransition |
| Заголовок задачи уникален в списке | DuplicateTodoTitle |
| Заголовок не пуст и не слишком длинный | InvalidTodoTitle |
| Срок выполнения не в прошлом | InvalidDueDate |
| Список задач имеет лимит | TodoListFull |
| Приоритет задачи в допустимом диапазоне | InvalidPriority |
| Нельзя архивировать активную задачу с подзадачами | ActiveSubtasksExist |
Доменные типы (предпосылки)
Прежде чем определять ошибки, зафиксируем доменные типы, на которые ошибки ссылаются:
// domain/types.ts
import { Schema } from "effect"
// --- Branded IDs ---
export const TodoIdSchema = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9-]{1,64}$/),
Schema.brand("TodoId"),
)
export type TodoId = typeof TodoIdSchema.Type
export const TodoListIdSchema = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9-]{1,64}$/),
Schema.brand("TodoListId"),
)
export type TodoListId = typeof TodoListIdSchema.Type
// --- Enums ---
export const TodoStatusSchema = Schema.Literal(
"Active",
"Completed",
"Archived",
)
export type TodoStatus = typeof TodoStatusSchema.Type
export const PrioritySchema = Schema.Literal(
"Critical",
"High",
"Medium",
"Low",
"None",
)
export type Priority = typeof PrioritySchema.Type
Полная модель ошибок
1. TodoNotFound
Бизнес-правило: Для выполнения операции задача должна существовать в системе.
Когда возникает:
- Попытка получить задачу по ID, которого нет
- Попытка обновить/удалить несуществующую задачу
- Ссылка на задачу из другого агрегата
// domain/errors/todo-errors.ts
import { Schema, Option } from "effect"
import { TodoIdSchema, type TodoId } from "../types"
export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
"TodoNotFound",
{
todoId: TodoIdSchema,
}
) {
get message(): string {
return `Todo with id "${this.todoId}" was not found`
}
}
Использование в бизнес-логике:
import { Effect, pipe, Option } from "effect"
// В Entity/Aggregate — при поиске
export const findTodoOrFail = (
todos: ReadonlyArray<Todo>,
id: TodoId,
): Effect.Effect<Todo, TodoNotFound> => {
const found = todos.find(t => t.id === id)
return found !== undefined
? Effect.succeed(found)
: Effect.fail(new TodoNotFound({ todoId: id }))
}
// В Repository (порт)
export interface TodoRepository {
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}
// В Use Case
export const getTodo = (id: TodoId) =>
pipe(
TodoRepository.pipe(
Effect.flatMap(repo => repo.findById(id))
),
// Можно обогатить контекстом
Effect.tapError(() =>
Effect.logWarning("Requested todo not found", { todoId: id })
),
)
2. InvalidStatusTransition
Бизнес-правило: Задача имеет конечный автомат состояний. Не все переходы допустимы.
┌─────────────┐
│ Active │
└──────┬───────┘
│
┌────────┼────────┐
▼ │ ▼
┌───────────┐ │ ┌──────────┐
│ Completed │ │ │ Archived │
└─────┬─────┘ │ └────┬─────┘
│ │ │
└──────────┼────────┘
│
(обратно в Active)
Допустимые переходы:
Active → Completed✅Active → Archived✅Completed → Active✅ (reopened)Completed → Archived✅Archived → Active✅ (restored)Completed → Completed❌Archived → Completed❌Archived → Archived❌
import { Schema } from "effect"
import { TodoIdSchema, TodoStatusSchema, type TodoId, type TodoStatus } from "../types"
export class InvalidStatusTransition extends Schema.TaggedError<InvalidStatusTransition>()(
"InvalidStatusTransition",
{
todoId: TodoIdSchema,
currentStatus: TodoStatusSchema,
targetStatus: TodoStatusSchema,
allowedTransitions: Schema.Array(TodoStatusSchema),
}
) {
get message(): string {
const allowed = this.allowedTransitions.length > 0
? this.allowedTransitions.join(", ")
: "none"
return (
`Cannot transition todo "${this.todoId}" ` +
`from "${this.currentStatus}" to "${this.targetStatus}". ` +
`Allowed transitions from "${this.currentStatus}": [${allowed}]`
)
}
}
Использование в бизнес-логике:
// domain/todo-status-machine.ts
import { Effect } from "effect"
// Статусная машина как ReadonlyMap (иммутабельна)
const STATUS_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlyArray<TodoStatus>> = new Map([
["Active", ["Completed", "Archived"] as const],
["Completed", ["Active", "Archived"] as const],
["Archived", ["Active"] as const],
] as const)
// Валидация перехода — чистая функция
export const validateTransition = (
todoId: TodoId,
currentStatus: TodoStatus,
targetStatus: TodoStatus,
): Effect.Effect<TodoStatus, InvalidStatusTransition> => {
const allowed = STATUS_TRANSITIONS.get(currentStatus) ?? []
if (allowed.includes(targetStatus)) {
return Effect.succeed(targetStatus)
}
return Effect.fail(new InvalidStatusTransition({
todoId,
currentStatus,
targetStatus,
allowedTransitions: allowed,
}))
}
// Использование в Entity
export const completeTodo = (
todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
pipe(
validateTransition(todo.id, todo.status, "Completed"),
Effect.map(newStatus => ({
...todo,
status: newStatus,
completedAt: new Date(),
updatedAt: new Date(),
})),
)
export const archiveTodo = (
todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
pipe(
validateTransition(todo.id, todo.status, "Archived"),
Effect.map(newStatus => ({
...todo,
status: newStatus,
archivedAt: new Date(),
updatedAt: new Date(),
})),
)
export const reopenTodo = (
todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
pipe(
validateTransition(todo.id, todo.status, "Active"),
Effect.map(newStatus => ({
...todo,
status: newStatus,
completedAt: undefined,
archivedAt: undefined,
updatedAt: new Date(),
})),
)
3. DuplicateTodoTitle
Бизнес-правило: В пределах одного TodoList заголовки задач должны быть уникальными.
import { Schema, Option } from "effect"
import { TodoIdSchema } from "../types"
export class DuplicateTodoTitle extends Schema.TaggedError<DuplicateTodoTitle>()(
"DuplicateTodoTitle",
{
title: Schema.String,
existingTodoId: Schema.OptionFromNullOr(TodoIdSchema),
}
) {
get message(): string {
return Option.match(this.existingTodoId, {
onNone: () => `A todo with title "${this.title}" already exists`,
onSome: (id) => `A todo with title "${this.title}" already exists (id: ${id})`,
})
}
}
Использование в бизнес-логике:
// domain/todo-list.ts (Aggregate)
export const checkTitleUniqueness = (
todos: ReadonlyArray<Todo>,
title: string,
excludeId?: TodoId,
): Effect.Effect<void, DuplicateTodoTitle> => {
const duplicate = todos.find(
t => t.title.toLowerCase() === title.toLowerCase() && t.id !== excludeId
)
return duplicate !== undefined
? Effect.fail(new DuplicateTodoTitle({
title,
existingTodoId: Option.some(duplicate.id),
}))
: Effect.void
}
4. InvalidTodoTitle
Бизнес-правило: Заголовок задачи должен быть непустым и не превышать 200 символов.
export class InvalidTodoTitle extends Schema.TaggedError<InvalidTodoTitle>()(
"InvalidTodoTitle",
{
title: Schema.String,
reason: Schema.Literal("empty", "too_long", "invalid_characters"),
maxLength: Schema.Number,
}
) {
get message(): string {
switch (this.reason) {
case "empty":
return "Todo title cannot be empty"
case "too_long":
return `Todo title exceeds maximum length of ${this.maxLength} characters (got ${this.title.length})`
case "invalid_characters":
return `Todo title contains invalid characters`
}
}
}
Использование:
const MAX_TITLE_LENGTH = 200
export const validateTodoTitle = (
title: string,
): Effect.Effect<string, InvalidTodoTitle> => {
const trimmed = title.trim()
if (trimmed.length === 0) {
return Effect.fail(new InvalidTodoTitle({
title,
reason: "empty",
maxLength: MAX_TITLE_LENGTH,
}))
}
if (trimmed.length > MAX_TITLE_LENGTH) {
return Effect.fail(new InvalidTodoTitle({
title,
reason: "too_long",
maxLength: MAX_TITLE_LENGTH,
}))
}
// Проверка на недопустимые символы (например, control characters)
if (/[\x00-\x1f\x7f]/.test(trimmed)) {
return Effect.fail(new InvalidTodoTitle({
title,
reason: "invalid_characters",
maxLength: MAX_TITLE_LENGTH,
}))
}
return Effect.succeed(trimmed)
}
5. InvalidDueDate
Бизнес-правило: Срок выполнения задачи не может быть в прошлом. Максимум — 5 лет вперёд.
export class InvalidDueDate extends Schema.TaggedError<InvalidDueDate>()(
"InvalidDueDate",
{
date: Schema.DateFromString,
reason: Schema.Literal("past_date", "too_far_future"),
}
) {
get message(): string {
switch (this.reason) {
case "past_date":
return `Due date ${this.date.toISOString()} is in the past`
case "too_far_future":
return `Due date ${this.date.toISOString()} is more than 5 years in the future`
}
}
}
Использование:
const MAX_FUTURE_YEARS = 5
export const validateDueDate = (
date: Date,
now: Date = new Date(),
): Effect.Effect<Date, InvalidDueDate> => {
if (date.getTime() < now.getTime()) {
return Effect.fail(new InvalidDueDate({ date, reason: "past_date" }))
}
const maxFuture = new Date(now)
maxFuture.setFullYear(maxFuture.getFullYear() + MAX_FUTURE_YEARS)
if (date.getTime() > maxFuture.getTime()) {
return Effect.fail(new InvalidDueDate({ date, reason: "too_far_future" }))
}
return Effect.succeed(date)
}
6. TodoListFull
Бизнес-правило: Список задач имеет лимит (по умолчанию 1000 задач).
export class TodoListFull extends Schema.TaggedError<TodoListFull>()(
"TodoListFull",
{
listId: TodoListIdSchema,
maxSize: Schema.Number,
currentSize: Schema.Number,
}
) {
get message(): string {
return (
`Todo list "${this.listId}" is full. ` +
`Maximum: ${this.maxSize}, current: ${this.currentSize}`
)
}
}
7. InvalidPriority
Бизнес-правило: Приоритет задачи должен быть из допустимого набора.
export class InvalidPriority extends Schema.TaggedError<InvalidPriority>()(
"InvalidPriority",
{
value: Schema.String,
allowedValues: Schema.Array(PrioritySchema),
}
) {
get message(): string {
return `Invalid priority "${this.value}". Allowed: ${this.allowedValues.join(", ")}`
}
}
8. ActiveSubtasksExist
Бизнес-правило: Нельзя архивировать задачу, у которой есть незавершённые подзадачи.
export class ActiveSubtasksExist extends Schema.TaggedError<ActiveSubtasksExist>()(
"ActiveSubtasksExist",
{
todoId: TodoIdSchema,
activeSubtaskIds: Schema.Array(TodoIdSchema),
}
) {
get message(): string {
return (
`Cannot archive todo "${this.todoId}": ` +
`${this.activeSubtaskIds.length} active subtask(s) exist`
)
}
}
Union-тип и Schema для всех ошибок
// domain/errors/index.ts
export {
TodoNotFound,
InvalidStatusTransition,
InvalidTodoTitle,
DuplicateTodoTitle,
InvalidDueDate,
TodoListFull,
InvalidPriority,
ActiveSubtasksExist,
} from "./todo-errors"
import { Schema } from "effect"
// Union тип для TypeScript
export type TodoDomainError =
| TodoNotFound
| InvalidStatusTransition
| InvalidTodoTitle
| DuplicateTodoTitle
| InvalidDueDate
| TodoListFull
| InvalidPriority
| ActiveSubtasksExist
// Schema для сериализации
export const TodoDomainErrorSchema = Schema.Union(
TodoNotFound,
InvalidStatusTransition,
InvalidTodoTitle,
DuplicateTodoTitle,
InvalidDueDate,
TodoListFull,
InvalidPriority,
ActiveSubtasksExist,
)
Полный пример: создание задачи
Соберём все ошибки в одном бизнес-сценарии:
// domain/todo-list.ts (Aggregate)
import { Effect, pipe, Option } from "effect"
const DEFAULT_MAX_SIZE = 1000
export const addTodo = (
list: TodoList,
title: string,
dueDate: Date | undefined,
priority: Priority,
): Effect.Effect<
TodoList,
| InvalidTodoTitle
| DuplicateTodoTitle
| TodoListFull
| InvalidDueDate
| InvalidPriority
> =>
pipe(
// 1. Валидация заголовка
validateTodoTitle(title),
// 2. Проверка уникальности
Effect.flatMap(validTitle =>
pipe(
checkTitleUniqueness(list.todos, validTitle),
Effect.map(() => validTitle),
)
),
// 3. Проверка лимита
Effect.flatMap(validTitle => {
if (list.todos.length >= (list.maxSize ?? DEFAULT_MAX_SIZE)) {
return Effect.fail(new TodoListFull({
listId: list.id,
maxSize: list.maxSize ?? DEFAULT_MAX_SIZE,
currentSize: list.todos.length,
}))
}
return Effect.succeed(validTitle)
}),
// 4. Валидация даты (если указана)
Effect.flatMap(validTitle =>
dueDate !== undefined
? pipe(
validateDueDate(dueDate),
Effect.map(validDate => ({ title: validTitle, dueDate: validDate })),
)
: Effect.succeed({ title: validTitle, dueDate: undefined }),
),
// 5. Создание задачи
Effect.map(({ title: validTitle, dueDate: validDueDate }) => {
const newTodo: Todo = {
id: generateTodoId(),
title: validTitle,
status: "Active" as const,
priority,
dueDate: validDueDate,
createdAt: new Date(),
updatedAt: new Date(),
}
return {
...list,
todos: [...list.todos, newTodo] as const,
updatedAt: new Date(),
}
}),
)
HTTP-маппинг ошибок
// infrastructure/adapters/http/error-mapper.ts
import { Effect } from "effect"
const TODO_ERROR_HTTP_MAP: Record<TodoDomainError["_tag"], number> = {
TodoNotFound: 404,
InvalidStatusTransition: 409,
InvalidTodoTitle: 400,
DuplicateTodoTitle: 409,
InvalidDueDate: 400,
TodoListFull: 422,
InvalidPriority: 400,
ActiveSubtasksExist: 409,
} as const
export const todoErrorToHttp = (error: TodoDomainError) =>
Effect.succeed({
status: TODO_ERROR_HTTP_MAP[error._tag],
body: {
error: error._tag,
message: error.message,
// Добавляем контекстные данные в зависимости от типа ошибки
...serializeErrorContext(error),
},
})
const serializeErrorContext = (error: TodoDomainError): Record<string, unknown> => {
switch (error._tag) {
case "TodoNotFound":
return { todoId: error.todoId }
case "InvalidStatusTransition":
return {
currentStatus: error.currentStatus,
targetStatus: error.targetStatus,
allowedTransitions: error.allowedTransitions,
}
case "InvalidTodoTitle":
return { reason: error.reason, maxLength: error.maxLength }
case "DuplicateTodoTitle":
return { title: error.title }
case "InvalidDueDate":
return { reason: error.reason }
case "TodoListFull":
return { maxSize: error.maxSize, currentSize: error.currentSize }
case "InvalidPriority":
return { allowedValues: error.allowedValues }
case "ActiveSubtasksExist":
return { activeSubtaskCount: error.activeSubtaskIds.length }
}
}
Тестирование ошибок
Unit-тесты для каждой ошибки
// domain/__tests__/errors.test.ts
import { describe, it, expect } from "bun:test"
import { Effect, Exit, Cause, Option, Schema, Equal } from "effect"
describe("TodoNotFound", () => {
it("should create with todoId", () => {
const error = new TodoNotFound({ todoId: "abc-123" as TodoId })
expect(error._tag).toBe("TodoNotFound")
expect(error.todoId).toBe("abc-123")
expect(error.message).toContain("abc-123")
})
it("should support structural equality", () => {
const e1 = new TodoNotFound({ todoId: "abc" as TodoId })
const e2 = new TodoNotFound({ todoId: "abc" as TodoId })
expect(Equal.equals(e1, e2)).toBe(true)
})
it("should encode/decode via Schema", () => {
const error = new TodoNotFound({ todoId: "abc-123" as TodoId })
const encoded = Schema.encodeSync(TodoNotFound)(error)
expect(encoded._tag).toBe("TodoNotFound")
expect(encoded.todoId).toBe("abc-123")
const decoded = Schema.decodeSync(TodoNotFound)(encoded)
expect(Equal.equals(decoded, error)).toBe(true)
})
})
describe("InvalidStatusTransition", () => {
it("should list allowed transitions in message", () => {
const error = new InvalidStatusTransition({
todoId: "abc" as TodoId,
currentStatus: "Archived",
targetStatus: "Completed",
allowedTransitions: ["Active"],
})
expect(error.message).toContain("Active")
expect(error.message).toContain("Archived")
expect(error.message).toContain("Completed")
})
})
describe("validateTransition", () => {
it("should allow Active → Completed", async () => {
const result = await Effect.runPromiseExit(
validateTransition("abc" as TodoId, "Active", "Completed")
)
expect(Exit.isSuccess(result)).toBe(true)
})
it("should reject Archived → Completed", async () => {
const result = await Effect.runPromiseExit(
validateTransition("abc" as TodoId, "Archived", "Completed")
)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.failureOption(result.cause)
expect(Option.isSome(error)).toBe(true)
if (Option.isSome(error)) {
expect(error.value._tag).toBe("InvalidStatusTransition")
expect(error.value.allowedTransitions).toEqual(["Active"])
}
}
})
})
describe("validateTodoTitle", () => {
it("should reject empty title", async () => {
const result = await Effect.runPromiseExit(validateTodoTitle(""))
expect(Exit.isFailure(result)).toBe(true)
})
it("should reject too long title", async () => {
const longTitle = "a".repeat(201)
const result = await Effect.runPromiseExit(validateTodoTitle(longTitle))
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.failureOption(result.cause)
if (Option.isSome(error)) {
expect(error.value.reason).toBe("too_long")
}
}
})
it("should accept valid title", async () => {
const result = await Effect.runPromiseExit(validateTodoTitle("Buy groceries"))
expect(Exit.isSuccess(result)).toBe(true)
})
})
Резюме: карта ошибок Todo-домена
TodoDomainError
├── TodoNotFound → 404 Not Found
│ todoId: TodoId
│
├── InvalidStatusTransition → 409 Conflict
│ todoId, currentStatus, targetStatus, allowedTransitions
│
├── InvalidTodoTitle → 400 Bad Request
│ title, reason, maxLength
│
├── DuplicateTodoTitle → 409 Conflict
│ title, existingTodoId
│
├── InvalidDueDate → 400 Bad Request
│ date, reason
│
├── TodoListFull → 422 Unprocessable Entity
│ listId, maxSize, currentSize
│
├── InvalidPriority → 400 Bad Request
│ value, allowedValues
│
└── ActiveSubtasksExist → 409 Conflict
todoId, activeSubtaskIds
Каждая ошибка:
- Имеет уникальный
_tagдля pattern matching - Содержит полный бизнес-контекст
- Имеет человекочитаемый
message - Поддерживает Schema encode/decode
- Использует доменные типы (TodoId, TodoStatus, Priority)
- Покрыта unit-тестами