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

Направление зависимостей

Dependency Inversion Principle — не абстрактное правило из учебника, а конкретный механизм, определяющий всю структуру проекта. Мы перевернём наивную модель «сверху вниз», разберём правило «всё указывает внутрь» и покажем, как R-канал Effect делает нарушения невозможными на уровне компилятора.

Наивная модель: зависимости «сверху вниз»

В классической слоистой архитектуре зависимости направлены от верхних слоёв к нижним:

HTTP Controller  →  Business Service  →  Database Repository  →  SQLite

Это кажется естественным: HTTP-обработчик вызывает сервис, сервис вызывает репозиторий, репозиторий обращается к базе. Информация «течёт» сверху вниз.

В коде это выглядит так:

// ❌ Наивная слоистая архитектура: зависимости направлены вниз

// database.ts — нижний слой
import { Database } from "bun:sqlite"
export const db = new Database("app.db")

// todo-repository.ts — слой данных
import { db } from "./database"

export function findTodoById(id: string) {
  return db.query("SELECT * FROM todos WHERE id = ?").get(id) as {
    id: string; title: string; done: number
  } | null
}

export function saveTodo(id: string, title: string, done: boolean) {
  db.run(
    "INSERT OR REPLACE INTO todos (id, title, done) VALUES (?, ?, ?)",
    [id, title, done ? 1 : 0]
  )
}

// todo-service.ts — слой бизнес-логики
import { findTodoById, saveTodo } from "./todo-repository"  // ← Прямая зависимость!

export function completeTodo(id: string) {
  const todo = findTodoById(id)
  if (!todo) throw new Error("Not found")
  if (todo.done) throw new Error("Already completed")
  saveTodo(id, todo.title, true)
}

// todo-controller.ts — слой представления
import { completeTodo } from "./todo-service"  // ← Прямая зависимость!

export async function handleComplete(req: Request) {
  const { id } = await req.json()
  completeTodo(id)
  return Response.json({ ok: true })
}

Что не так с этим подходом?

На первый взгляд — всё чисто, слои разделены, каждый файл отвечает за своё. Но посмотрите на todo-service.ts: он напрямую импортирует todo-repository.ts. Это означает:

  1. Бизнес-логика привязана к SQLite. Функция completeTodo содержит бизнес-правило «нельзя завершить уже завершённую задачу», но для проверки этого правила она вынуждена вызывать конкретную функцию, которая обращается к конкретной базе данных. Бизнес-правило и инфраструктура слиплись.

  2. Невозможно тестировать бизнес-логику в изоляции. Чтобы протестировать completeTodo, нужно поднять SQLite, создать таблицу, вставить тестовые данные. Тест, который проверяет одно бизнес-правило, зависит от всей инфраструктуры.

  3. Невозможно заменить хранилище. Если завтра потребуется PostgreSQL вместо SQLite — придётся изменить todo-repository.ts, а это повлечёт за собой перетестирование todo-service.ts, потому что они связаны напрямую.

  4. Изменения каскадируют вверх. Если изменится схема таблицы todos (добавить колонку priority), изменение затронет todo-repository, затем сигнатуру функций, затем todo-service, затем, возможно, todo-controller. Один SQL-столбец — четыре файла.


Dependency Inversion Principle (DIP)

Роберт Мартин сформулировал Dependency Inversion Principle как пятый принцип SOLID:

A. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.

B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

«Абстракция» в контексте TypeScript — это интерфейс (или тип), определяющий контракт. «Деталь» — конкретная реализация этого контракта.

Применение DIP переворачивает направление зависимостей:

БЫЛО (наивное):
  BusinessLogic → Repository → SQLite
  (верхний уровень зависит от нижнего)

СТАЛО (с DIP):
  BusinessLogic → RepositoryPort ← RepositorySqlite
  (оба зависят от абстракции, стрелки указывают к абстракции)

В коде:

// ✅ С Dependency Inversion

// ports/todo-repository.ts — АБСТРАКЦИЯ (контракт)
// Живёт рядом с бизнес-логикой, а не рядом с БД!
export interface TodoRepository {
  readonly findById: (id: string) => Promise<Todo | null>
  readonly save: (todo: Todo) => Promise<void>
}

// domain/todo-service.ts — БИЗНЕС-ЛОГИКА
// Зависит ТОЛЬКО от абстракции, не от конкретной БД
import type { TodoRepository } from "../ports/todo-repository"

