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

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

Зависимости исходного кода направлены только к Application Core. DIP как механизм разворота, R-канал и never как формальное доказательство. Типы нарушений, автоматизация проверки через ESLint, связь с Clean Architecture и SOLID.

Введение: самое важное правило

Если бы нужно было выбрать одно-единственное правило, определяющее гексагональную архитектуру, это было бы Dependency Rule — правило зависимостей:

Зависимости исходного кода должны указывать только внутрь — к Application Core. Никогда — наружу, от ядра к адаптерам.

Это означает: Application Core не знает о существовании адаптеров. Он не импортирует их, не ссылается на них, не содержит ни единой строки, которая зависела бы от конкретной технологии. Адаптеры знают о ядре. Ядро не знает об адаптерах.


Визуализация правила

НАПРАВЛЕНИЕ ЗАВИСИМОСТЕЙ ИСХОДНОГО КОДА:

                ┌──────────────────────┐
                │                      │
                │   ADAPTERS           │
                │   (HTTP, SQLite,     │
   Зависимости  │    FileSystem)       │
   указывают    │                      │
   ВНУТРЬ ──►   │   ┌──────────────┐   │
                │   │              │   │
                │   │   PORTS      │   │
                │   │  (Contracts) │   │
                │   │              │   │
                │   │ ┌──────────┐ │   │
                │   │ │ APP CORE │ │   │
                │   │ │ (Domain, │ │   │
                │   │ │ UseCases)│ │   │
                │   │ └──────────┘ │   │
                │   └──────────────┘   │
                └──────────────────────┘

Внешние слои ЗАВИСЯТ от внутренних.
Внутренние слои НЕ ЗНАЮТ о внешних.

Что такое «зависимость» в контексте правила

Import как зависимость

В TypeScript зависимость исходного кода выражается через import:

// Файл A зависит от файла B, если A содержит import из B
import { TodoRepository } from "../ports/todo-repository.js"
// ☝️ Этот файл ЗАВИСИТ от файла todo-repository.js

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

РАЗРЕШЕНО:
  adapters/sqlite/todo-repository.ts  →  import { TodoRepository } from "../../ports/..."
  adapters/http/router.ts             →  import { CreateTodoInput } from "../../domain/..."
  application/create-todo.ts          →  import { Todo } from "../../domain/..."
  application/create-todo.ts          →  import { TodoRepository } from "../../ports/..."
  domain/services/checker.ts          →  import { Todo } from "../todo/todo.js"

ЗАПРЕЩЕНО:
  domain/todo.ts                      →  import { Database } from "bun:sqlite"    ❌
  domain/todo.ts                      →  import { SqliteTodoRepository } from "../../adapters/..." ❌
  ports/todo-repository.ts            →  import { Database } from "bun:sqlite"    ❌
  application/create-todo.ts          →  import { SqliteTodoRepository } from "../../adapters/..." ❌

Диаграмма разрешённых зависимостей

┌─────────────────────────────────────────────────────────────┐
│ ADAPTERS                                                     │
│                                                              │
│  sqlite/todo-repo.ts ──────────────────┐                    │
│  http/router.ts ───────────────────────┼────────┐           │
│                                        │        │           │
├────────────────────────────────────────┼────────┼───────────┤
│ PORTS                                  ▼        │           │
│                                                 │           │
│  todo-repository.ts ──────────────┐    │        │           │
│  notification.ts ─────────────────┼────┼────────┤           │
│  clock.ts ────────────────────────┤    │        │           │
│                                   │    │        │           │
├───────────────────────────────────┼────┼────────┼───────────┤
│ APPLICATION (Use Cases)           ▼    ▼        ▼           │
│                                                              │
│  create-todo.ts ──────────────────┐                         │
│  complete-todo.ts ────────────────┤                         │
│                                   │                         │
├───────────────────────────────────┼─────────────────────────┤
│ DOMAIN                            ▼                         │
│                                                              │
│  todo.ts                                                     │
│  todo-title.ts                                               │
│  errors.ts                                                   │
│  events.ts                                                   │
│                                                              │
│  (НУЛЕВЫЕ внешние зависимости)                              │
└─────────────────────────────────────────────────────────────┘

