Направление зависимостей
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. Это означает:
-
Бизнес-логика привязана к SQLite. Функция
completeTodoсодержит бизнес-правило «нельзя завершить уже завершённую задачу», но для проверки этого правила она вынуждена вызывать конкретную функцию, которая обращается к конкретной базе данных. Бизнес-правило и инфраструктура слиплись. -
Невозможно тестировать бизнес-логику в изоляции. Чтобы протестировать
completeTodo, нужно поднять SQLite, создать таблицу, вставить тестовые данные. Тест, который проверяет одно бизнес-правило, зависит от всей инфраструктуры. -
Невозможно заменить хранилище. Если завтра потребуется PostgreSQL вместо SQLite — придётся изменить
todo-repository.ts, а это повлечёт за собой перетестированиеtodo-service.ts, потому что они связаны напрямую. -
Изменения каскадируют вверх. Если изменится схема таблицы
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 «всё указывает внутрь».
Ключевые выводы
- Наивное «сверху вниз» — бизнес-логика зависит от базы данных. Менять БД = менять бизнес-логику.
- DIP переворачивает зависимости — оба уровня зависят от абстракции (порта), а не друг от друга.
- Порт принадлежит потребителю, а не реализатору. Он живёт рядом с доменом.
- Effect-ts встраивает DIP в типы — R-канал явно показывает зависимости, компилятор гарантирует их предоставление.
- Dependency Rule — зависимости в исходном коде указывают только внутрь, к домену.
- Три следствия: тестируемость без инфраструктуры, заменяемость адаптеров, параллельная разработка.
- Нарушения видны по импортам: если
domain/импортирует изadapters/или изbun:sqlite— это красная линия.