Правило зависимостей: всё указывает внутрь
Зависимости исходного кода направлены только к 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 — фундамент гексагональной архитектуры:
- Зависимости исходного кода указывают внутрь — адаптеры зависят от портов, порты зависят от домена, домен не зависит ни от чего
- Dependency Inversion — бизнес-логика зависит от абстракций (портов), а не от конкретных технологий
- Effect-ts обеспечивает правило на уровне типов — R-канал, Context.Tag, Layer создают систему, где нарушение правила = ошибка компиляции
- R = never — формальное доказательство что все зависимости удовлетворены и правило соблюдено
- Тестируемость — прямое следствие правила: если ядро не зависит от инфраструктуры, его можно тестировать без инфраструктуры
Dependency Rule — это не рекомендация, а архитектурный инвариант. В хорошо спроектированной системе на Effect-ts он проверяется компилятором автоматически.