Стрелки (──►) означают "зависит от" (import)
Все стрелки направлены ВНИЗ (внутрь)
Ни одна стрелка не идёт ВВЕРХ (наружу)

Dependency Inversion Principle (DIP)

Проблема без DIP

Без Dependency Inversion бизнес-логика зависела бы от инфраструктуры напрямую:

// ❌ БЕЗ DIP: бизнес-логика зависит от инфраструктуры
// application/create-todo.ts
import { Database } from "bun:sqlite"   // прямая зависимость!

const createTodo = (db: Database, title: string) => {
  if (title.length === 0) throw new Error("Title required")
  db.run("INSERT INTO todos (title) VALUES (?)", [title])
}

// Зависимость: Use Case → SQLite
// Замена SQLite на PostgreSQL = переписать Use Case
  Use Case ──────зависит──────► SQLite
  (бизнес-логика)               (инфраструктура)
  
  Направление зависимости: НАРУЖУ ❌

Решение с DIP

Dependency Inversion разворачивает направление зависимости: вместо того чтобы бизнес-логика зависела от инфраструктуры, инфраструктура зависит от абстракции (порта), которую определяет бизнес-логика:

// ✅ С DIP: бизнес-логика зависит от абстракции (порта)

// ports/todo-repository.ts — ПОРТ (определён Application Core)
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
  }
>() {}

// application/create-todo.ts — USE CASE (зависит от порта, не от SQLite)
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository  // зависимость от АБСТРАКЦИИ
    const todo = /* create domain entity */
    yield* repo.save(todo)
    return todo
  })

// adapters/sqlite/todo-repository.ts — АДАПТЕР (зависит от порта И от SQLite)
import { TodoRepository } from "../../ports/todo-repository.js"
import { Database } from "bun:sqlite"

const SqliteTodoRepository = Layer.scoped(TodoRepository, /* ... */)
  Use Case ──────зависит──────► Port (абстракция)


  SQLite Adapter ──зависит────────┘
  
  Оба зависят от порта. Порт определён Application Core.
  Направление зависимостей: ВНУТРЬ ✅

DIP = Dependency Inversion Principle

Не путать с Dependency Injection (DI) — это разные концепции:

  • Dependency Inversion — принцип проектирования: «модули высокого уровня не должны зависеть от модулей низкого уровня; оба должны зависеть от абстракций»
  • Dependency Injection — техника реализации: передача зависимостей извне, а не создание внутри

В Effect-ts:

  • Dependency Inversion реализуется через Context.Tag (порт = абстракция)
  • Dependency Injection реализуется через Effect.provide(Layer) (подключение адаптера)

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

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

Тип Effect<A, E, R> явно объявляет зависимости через R-канал. Это не абстрактное соглашение — это проверка компилятором:

// Тип Use Case: зависит от TodoRepository, Clock, IdGenerator
const createTodo = (
  input: CreateTodoInput
): Effect.Effect<
  Todo,
  ValidationError | DuplicateTitle,
  TodoRepository | Clock | IdGenerator   // ← зависимости в типе
> => ...

// TypeScript ГАРАНТИРУЕТ:
// 1. createTodo зависит ТОЛЬКО от портов (Tag-ов), не от конкретных адаптеров
// 2. Все зависимости должны быть предоставлены перед запуском
// 3. Если зависимость не предоставлена — ошибка компиляции

Компилятор как архитектурный enforcement

// ❌ Не скомпилируется: SqliteTodoRepository требует SqliteClient,
// который не предоставлен
const broken = createTodo(input).pipe(
  Effect.provide(SqliteTodoRepository)
  // Error: Layer requires SqliteClient
)