export function completeTodo(repo: TodoRepository, id: string): Promise<void> {
  const todo = await repo.findById(id)
  if (!todo) throw new Error("Not found")
  if (todo.done) throw new Error("Already completed")
  await repo.save({ ...todo, done: true })
}

// adapters/sqlite/todo-repository-sqlite.ts — ДЕТАЛЬ (реализация)
// Зависит от абстракции, реализуя контракт
import type { TodoRepository } from "../../ports/todo-repository"
import { Database } from "bun:sqlite"

export function createSqliteTodoRepository(db: Database): TodoRepository {
  return {
    findById: (id) => { /* SQLite query */ },
    save: (todo) => { /* SQLite insert/update */ },
  }
}

Обратите внимание на ключевую инверсию:

  • todo-service.ts импортирует тип TodoRepository — абстракцию
  • todo-repository-sqlite.ts тоже импортирует тип TodoRepository — чтобы его реализовать
  • Ни один из них не импортирует другого
  • Оба зависят от абстракции, а не друг от друга

Правило зависимостей: всё указывает внутрь

В Hexagonal Architecture (и в Clean Architecture Роберта Мартина) это правило формулируется ещё строже:

Зависимости в исходном коде могут указывать только внутрь — к домену.

Визуально:

┌─────────────────────────────────────────────────┐
│  Infrastructure (HTTP, SQLite, Files, Email)    │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │  Application Layer (Use Cases)            │  │
│  │                                           │  │
│  │  ┌───────────────────────────────────┐    │  │
│  │  │  Domain (Entities, Value Objects, │    │  │
│  │  │  Events, Domain Services)         │    │  │
│  │  │                                   │    │  │
│  │  │  ★ НОЛЬ зависимостей от внешних   │    │  │
│  │  │    слоёв                          │    │  │
│  │  └───────────────────────────────────┘    │  │
│  │               ↑                           │  │
│  │      Ports (контракты)                    │  │
│  └───────────────────────────────────────────┘  │
│                    ↑                            │
│           Adapters (реализации)                 │
└─────────────────────────────────────────────────┘

Стрелки зависимостей: → указывают к центру (внутрь)

Домен — центр. Не зависит ни от чего. Не знает о HTTP, SQL, файлах, email, очередях. Содержит только бизнес-правила, выраженные чистыми функциями и типами.

Порты — контракты на границе домена. Определяют, что домен ожидает от внешнего мира (driven ports) и что предлагает внешнему миру (driving ports). Порты принадлежат домену, а не инфраструктуре.

Адаптеры — реализации портов. Знают о конкретных технологиях (SQLite, HTTP, SMTP). Зависят от портов (реализуют их контракты).

Инфраструктура — фреймворки, библиотеки, системные ресурсы. Это «самый внешний» слой.

Правило в терминах импортов

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

✅ Допустимо:
  adapters/sqlite-repo.ts  →  import from "ports/todo-repository"
  adapters/sqlite-repo.ts  →  import from "domain/todo"
  app/create-todo.ts       →  import from "domain/todo"
  app/create-todo.ts       →  import from "ports/todo-repository"

❌ Запрещено:
  domain/todo.ts           →  import from "adapters/sqlite-repo"
  domain/todo.ts           →  import from "bun:sqlite"
  ports/todo-repository.ts →  import from "adapters/sqlite-repo"
  domain/todo-service.ts   →  import from "express"

Если файл из domain/ импортирует что-то из adapters/ или из пакета вроде bun:sqlite — правило нарушено. Это красная линия, которую нельзя пересекать.


Практический пример: инверсия зависимостей для Todo

До инверсии

// ❌ domain/todo-service.ts — знает о SQLite
import { Database } from "bun:sqlite"

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

export function completeTodo(id: string): void {
  const row = db.query("SELECT * FROM todos WHERE id = ?").get(id)
  if (!row) throw new Error("Todo not found")
  if ((row as any).done === 1) throw new Error("Already done")
  db.run("UPDATE todos SET done = 1, completed_at = ? WHERE id = ?",
    [new Date().toISOString(), id])
}

Бизнес-правила (done === 1 → ошибка) смешаны с деталями хранения (db.query, db.run, SQL-синтаксис, done = 1 вместо true). Правило «завершённую задачу нельзя завершить повторно» утонуло в инфраструктурном шуме.

