Типобезопасный домен: Гексагональная архитектура на базе Effect Чистота домена: нулевые зависимости от инфраструктуры
Глава

Чистота домена: нулевые зависимости от инфраструктуры

Принцип нулевых зависимостей, паттерны загрязнения домена (прямой импорт, утечка типов, глобальное состояние, побочные эффекты), как Effect-ts обеспечивает чистоту через R-канал и E-канал, допустимые зависимости, структурное обеспечение чистоты (tsconfig, ESLint, архитектурные тесты, отдельный package.json), Dependency Inversion в домене, обработка времени и случайности, чеклист чистоты

Принцип нулевых зависимостей

Чистота домена — это не эстетический выбор и не перфекционизм. Это архитектурный принцип, от соблюдения которого зависит долгосрочная жизнеспособность системы. Принцип формулируется просто:

Доменный слой не должен иметь ни одного импорта из инфраструктурного слоя.

Ни одного. Ноль. Это абсолютное правило, не имеющее исключений.

В терминах модулей TypeScript это означает: файлы в директории domain/ никогда не содержат import из adapters/, infrastructure/, и не используют библиотеки, привязанные к конкретной технологии (express, sqlite, node:fs, и т.д.).

domain/
├── entities/
│   └── todo.ts          ← импортирует ТОЛЬКО из domain/ и effect
├── value-objects/
│   └── priority.ts      ← импортирует ТОЛЬКО из domain/ и effect
├── errors/
│   └── todo-errors.ts   ← импортирует ТОЛЬКО из domain/ и effect
└── events/
    └── todo-events.ts   ← импортирует ТОЛЬКО из domain/ и effect

❌ ЗАПРЕЩЕНО в любом файле domain/:
import { Database } from "bun:sqlite"
import { HttpServer } from "@effect/platform"
import { Router } from "express"
import { readFile } from "node:fs/promises"

Зачем нужна чистота домена

1. Долговечность бизнес-логики

Технологии устаревают быстро. Express, Koa, Hono, Fastify — каждые несколько лет появляется новый «лучший» HTTP-фреймворк. Базы данных, облачные провайдеры, протоколы обмена — всё это меняется.

Бизнес-правила меняются медленно. «Задача имеет приоритет» — это правило, которое будет актуально через 10 лет. Если бизнес-логика перемешана с инфраструктурой, то при замене фреймворка придётся переписывать и бизнес-правила. Это дорого, рискованно и абсолютно не нужно.

Стоимость замены технологии:

Чистый домен:              Загрязнённый домен:
┌─────────────┐            ┌─────────────────────┐
│ Заменить    │            │ Переписать ВСЁ:     │
│ ТОЛЬКО      │            │ бизнес-логику +     │
│ адаптер     │            │ инфраструктуру +    │
│             │            │ тесты +             │
│ Стоимость:  │            │ перетестировать      │
│ НИЗКАЯ      │            │                     │
│ Риск: НИЗКИЙ│            │ Стоимость: ВЫСОКАЯ  │
└─────────────┘            │ Риск: ВЫСОКИЙ       │
                           └─────────────────────┘

2. Тестируемость без инфраструктуры

Чистый домен тестируется мгновенно. Не нужно поднимать базу данных, запускать HTTP-сервер, мокать файловую систему. Чистые функции + чистые данные = простейшие тесты.

import { describe, it, expect } from "bun:test"
import { Effect } from "effect"

describe("Todo.complete", () => {
  it("should complete an active todo", async () => {
    const todo = new Todo({
      id: new TodoId({ value: "1" as TodoId["value"] }),
      title: "Write tests",
      status: "Active",
      priority: "High",
      createdAt: new Date(),
      completedAt: null,
    })

    const result = await Effect.runPromise(todo.complete())

    expect(result.status).toBe("Completed")
    expect(result.completedAt).toBeDefined()
  })

  it("should reject completing a completed todo", async () => {
    const todo = new Todo({
      id: new TodoId({ value: "1" as TodoId["value"] }),
      title: "Done task",
      status: "Completed",
      priority: "Low",
      createdAt: new Date(),
      completedAt: new Date(),
    })

    const result = await Effect.runPromiseExit(todo.complete())

    expect(result._tag).toBe("Failure")
  })
})

// Этот тест выполняется за <1 мс — нет IO, нет сети, нет БД

3. Переносимость между контекстами

Чистый домен можно использовать в любом контексте: на сервере, в CLI, в WebWorker, даже в браузере. Он не привязан к конкретной среде выполнения.