// ✅ Скомпилируется: все зависимости удовлетворены
const working = createTodo(input).pipe(
  Effect.provide(SqliteTodoRepository),
  Effect.provide(SqliteClientLive),
  Effect.provide(SystemClock),
  Effect.provide(UuidGenerator),
)
// Тип: Effect<Todo, ValidationError | DuplicateTitle, never>
// R = never → все зависимости предоставлены

Тип never как доказательство

Когда R-канал становится never, это означает: «все зависимости удовлетворены, программу можно запустить». Это формальное доказательство на уровне типов, что Dependency Rule соблюдён.


Нарушения Dependency Rule

Нарушение 1: прямой import инфраструктуры в домене

// domain/todo.ts
import { Database } from "bun:sqlite"  // ❌ НАРУШЕНИЕ!

class Todo {
  save(db: Database) { ... }  // Домен знает о SQLite
}

Почему это плохо: замена SQLite на любую другую БД потребует изменения доменного кода.

Нарушение 2: инфраструктурные типы в контракте порта

// ports/todo-repository.ts
import { Statement } from "bun:sqlite"  // ❌ НАРУШЕНИЕ!

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Statement  // тип SQLite в порте!
  }
>() {}

Почему это плохо: порт — часть Application Core. Если порт зависит от технологии, весь Application Core зависит от этой технологии.

Нарушение 3: Use Case импортирует конкретный адаптер

// application/create-todo.ts
import { SqliteTodoRepository } from "../../adapters/sqlite/todo-repo.js"  // ❌ НАРУШЕНИЕ!

const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    // Использует конкретный адаптер вместо порта
    const todo = ...
    yield* SqliteTodoRepository.save(todo)  // ← привязка к SQLite
  })

Почему это плохо: Use Case привязан к конкретной реализации. Тестирование без SQLite невозможно.

Нарушение 4: циклическая зависимость

// domain/todo.ts
import { TodoView } from "../../adapters/http/todo-response.js"  // ❌ домен → адаптер

// adapters/http/todo-response.ts
import { Todo } from "../../domain/todo.js"  // адаптер → домен

// Цикл: domain → adapter → domain
// Нарушение: domain зависит от adapter

Автоматизация проверки Dependency Rule

ESLint правила

// .eslintrc.js — правила для проверки зависимостей
module.exports = {
  rules: {
    "import/no-restricted-paths": ["error", {
      zones: [
        // domain/ НЕ МОЖЕТ импортировать из adapters/
        {
          target: "./src/domain/**",
          from: "./src/adapters/**",
          message: "Domain MUST NOT depend on adapters (Dependency Rule violation)"
        },
        // domain/ НЕ МОЖЕТ импортировать из application/
        {
          target: "./src/domain/**",
          from: "./src/application/**",
          message: "Domain MUST NOT depend on application layer"
        },
        // ports/ НЕ МОЖЕТ импортировать из adapters/
        {
          target: "./src/ports/**",
          from: "./src/adapters/**",
          message: "Ports MUST NOT depend on adapters (Dependency Rule violation)"
        },
        // application/ НЕ МОЖЕТ импортировать из adapters/
        {
          target: "./src/application/**",
          from: "./src/adapters/**",
          message: "Application MUST NOT depend on adapters (Dependency Rule violation)"
        },
        // domain/ НЕ МОЖЕТ импортировать инфраструктурные пакеты
        {
          target: "./src/domain/**",
          from: ["bun:sqlite", "node:fs", "node:http"],
          message: "Domain MUST NOT depend on infrastructure packages"
        },
      ]
    }]
  }
}

Скрипт проверки

// scripts/check-dependency-rule.ts
import { Glob } from "bun"