После инверсии

Шаг 1: Определяем доменные типы (ядро)

// domain/todo.ts — НОЛЬ импортов извне
export type TodoId = string & { readonly _tag: "TodoId" }

export type TodoStatus = "active" | "completed" | "archived"

export interface Todo {
  readonly id: TodoId
  readonly title: string
  readonly status: TodoStatus
  readonly createdAt: Date
  readonly completedAt: Date | null
}

// Бизнес-правило — чистая функция
export const complete = (todo: Todo, now: Date): Todo => {
  if (todo.status !== "active") {
    throw new Error(`Cannot complete todo in status: ${todo.status}`)
  }
  return { ...todo, status: "completed", completedAt: now }
}

Шаг 2: Определяем порт (контракт)

// ports/todo-repository.ts — зависит только от domain/todo
import type { Todo, TodoId } from "../domain/todo"

export interface TodoRepository {
  readonly findById: (id: TodoId) => Promise<Todo | null>
  readonly save: (todo: Todo) => Promise<void>
}

Шаг 3: Бизнес-логика зависит от порта

// app/complete-todo.ts — зависит от domain и ports
import { complete } from "../domain/todo"
import type { TodoRepository } from "../ports/todo-repository"
import type { TodoId } from "../domain/todo"

export async function completeTodo(
  repo: TodoRepository,
  id: TodoId,
  now: Date,
): Promise<void> {
  const todo = await repo.findById(id)
  if (!todo) throw new Error("Todo not found")
  const completed = complete(todo, now)  // Чистая доменная функция
  await repo.save(completed)
}

Шаг 4: Адаптер реализует порт

// adapters/sqlite/todo-repo-sqlite.ts — зависит от ports и domain
import type { TodoRepository } from "../../ports/todo-repository"
import type { Todo, TodoId } from "../../domain/todo"
import { Database } from "bun:sqlite"

export function createSqliteTodoRepo(db: Database): TodoRepository {
  return {
    findById: (id: TodoId): Promise<Todo | null> => {
      const row = db.query("SELECT * FROM todos WHERE id = ?").get(id) as any
      if (!row) return Promise.resolve(null)
      return Promise.resolve({
        id: row.id as TodoId,
        title: row.title,
        status: row.status,
        createdAt: new Date(row.created_at),
        completedAt: row.completed_at ? new Date(row.completed_at) : null,
      })
    },
    save: (todo: Todo): Promise<void> => {
      db.run(
        `INSERT OR REPLACE INTO todos (id, title, status, created_at, completed_at)
         VALUES (?, ?, ?, ?, ?)`,
        [todo.id, todo.title, todo.status,
         todo.createdAt.toISOString(),
         todo.completedAt?.toISOString() ?? null]
      )
      return Promise.resolve()
    },
  }
}

Граф зависимостей после инверсии

adapters/sqlite/todo-repo-sqlite.ts
  ├── imports from: ports/todo-repository.ts
  ├── imports from: domain/todo.ts
  └── imports from: bun:sqlite

app/complete-todo.ts
  ├── imports from: ports/todo-repository.ts
  └── imports from: domain/todo.ts

ports/todo-repository.ts
  └── imports from: domain/todo.ts

domain/todo.ts
  └── (ноль импортов)

Обратите внимание: domain/todo.ts ни от чего не зависит. ports/todo-repository.ts зависит только от домена. Адаптер зависит от портов и домена — но ни домен, ни порты не знают об адаптере. Все стрелки указывают внутрь.


Effect-ts: DIP, встроенный в систему типов

В примере выше инверсия зависимостей реализована через обычные TypeScript-интерфейсы. Это работает, но имеет слабость: ничто не мешает разработчику «забыть» передать репозиторий или передать неправильную реализацию.

Effect-ts делает DIP частью системы типов, делая нарушения невозможными:

import { Effect, Context, Layer } from "effect"

// ── Домен ──────────────────────────────────────────
// domain/todo.ts — ноль зависимостей
export interface Todo {
  readonly id: string
  readonly title: string
  readonly status: "active" | "completed"
  readonly completedAt: Date | null
}

export const completeTodo = (todo: Todo, now: Date): Todo =>
  todo.status === "active"
    ? { ...todo, status: "completed", completedAt: now }
    : todo  // Идемпотентность