// Один и тот же доменный код работает:

// 1. В HTTP-сервере (Bun + Effect HttpServer)
// 2. В CLI-утилите (Effect CLI)
// 3. В тестах (bun:test)
// 4. В seed-скриптах (генерация тестовых данных)
// 5. В Worker потоках
// 6. В браузере (если нужно)

// Потому что домен — это ЧИСТЫЙ TypeScript + Effect
// Без привязки к runtime-среде

4. Понятность для новых разработчиков

Когда новый разработчик открывает domain/entities/todo.ts, он видит только бизнес-логику. Не SQL, не HTTP, не конфигурацию сервера. Только правила предметной области. Это радикально снижает когнитивную нагрузку.

Как нарушается чистота: паттерны загрязнения

Паттерн 1: Прямой импорт инфраструктуры

Самый очевидный вид нарушения — прямой импорт инфраструктурной библиотеки в доменном файле.

// domain/entities/todo.ts

// ❌ НАРУШЕНИЕ: прямой импорт инфраструктуры
import { Database } from "bun:sqlite"

class Todo {
  async save(): Promise<void> {
    const db = new Database("app.db")
    db.run("INSERT INTO todos VALUES (?)", [this.id])
  }
}

Почему это плохо: Теперь Todo намертво привязан к SQLite. Нельзя протестировать без базы, нельзя заменить на PostgreSQL, нельзя использовать в браузере.

Паттерн 2: Утечка типов инфраструктуры

Более коварный вид нарушения — когда доменная модель использует типы из инфраструктурных библиотек.

// ❌ НАРУШЕНИЕ: инфраструктурный тип в доменной модели
import { Statement } from "bun:sqlite"

interface TodoRepository {
  // Statement — это тип SQLite, он протекает в домен
  prepareFind(): Statement
}

// ❌ НАРУШЕНИЕ: тип Express Request в домене
import { Request } from "express"

const createTodoFromRequest = (req: Request): Todo => {
  return new Todo(req.body)
}

Почему это плохо: Даже если вы не вызываете инфраструктурный код напрямую, зависимость от его типов привязывает домен к конкретной технологии.

Паттерн 3: Неявная зависимость через глобальное состояние

// ❌ НАРУШЕНИЕ: обращение к глобальному состоянию
class Todo {
  isOverdue(): boolean {
    // process.env — это инфраструктурная зависимость
    const gracePeriod = parseInt(process.env.GRACE_PERIOD_DAYS ?? "0")
    const deadline = new Date(this.dueDate)
    deadline.setDate(deadline.getDate() + gracePeriod)
    return new Date() > deadline
  }
}

// ✅ ПРАВИЛЬНО: все зависимости — явные параметры
const isOverdue = (
  todo: Todo,
  now: Date,
  gracePeriodDays: number
): boolean => {
  const deadline = new Date(todo.dueDate)
  deadline.setDate(deadline.getDate() + gracePeriodDays)
  return now > deadline
}

Паттерн 4: Доменные события с инфраструктурными деталями

// ❌ НАРУШЕНИЕ: событие содержит инфраструктурные детали
class TodoCreated {
  constructor(
    public readonly todoId: string,
    public readonly title: string,
    public readonly sqlRowId: number,        // ❌ деталь SQLite
    public readonly httpRequestId: string,   // ❌ деталь HTTP
    public readonly userAgent: string,       // ❌ деталь HTTP
  ) {}
}

// ✅ ПРАВИЛЬНО: событие содержит ТОЛЬКО доменные данные
class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    todoId: TodoId,
    title: Schema.String,
    priority: Schema.Literal("Low", "Medium", "High", "Critical"),
    occurredAt: Schema.DateFromSelf,
  }
) {}

Паттерн 5: Валидация с побочными эффектами

// ❌ НАРУШЕНИЕ: валидация обращается к БД
class Todo {
  static async create(title: string): Promise<Todo> {
    // Проверка уникальности через запрос к БД
    const existing = await db.query(
      "SELECT COUNT(*) FROM todos WHERE title = ?", [title]
    )
    if (existing > 0) throw new Error("Duplicate title")
    return new Todo({ title })
  }
}