const FORBIDDEN_PATTERNS: ReadonlyArray<{
  readonly source: string
  readonly forbidden: ReadonlyArray<string>
  readonly message: string
}> = [
  {
    source: "src/domain/**/*.ts",
    forbidden: ["from \"../../adapters", "from \"bun:sqlite", "from \"node:fs"],
    message: "Domain depends on infrastructure"
  },
  {
    source: "src/ports/**/*.ts",
    forbidden: ["from \"../../adapters", "from \"bun:sqlite"],
    message: "Port depends on adapter"
  },
  {
    source: "src/application/**/*.ts",
    forbidden: ["from \"../../adapters"],
    message: "Application depends on adapter"
  },
] as const

const violations: Array<string> = []

for (const rule of FORBIDDEN_PATTERNS) {
  const glob = new Glob(rule.source)
  for await (const file of glob.scan(".")) {
    const content = await Bun.file(file).text()
    for (const pattern of rule.forbidden) {
      if (content.includes(pattern)) {
        violations.push(`${rule.message}: ${file} contains "${pattern}"`)
      }
    }
  }
}

if (violations.length > 0) {
  console.error("❌ Dependency Rule violations found:")
  violations.forEach((v) => console.error(`  • ${v}`))
  process.exit(1)
} else {
  console.log("✅ Dependency Rule: no violations")
}

Dependency Rule и тестирование

Правило делает тестирование тривиальным

Если Dependency Rule соблюдён, то Application Core зависит только от портов (абстракций). Это означает, что для тестирования достаточно подставить тестовые адаптеры:

describe("createTodo", () => {
  // Тестовые адаптеры: мгновенные, детерминированные
  const testLayer = Layer.mergeAll(
    InMemoryTodoRepository,
    Layer.succeed(Clock, { now: () => Effect.succeed(new Date("2025-01-15T10:00:00Z")) }),
    Layer.succeed(IdGenerator, { generate: () => Effect.succeed(new TodoId({ value: "test-id" })) }),
  )
  
  it("создаёт задачу с корректным заголовком", async () => {
    const result = await Effect.runPromise(
      createTodo({ title: "Test task" }).pipe(Effect.provide(testLayer))
    )
    
    expect(result.title.value).toBe("Test task")
    expect(result.id.value).toBe("test-id")
    expect(result.createdAt).toEqual(new Date("2025-01-15T10:00:00Z"))
  })
})

Тесты:

  • Не требуют базы данных
  • Не зависят от сети
  • Выполняются за миллисекунды
  • Детерминированы (одинаковый результат каждый раз)

Всё это — прямое следствие Dependency Rule.


Связь с другими архитектурными принципами

Clean Architecture: Dependency Rule идентичен

Robert C. Martin в Clean Architecture формулирует то же правило: «Source code dependencies must point only inward, toward higher-level policies.» Это не совпадение — Clean Architecture вдохновлена Hexagonal Architecture.

Onion Architecture: те же слои

Jeffrey Palermo в Onion Architecture рисует концентрические круги, где зависимости направлены к центру. Это та же идея, другая визуализация.

SOLID: DIP как основа

Dependency Rule — это масштабированный Dependency Inversion Principle (буква D в SOLID), применённый ко всей архитектуре, а не к отдельным классам.


Резюме

Dependency Rule — фундамент гексагональной архитектуры:

  1. Зависимости исходного кода указывают внутрь — адаптеры зависят от портов, порты зависят от домена, домен не зависит ни от чего
  2. Dependency Inversion — бизнес-логика зависит от абстракций (портов), а не от конкретных технологий
  3. Effect-ts обеспечивает правило на уровне типов — R-канал, Context.Tag, Layer создают систему, где нарушение правила = ошибка компиляции
  4. R = never — формальное доказательство что все зависимости удовлетворены и правило соблюдено
  5. Тестируемость — прямое следствие правила: если ядро не зависит от инфраструктуры, его можно тестировать без инфраструктуры

Dependency Rule — это не рекомендация, а архитектурный инвариант. В хорошо спроектированной системе на Effect-ts он проверяется компилятором автоматически.