// ── Порт ───────────────────────────────────────────
// ports/todo-repository.ts — зависит только от домена
export class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: string) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
  }
>() {}

// ── Use Case ───────────────────────────────────────
// app/complete-todo-use-case.ts — зависит от порта
export const completeTodoUseCase = (
  id: string,
  now: Date,
): Effect.Effect<Todo, TodoNotFound, TodoRepository> =>
  // Тип R = TodoRepository — зависимость ЯВНАЯ
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const todo = yield* repo.findById(id)
    const completed = completeTodo(todo, now)
    yield* repo.save(completed)
    return completed
  })

// ── Адаптер ────────────────────────────────────────
// adapters/sqlite/todo-repo-sqlite.ts
export const TodoRepositorySqlite: Layer.Layer<TodoRepository, never, SqliteClient> =
  Layer.effect(
    TodoRepository,
    Effect.gen(function* () {
      const sql = yield* SqliteClient
      return {
        findById: (id) => /* ... */,
        save: (todo) => /* ... */,
      }
    })
  )

// Тестовый адаптер
export const TodoRepositoryTest: Layer.Layer<TodoRepository> =
  Layer.sync(TodoRepository, () => {
    const store = new Map<string, Todo>()
    return {
      findById: (id) =>
        Effect.fromNullable(store.get(id)).pipe(
          Effect.mapError(() => new TodoNotFound({ id }))
        ),
      save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
    }
  })

Ключевая магия Effect: тип Effect.Effect<Todo, TodoNotFound, TodoRepository> явно декларирует зависимость от TodoRepository. Если вы попытаетесь запустить эффект без предоставления этого сервиса, код не скомпилируется:

// ❌ Type Error! TodoRepository not provided
Effect.runPromise(completeTodoUseCase("123", new Date()))

// ✅ Компилируется: зависимость предоставлена через Layer
const program = completeTodoUseCase("123", new Date()).pipe(
  Effect.provide(TodoRepositorySqlite),
  Effect.provide(SqliteClientLive),
)
Effect.runPromise(program)

Где живёт порт: ключевой вопрос владения

Критически важный вопрос: кому принадлежит порт (интерфейс)?

В наивной архитектуре интерфейс TodoRepository мог бы жить рядом с SQLite-адаптером — в папке infrastructure/. Но это нарушает DIP: тогда бизнес-логика зависела бы от пакета infrastructure.

Порт принадлежит тому, кто его использует, а не тому, кто его реализует.

✅ Правильное размещение:
  src/
    domain/
      todo.ts                    // Сущности и бизнес-правила
    ports/
      todo-repository.ts         // ← Порт живёт рядом с доменом
    adapters/
      sqlite/
        todo-repo-sqlite.ts      // Адаптер реализует порт

✅ Или даже внутри домена:
  src/
    domain/
      todo/
        todo.ts
        todo-repository.port.ts  // ← Порт как часть домена
    adapters/
      sqlite/
        todo-repo-sqlite.ts

Адаптер импортирует порт (и зависит от него). Порт не знает об адаптере. Бизнес-логика не знает об адаптере. Адаптер — «плагин», который можно заменить.


Три следствия Dependency Inversion

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

Если бизнес-логика зависит от абстракции, её можно протестировать с тестовым дублём:

// test/complete-todo.test.ts
import { describe, it, expect } from "bun:test"
import { Effect } from "effect"
import { completeTodoUseCase } from "../app/complete-todo-use-case"
import { TodoRepositoryTest } from "../adapters/test/todo-repo-test"

describe("completeTodo", () => {
  it("should complete an active todo", async () => {
    const result = await Effect.runPromise(
      completeTodoUseCase("todo-1", new Date("2025-01-15")).pipe(
        Effect.provide(TodoRepositoryTest)  // In-memory, без SQLite
      )
    )
    expect(result.status).toBe("completed")
  })
})

Тест запускается за миллисекунды, не требует базы данных и проверяет именно бизнес-правило.

2. Заменяемость адаптеров

Замена SQLite на PostgreSQL затрагивает только адаптер. Бизнес-логика, порты и тесты остаются неизменными:

// Новый адаптер — новый файл, ноль изменений в остальном коде
// adapters/postgres/todo-repo-postgres.ts
export const TodoRepositoryPostgres: Layer.Layer<TodoRepository, never, PgClient> =
  Layer.effect(
    TodoRepository,
    Effect.gen(function* () {
      const pg = yield* PgClient
      return {
        findById: (id) => /* PostgreSQL query */,
        save: (todo) => /* PostgreSQL upsert */,
      }
    })
  )

// Переключение — одна строка в точке сборки:
// Было:
const AppLayer = TodoRepositorySqlite.pipe(Layer.provide(SqliteClientLive))
// Стало:
const AppLayer = TodoRepositoryPostgres.pipe(Layer.provide(PgClientLive))

3. Параллельная разработка

Команда может работать параллельно, если определён порт:

  • Разработчик A реализует бизнес-логику, используя TodoRepositoryTest
  • Разработчик B реализует TodoRepositorySqlite, ориентируясь на контракт порта
  • Разработчик C реализует HTTP-адаптер, ориентируясь на Use Case интерфейс

Они не блокируют друг друга, потому что работают против абстракций, а не против реализаций.


Типичные нарушения Dependency Rule

1. Доменные типы, зависящие от ORM

// ❌ Доменная сущность зависит от Prisma
import { Prisma } from "@prisma/client"

export type Todo = Prisma.TodoGetPayload<{ include: { tags: true } }>

Здесь тип Todo генерируется Prisma. Если вы смените ORM — изменится доменный тип. Домен стал заложником инфраструктуры.

2. Бизнес-логика, возвращающая HTTP-коды

// ❌ Домен знает о HTTP
export function completeTodo(todo: Todo): { status: number; body: object } {
  if (todo.done) return { status: 409, body: { error: "Already done" } }
  return { status: 200, body: { ...todo, done: true } }
}

HTTP-коды — деталь транспортного уровня. Домен должен бросать доменные ошибки, а адаптер — маппить их в HTTP-коды.

3. Импорт конкретной библиотеки в домене

// ❌ Домен зависит от конкретной библиотеки логирования
import winston from "winston"

export function completeTodo(todo: Todo): Todo {
  winston.info(`Completing todo ${todo.id}`)  // ← Зависимость!
  return { ...todo, status: "completed" }
}

Если нужно логирование — создайте порт Logger, реализуйте его через winston в адаптере.

4. SQL в бизнес-логике

// ❌ SQL-запрос в слое бизнес-логики
export function getOverdueTodos(db: Database): Todo[] {
  return db.query(
    "SELECT * FROM todos WHERE due_date < datetime('now') AND status = 'active'"
  ).all() as Todo[]
}

Фильтрация по дате — бизнес-правило. SQL — деталь хранения. Они должны быть разделены: бизнес-правило isOverdue — в домене, SQL-запрос — в адаптере.


Стабильность и направление зависимостей

Принцип Stable Dependencies (SDP) Роберта Мартина гласит:

Зависимости должны быть направлены в сторону большей стабильности.

Стабильность модуля определяется тем, насколько сложно его изменить. Модуль стабилен, если от него зависит много других модулей — изменение затронет всех.

В гексагональной архитектуре:

СлойСтабильностьПричина
DomainМаксимальнаяОт него зависят все; меняется только при изменении бизнес-правил
PortsВысокаяКонтракты меняются редко
ApplicationСредняяОркестрация может меняться
AdaptersНизкаяТехнологии меняются часто
InfrastructureМинимальнаяВнешние системы меняются вне нашего контроля

Зависимости от нестабильного к стабильному: Infrastructure → Adapters → Application → Ports → Domain. Это совпадает с Dependency Rule «всё указывает внутрь».


Ключевые выводы

  1. Наивное «сверху вниз» — бизнес-логика зависит от базы данных. Менять БД = менять бизнес-логику.
  2. DIP переворачивает зависимости — оба уровня зависят от абстракции (порта), а не друг от друга.
  3. Порт принадлежит потребителю, а не реализатору. Он живёт рядом с доменом.
  4. Effect-ts встраивает DIP в типы — R-канал явно показывает зависимости, компилятор гарантирует их предоставление.
  5. Dependency Rule — зависимости в исходном коде указывают только внутрь, к домену.
  6. Три следствия: тестируемость без инфраструктуры, заменяемость адаптеров, параллельная разработка.
  7. Нарушения видны по импортам: если domain/ импортирует из adapters/ или из bun:sqlite — это красная линия.