// ✅ ПРАВИЛЬНО: домен проверяет уникальность
// на переданных данных, а не через запрос к БД
const checkTitleUniqueness = (
  existingTitles: ReadonlyArray<string>,
  newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
  existingTitles.some(
    (t) => t.toLowerCase() === newTitle.toLowerCase()
  )
    ? Effect.fail(new DuplicateTitleError({ title: newTitle }))
    : Effect.void

// Application Layer отвечает за получение existingTitles из репозитория
// и передачу их в доменную функцию

Как Effect-ts обеспечивает чистоту

Effect-ts предоставляет мощные механизмы для структурного обеспечения чистоты домена. Не через конвенции и дисциплину, а через типовую систему.

R-канал как гарантия зависимостей

В Effect Effect<A, E, R> тип R (Requirements) явно показывает все зависимости функции. Если доменная функция имеет R = never — она не требует внешних зависимостей.

// Чистая доменная функция: R = never
// Нет внешних зависимостей — это видно из типа!
const completeTodo: (
  todo: Todo
) => Effect.Effect<Todo, InvalidStatusTransitionError>
//                                                    ^ R = never (опущен)

// Application функция: R = TodoRepository
// Есть зависимость от репозитория — это видно из типа!
const completeTodoUseCase: (
  todoId: string
) => Effect.Effect<Todo, TodoNotFoundError | InvalidStatusTransitionError, TodoRepository>
//                                                                          ^ R = TodoRepository

Правило: Все функции в доменном слое должны иметь R = never. Если функция требует R ≠ never — она принадлежит Application Layer или инфраструктуре.

// Доменный слой — R всегда never:
declare namespace DomainLayer {
  // ✅ R = never — чистая доменная логика
  const create: (input: CreateInput) => Effect.Effect<Todo, ValidationError>
  const complete: (todo: Todo) => Effect.Effect<Todo, TransitionError>
  const calculateScore: (todo: Todo) => Effect.Effect<number>

  // ❌ R ≠ never — это НЕ доменная функция
  const save: (todo: Todo) => Effect.Effect<void, DbError, TodoRepository>
}

E-канал как доменный контракт ошибок

Канал ошибок E в доменных функциях содержит только доменные ошибки. Никаких SqliteError, HttpError, TimeoutError.

// ✅ Доменные ошибки — часть домена
type CreateTodoDomainError =
  | EmptyTitleError
  | TitleTooLongError
  | DuplicateTitleError
  | TodoListFullError

// ❌ Инфраструктурные ошибки — НЕ часть домена
type InfrastructureError =
  | SqliteError
  | ConnectionTimeoutError
  | FileNotFoundError
  | HttpError

Schema как граница валидации

Schema из Effect обеспечивает валидацию на уровне типов, без обращения к внешним системам.

import { Schema } from "effect"

// Доменная валидация через Schema — чистая, без IO
const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1, {
    message: () => "Title cannot be empty"
  }),
  Schema.maxLength(255, {
    message: () => "Title cannot exceed 255 characters"
  }),
  Schema.brand("TodoTitle")
)

type TodoTitle = Schema.Schema.Type<typeof TodoTitle>

// Валидация — это чистая функция decode
const validateTitle = Schema.decodeUnknown(TodoTitle)
// Effect<TodoTitle, ParseError>  ← R = never!

Допустимые зависимости доменного слоя

Не все внешние зависимости запрещены. Вот что допустимо импортировать в доменном коде:

1. Effect core (effect)

// ✅ Основная библиотека Effect — это фундамент
import { Effect, Data, Schema, Option, Either, pipe, Array } from "effect"

Effect — это не инфраструктурная библиотека, а язык выражения вычислений. Он не привязывает вас к конкретной технологии. Effect<A, E, R> — это описание вычисления, а не его выполнение.

2. Другие доменные модули

// ✅ Импорт из других частей домена
import { TodoId } from "../value-objects/todo-id.js"
import { Priority } from "../value-objects/priority.js"
import { InvalidStatusTransitionError } from "../errors/todo-errors.js"

3. Стандартные типы TypeScript

// ✅ Стандартные типы — Date, Map, Set, RegExp, etc.
const isOverdue = (dueDate: Date, now: Date): boolean =>
  now > dueDate

// ✅ ReadonlyArray, ReadonlyMap, ReadonlySet
const activeTodos = (todos: ReadonlyArray<Todo>): ReadonlyArray<Todo> =>
  todos.filter((t) => t.status === "Active")

4. Чистые утилитарные библиотеки (с осторожностью)

// ⚠️ Допустимо, но с осторожностью — только чистые утилиты
// Убедитесь, что библиотека не тянет IO-зависимости
import { v4 as uuid } from "uuid"  // ⚠️ генерация ID — граничный случай

Примечание о генерации ID: Генерация UUID технически является побочным эффектом (использует источник энтропии). В строго чистом подходе ID генерируется вне домена (в Application Layer) и передаётся в доменную функцию как параметр:

// Строго чистый подход: ID приходит извне
const createTodo = (
  id: TodoId,  // ← ID сгенерирован в Application Layer
  title: string,
  priority: Priority
): Effect.Effect<Todo, ValidationError> => { /* ... */ }

// Application Layer генерирует ID через Effect.sync
const createTodoUseCase = (input: CreateInput) =>
  pipe(
    Effect.sync(() => crypto.randomUUID()),
    Effect.map((id) => new TodoId({ value: id as TodoId["value"] })),
    Effect.flatMap((id) => createTodo(id, input.title, input.priority))
  )

Структурное обеспечение чистоты

Конвенции — это хорошо, но они нарушаются. Как структурно гарантировать чистоту домена?

Подход 1: Строгие пути в tsconfig

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@domain/*": ["./src/domain/*"],
      "@app/*": ["./src/app/*"],
      "@infra/*": ["./src/infra/*"],
      "@adapters/*": ["./src/adapters/*"]
    }
  }
}

При code review проверяйте: файлы в @domain/ не импортируют @infra/ и @adapters/.

Подход 2: ESLint правило import/no-restricted-paths

// .eslintrc.json
{
  "rules": {
    "import/no-restricted-paths": ["error", {
      "zones": [
        {
          "target": "./src/domain",
          "from": "./src/adapters",
          "message": "Domain cannot import from adapters"
        },
        {
          "target": "./src/domain",
          "from": "./src/infrastructure",
          "message": "Domain cannot import from infrastructure"
        },
        {
          "target": "./src/domain",
          "from": "./src/app",
          "message": "Domain cannot import from application layer"
        }
      ]
    }]
  }
}

Подход 3: Отдельный package.json для домена

Наиболее строгий подход — вынести домен в отдельный пакет с собственным package.json, где явно указаны только разрешённые зависимости:

packages/
  domain/
    package.json    ← только "effect" в dependencies
    src/
      entities/
      value-objects/
      errors/
      events/
  app/
    package.json    ← зависит от @myapp/domain
    src/
  infrastructure/
    package.json    ← зависит от @myapp/domain, bun:sqlite, etc.
    src/
// packages/domain/package.json
{
  "name": "@myapp/domain",
  "dependencies": {
    "effect": "^3.x"
    // И ВСЁ. Больше ничего.
  }
}

Теперь если кто-то попытается import { Database } from "bun:sqlite" в доменном коде, пакет просто не скомпилируетсяbun:sqlite нет в зависимостях.

Подход 4: Архитектурные тесты

Можно написать тест, который автоматически проверяет чистоту импортов:

import { describe, it, expect } from "bun:test"
import { readdir, readFile } from "node:fs/promises"
import { join } from "node:path"

const DOMAIN_DIR = join(import.meta.dir, "../src/domain")

const FORBIDDEN_IMPORTS = [
  "bun:sqlite",
  "@effect/platform",
  "express",
  "node:fs",
  "node:http",
  "node:net",
] as const

const getAllTsFiles = async (dir: string): Promise<ReadonlyArray<string>> => {
  const entries = await readdir(dir, { recursive: true })
  return entries.filter((f) => f.endsWith(".ts"))
}

describe("Domain Purity", () => {
  it("should not import infrastructure modules", async () => {
    const files = await getAllTsFiles(DOMAIN_DIR)
    const violations: Array<string> = []

    for (const file of files) {
      const content = await readFile(join(DOMAIN_DIR, file), "utf-8")
      for (const forbidden of FORBIDDEN_IMPORTS) {
        if (content.includes(`from "${forbidden}"`)) {
          violations.push(`${file} imports "${forbidden}"`)
        }
        if (content.includes(`from '${forbidden}'`)) {
          violations.push(`${file} imports '${forbidden}'`)
        }
      }
    }

    expect(violations).toEqual([])
  })

  it("domain functions should have R = never", async () => {
    // Этот тест проверяется компилятором TypeScript
    // Если доменная функция требует R ≠ never,
    // она не скомпилируется при использовании без provide
  })
})

Паттерн: Dependency Inversion в домене

Иногда доменная логика действительно нуждается в информации из внешнего мира. Например, проверка уникальности заголовка требует знания о существующих задачах. Как быть?

Ответ: Dependency Inversion. Домен определяет порт (контракт), а не реализацию. Данные приходят извне через параметры.

Стратегия 1: Передача данных как параметров

Самая простая стратегия — доменная функция принимает уже загруженные данные:

// Домен: чистая функция, принимающая данные
const checkTitleUniqueness = (
  existingTitles: ReadonlyArray<string>,
  newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
  existingTitles.some(
    (t) => t.toLowerCase() === newTitle.toLowerCase()
  )
    ? Effect.fail(new DuplicateTitleError({ title: newTitle }))
    : Effect.void

// Application Layer: загружает данные и передаёт в домен
const createTodoUseCase = (input: CreateInput) =>
  pipe(
    // 1. Загрузить существующие задачи (через порт)
    TodoRepository.findAllTitles(),
    // 2. Передать в доменную функцию
    Effect.flatMap((titles) =>
      checkTitleUniqueness(titles, input.title)
    ),
    // 3. Если уникален — создать
    Effect.flatMap(() => buildTodo(input))
  )

Стратегия 2: Доменный порт как параметр

Для более сложных случаев домен может определить интерфейс (порт), который передаётся как параметр:

// Домен определяет контракт — что ему нужно
interface TodoTitleChecker {
  readonly isUnique: (title: string) => Effect.Effect<boolean>
}

// Доменная функция принимает контракт как параметр
const createTodo = (
  checker: TodoTitleChecker,
  input: CreateInput
): Effect.Effect<Todo, DuplicateTitleError | ValidationError> =>
  pipe(
    checker.isUnique(input.title),
    Effect.flatMap((unique) =>
      unique
        ? buildTodo(input)
        : Effect.fail(new DuplicateTitleError({ title: input.title }))
    )
  )

Стратегия 3: Effect Service (для Application Layer)

В Application Layer используется полноценный Effect.Service. Но в самом домене мы стараемся обходиться стратегиями 1 и 2, чтобы сохранить R = never.

// Это уже Application Layer, не домен!
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly save: (todo: Todo) => Effect.Effect<void>
  }
>() {}

// Use Case в Application Layer — R = TodoRepository
const completeTodoUseCase = (
  todoId: string
): Effect.Effect<Todo, TodoNotFoundError | InvalidStatusTransitionError, TodoRepository> =>
  pipe(
    TodoRepository,
    Effect.flatMap((repo) => repo.findById(new TodoId({ value: todoId }))),
    Effect.flatMap((todo) => todo.complete()),  // ← чистая доменная логика
    Effect.tap((updated) =>
      pipe(
        TodoRepository,
        Effect.flatMap((repo) => repo.save(updated))
      )
    )
  )

Чистота домена и время/случайность

Особый случай — операции, зависящие от текущего времени или случайных значений. Формально new Date() и Math.random() — побочные эффекты. Как с этим быть?

Подход: Время и случайность как параметры

// ❌ Нечистая функция — зависит от глобального состояния
const isOverdue = (todo: Todo): boolean =>
  new Date() > todo.dueDate  // new Date() — побочный эффект!

// ✅ Чистая функция — время передаётся как параметр
const isOverdue = (todo: Todo, now: Date): boolean =>
  now > todo.dueDate

// ✅ Чистая функция — ID передаётся как параметр
const createTodo = (
  id: TodoId,  // ← сгенерирован снаружи
  title: string,
  now: Date     // ← текущее время передано снаружи
): Effect.Effect<Todo, ValidationError> =>
  Effect.succeed(new Todo({
    id,
    title,
    status: "Active",
    priority: "Medium",
    createdAt: now,
    completedAt: null,
  }))

Effect-ts предоставляет сервисы Clock и Random для детерминированного контроля над временем и случайностью:

import { Effect, Clock } from "effect"

// Application Layer: использует Clock сервис
const createTodoWithTimestamp = (id: TodoId, title: string) =>
  pipe(
    Clock.currentTimeMillis,
    Effect.map((millis) => new Date(Number(millis))),
    Effect.flatMap((now) => createTodo(id, title, now))
  )
// R = never для самой доменной функции createTodo
// R = Clock для обёртки в Application Layer (Clock предоставляется Effect runtime)

Чистота домена: чеклист

Используйте этот чеклист при code review доменного кода:

Импорты

  • Нет импортов из adapters/, infrastructure/, app/
  • Нет импортов bun:sqlite, node:fs, node:http, node:net
  • Нет импортов HTTP-фреймворков (express, hono, koa, fastify)
  • Нет импортов ORM (prisma, drizzle, typeorm)
  • Единственная внешняя зависимость — effect

Типы

  • Нет типов из инфраструктурных библиотек в сигнатурах
  • Доменные функции имеют R = never (нет зависимостей в R-канале)
  • Ошибки в E-канале — только доменные (TaggedError)
  • Нет Promise<T> — только Effect<A, E>

Поведение

  • Нет прямых обращений к process.env
  • Нет console.log / console.error
  • Нет new Date() внутри функций (время — параметр)
  • Нет Math.random() / crypto.randomUUID() (случайность — параметр)
  • Нет fetch, XMLHttpRequest, сетевых вызовов
  • Нет чтения/записи файлов

Данные

  • Все структуры данных неизменяемы (readonly, ReadonlyArray)
  • Нет let, только const
  • Нет мутаций массивов (.push, .splice, .sort на месте)
  • Используются Schema.Class для сущностей

Практический пример: рефакторинг загрязнённого домена

Рассмотрим реальный пример — доменный код, который нарушает чистоту, и шаг за шагом очистим его.

До рефакторинга (загрязнённый домен)

// ❌ domain/todo.ts — загрязнённый домен
import { Database } from "bun:sqlite"
import { randomUUID } from "crypto"

const db = new Database("todos.db")

export class Todo {
  id: string
  title: string
  status: string
  createdAt: Date

  constructor(title: string) {
    this.id = randomUUID()          // ❌ побочный эффект
    this.title = title
    this.status = "active"
    this.createdAt = new Date()     // ❌ побочный эффект
  }

  async save(): Promise<void> {     // ❌ IO в домене
    db.run(
      "INSERT INTO todos (id, title, status) VALUES (?, ?, ?)",
      [this.id, this.title, this.status]
    )
    console.log(`Saved todo: ${this.id}`)  // ❌ логирование
  }

  async complete(): Promise<void> {
    if (this.status === "completed") {
      throw new Error("Already completed")  // ❌ нетипизированная ошибка
    }
    this.status = "completed"  // ❌ мутация
    await this.save()          // ❌ IO в домене
  }
}

После рефакторинга (чистый домен)

// ✅ domain/value-objects/todo-id.ts
import { Schema } from "effect"

export class TodoId extends Schema.Class<TodoId>("TodoId")({
  value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}

// ✅ domain/errors/todo-errors.ts
import { Data } from "effect"

export class TodoAlreadyCompletedError extends Data.TaggedError(
  "TodoAlreadyCompletedError"
)<{
  readonly todoId: string
}> {}

// ✅ domain/entities/todo.ts — чистый домен
import { Schema, Effect } from "effect"
import { TodoId } from "../value-objects/todo-id.js"
import { TodoAlreadyCompletedError } from "../errors/todo-errors.js"

export class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
  status: Schema.Literal("Active", "Completed", "Archived"),
  createdAt: Schema.DateFromSelf,
}) {
  complete(): Effect.Effect<Todo, TodoAlreadyCompletedError> {
    return this.status === "Active"
      ? Effect.succeed(new Todo({ ...this, status: "Completed" }))
      : Effect.fail(
          new TodoAlreadyCompletedError({ todoId: this.id.value })
        )
  }
}

// ✅ domain/events/todo-events.ts
import { Schema } from "effect"
import { TodoId } from "../value-objects/todo-id.js"

export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    todoId: TodoId,
    title: Schema.String,
    occurredAt: Schema.DateFromSelf,
  }
) {}

Вся инфраструктурная логика (сохранение в БД, генерация ID, логирование) переместилась в Application Layer и адаптеры, а домен стал чистым, тестируемым и переносимым.

Резюме

Чистота домена — это фундаментальный принцип Hexagonal Architecture:

  • Доменный слой имеет нулевые зависимости от инфраструктуры
  • Все доменные функции имеют R = never — нет зависимостей в R-канале Effect
  • Время, случайность и ID передаются как параметры, а не генерируются внутри
  • Чистота обеспечивается структурно: через tsconfig paths, ESLint rules, архитектурные тесты
  • Допустимые зависимости: effect, другие доменные модули, стандартные типы TypeScript
  • Результат: мгновенные тесты, лёгкая замена технологий, понятный код

В следующей главе мы разберём типы домена — Entity, Value Object, Aggregate и Event — и их точное определение в контексте TypeScript и Effect-ts.