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

Проблема растущей сложности

В этой главе мы разберем, почему интуитивный подход к разработке неизбежно приводит к «вязкости» кода и росту технического долга. Вы научитесь распознавать симптомы архитектурной деградации и поймете, почему без строгих границ масштабирование проекта превращается в борьбу за выживание.

Два вида сложности

Фред Брукс в эссе «No Silver Bullet» (1986) провёл фундаментальное разграничение, которое актуально и сегодня.

Essential complexity — сложность, присущая самой задаче. Если вы строите систему учёта налогов, вам придётся учитывать налоговое законодательство, множество ставок, исключений и правил округления. Эта сложность не зависит от того, пишете ли вы на TypeScript или на Cobol. Она неустранима.

Accidental complexity — сложность, которую мы привносим сами: неудачным выбором абстракций, запутанными зависимостями, дублированием, неявными связями между модулями. Именно accidental complexity превращает проект в «комок грязи» (Big Ball of Mud), и именно с ней борется архитектура.

Задача архитектуры — не устранить essential complexity (это невозможно), а минимизировать accidental complexity, чтобы команда тратила силы на решение бизнес-задач, а не на борьбу с собственным кодом.


Анатомия деградации: как проект превращается в монолит

Стадия 1 — «Всё просто» (0–3 месяца)

Проект начинается с одного файла или небольшой папки. Один разработчик держит всю систему в голове. Код читается сверху вниз. Зависимостей мало. Тесты (если есть) — простые и быстрые.

// server.ts — весь проект в одном файле
import { Database } from "bun:sqlite"

const db = new Database("app.db")
db.run("CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT, done INTEGER)")

const server = Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url)

    if (url.pathname === "/todos" && req.method === "GET") {
      const todos = db.query("SELECT * FROM todos").all()
      return Response.json(todos)
    }

    if (url.pathname === "/todos" && req.method === "POST") {
      const body = await req.json()
      db.run("INSERT INTO todos (title, done) VALUES (?, 0)", [body.title])
      return Response.json({ ok: true }, { status: 201 })
    }

    return new Response("Not found", { status: 404 })
  }
})

На этом этапе архитектура кажется излишней. «Зачем абстракции, если всё и так работает?» Это ловушка: проект кажется управляемым, потому что его сложность ещё мала.

Стадия 2 — «Добавим фичи» (3–9 месяцев)

Появляются новые требования: приоритеты задач, сроки, категории, пользователи, права доступа, уведомления. Каждая фича добавляет if-ветки в тот же файл. Запросы к БД перемежаются с бизнес-логикой и формированием HTTP-ответов.

// Тот же server.ts, спустя полгода — 800 строк
if (url.pathname === "/todos" && req.method === "POST") {
  const body = await req.json()

  // Валидация — прямо здесь
  if (!body.title || body.title.length > 200) {
    return Response.json({ error: "Invalid title" }, { status: 400 })
  }

  // Бизнес-правило — прямо здесь
  const existing = db.query("SELECT id FROM todos WHERE title = ? AND user_id = ?")
    .get(body.title, userId)
  if (existing) {
    return Response.json({ error: "Duplicate" }, { status: 409 })
  }

  // Проверка прав — прямо здесь
  const user = db.query("SELECT * FROM users WHERE id = ?").get(userId)
  if (user.plan === "free" && countUserTodos(userId) >= 10) {
    return Response.json({ error: "Upgrade to pro" }, { status: 403 })
  }

  // Запись в БД — прямо здесь
  db.run(
    "INSERT INTO todos (title, done, priority, due_date, user_id, category_id) VALUES (?, 0, ?, ?, ?, ?)",
    [body.title, body.priority ?? "medium", body.dueDate, userId, body.categoryId]
  )

  // Уведомление — прямо здесь
  if (body.assigneeId) {
    await sendEmail(body.assigneeId, `New todo: ${body.title}`)
  }

  // Аналитика — прямо здесь
  await trackEvent("todo_created", { userId, plan: user.plan })

  return Response.json({ ok: true }, { status: 201 })
}

Обратите внимание: в одном блоке if перемешаны шесть различных ответственностей — валидация ввода, проверка дубликатов, авторизация, персистентность, уведомления и аналитика. Они все зависят друг от друга через общие переменные (body, userId, user), через прямые вызовы db.query и через побочные эффекты (sendEmail, trackEvent).

Стадия 3 — «Уже страшно трогать» (9–18 месяцев)

Файлов стало больше, но разделение произошло стихийно: по техническим слоям (routes/, models/, utils/), а не по бизнес-смыслу. Модуль utils превратился в свалку из 50 функций. Любое изменение в одном месте ломает что-то в другом. Новый разработчик тратит неделю, чтобы разобраться в коде.

Стадия 4 — «Big Ball of Mud» (18+ месяцев)

Система стала монолитом с циклическими зависимостями, неявными контрактами и «магическими» строками. Тесты хрупкие (если они вообще есть). Развёртывание — стресс. Рефакторинг парализован страхом что-то сломать. Технический долг копится быстрее, чем гасится.


Метрика деградации: сложность изменения

Самый практичный индикатор «здоровья» системы — это стоимость внесения изменения. В здоровой архитектуре добавление новой фичи (например, нового поля tags у задачи) затрагивает ограниченное число файлов, и вы заранее знаете какие. В деградировавшей системе одно изменение порождает каскад: правки в десяти местах, исправление сломанных тестов, неожиданные баги в несвязанных модулях.

Визуализация этого эффекта — график «стоимость фичи от времени»:

Стоимость
фичи

  │                              ╱ Без архитектуры
  │                           ╱
  │                        ╱
  │                     ╱
  │                  ╱
  │              ╱
  │          ╱
  │       ╱      ─────────────── С архитектурой
  │    ╱   ──────
  │ ╱──
  │╱
  └──────────────────────────────→ Время / Размер системы

В начале проекта архитектура кажется накладным расходом: она требует больше файлов, больше абстракций, больше думания. Но с ростом системы кривая «без архитектуры» стремится вверх экспоненциально, а кривая «с архитектурой» остаётся почти линейной. Точка пересечения — момент, когда инвестиции в архитектуру начинают окупаться. Для большинства проектов это происходит удивительно быстро: через 2–4 месяца активной разработки.


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

1. Отсутствие границ между ответственностями

Когда HTTP-обработчик одновременно валидирует ввод, проверяет права, вызывает бизнес-правила, пишет в базу и отправляет уведомления — это не просто «некрасивый код». Это отсутствие модульности. Каждая ответственность связана с остальными, и изменение одной тянет за собой все остальные.

// ❌ Всё в одном месте — нет границ
async function handleCreateTodo(req: Request) {
  const body = await req.json()                              // Парсинг HTTP
  if (!body.title) throw new Error("Title required")         // Валидация
  const exists = db.query("...").get(body.title)             // Бизнес-правило + БД
  if (exists) throw new Error("Duplicate")                   // Ещё бизнес-правило
  db.run("INSERT INTO todos ...", [body.title])              // Персистентность
  await sendEmail(body.assignee, "New todo")                 // Уведомления
  await analytics.track("todo_created")                      // Аналитика
  return Response.json({ ok: true })                         // HTTP-ответ
}

Почему это катастрофично? Допустим, вы хотите заменить SQLite на PostgreSQL. Вам придётся найти и изменить каждый обработчик, потому что SQL-запросы «размазаны» по всему коду. А если вы хотите протестировать бизнес-правило «нельзя создать задачу с дублирующимся названием» — вам нужно поднять HTTP-сервер и реальную базу данных, хотя правило не имеет к ним никакого отношения.

2. Неявные зависимости

Когда один модуль импортирует другой напрямую, между ними создаётся жёсткая связь. Но ещё хуже — когда зависимости неявные: через глобальное состояние, через «магические» строки, через побочные эффекты при импорте.

// ❌ Неявная зависимость через глобальную переменную
// database.ts
export const db = new Database("app.db")  // Глобальный синглтон

// todo-service.ts
import { db } from "./database"  // Жёсткая привязка к конкретной БД

export function createTodo(title: string) {
  db.run("INSERT INTO todos (title) VALUES (?)", [title])
}

// Проблемы:
// 1. Невозможно использовать другую БД без изменения кода
// 2. Невозможно тестировать без реальной SQLite
// 3. Невозможно запустить два экземпляра с разными БД
// 4. Порядок инициализации — невидимая мина

3. Двунаправленные зависимости (циклы)

Когда модуль A зависит от B, а B зависит от A — вы получаете циклическую зависимость. Оба модуля становятся неразделимы: их невозможно тестировать, переиспользовать или понять по отдельности.

// ❌ Циклическая зависимость
// todo-service.ts
import { sendNotification } from "./notification-service"

export function completeTodo(id: string) {
  // ... пометить как выполненную
  sendNotification(`Todo ${id} completed`)
}

// notification-service.ts
import { getTodoTitle } from "./todo-service"  // ← Цикл!

export function sendNotification(message: string) {
  const title = getTodoTitle(/* ... */)  // Зачем?
  // ...
}

Циклические зависимости — один из самых надёжных индикаторов нарушения архитектуры. Если они появились, значит границы между модулями проведены неверно.

4. Изменяемое разделяемое состояние

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

// ❌ Разделяемое мутабельное состояние
const appState = {
  currentUser: null as User | null,
  todos: [] as Todo[],
  isLoading: false,
  lastError: null as string | null,
}

// Кто-то мутирует состояние:
function loadTodos() {
  appState.isLoading = true
  appState.todos = fetchTodos()
  appState.isLoading = false
}

// Кто-то другой зависит от этого:
function renderStats() {
  // Может сломаться, если вызван пока isLoading === true
  return appState.todos.filter(t => t.done).length
}

// Третий тоже мутирует:
function clearOnLogout() {
  appState.currentUser = null
  // Забыли очистить todos — теперь отображаются чужие задачи
}

В функциональном подходе (и в Effect-ts в частности) этой проблемы не существует: данные иммутабельны, а эффекты (мутации, I/O, ошибки) описываются явно через тип Effect<A, E, R>.

5. Отсутствие явных контрактов

Когда между модулями нет формального контракта (интерфейса, типа, порта), они общаются «на договорённости»: «я знаю, что эта функция вернёт объект с полем id». Такие неявные контракты ломаются при рефакторинге без предупреждения.

// ❌ Неявный контракт: вызывающий код «знает» структуру ответа
function getTodos() {
  return db.query("SELECT * FROM todos").all()
  // Что именно возвращается? Какие поля? Какие типы?
  // Если добавить колонку в БД — изменится ли тип?
}

// Потребитель полагается на неявный контракт:
const todos = getTodos()
todos.forEach(t => console.log(t.title))  // А если поле переименуют в `name`?

Количественные метрики сложности

Сложность — не субъективное ощущение. Её можно измерить.

Цикломатическая сложность (McCabe, 1976)

Количество линейно независимых путей через функцию. Каждый if, for, while, case, catch, &&, || увеличивает сложность на 1.

// Цикломатическая сложность = 1 (один путь)
function getGreeting(name: string): string {
  return `Hello, ${name}`
}

// Цикломатическая сложность = 7 (семь путей!)
function processTodo(todo: unknown): string {
  if (!todo) return "No todo"                                    // +1
  if (typeof todo !== "object") return "Invalid"                 // +1
  const t = todo as Record<string, unknown>
  if (!t.title || typeof t.title !== "string") return "No title" // +1 +1
  if (t.title.length > 200) return "Too long"                    // +1
  if (t.done) return `Done: ${t.title}`                          // +1
  return `Pending: ${t.title}`
}

Рекомендации по порогам: 1–10 — хорошо, 11–20 — требует внимания, 21+ — необходим рефакторинг. Функции с цикломатической сложностью выше 10 практически невозможно полностью покрыть тестами.

Когнитивная сложность (Sonar, 2017)

Усовершенствование цикломатической сложности: учитывает вложенность, прерывания потока и нелинейность. Вложенный if внутри for внутри try/catch вносит больше когнитивной нагрузки, чем три последовательных if.

// Когнитивная сложность = 1
function isOverdue(dueDate: Date): boolean {
  if (dueDate < new Date()) return true   // +1 (один if)
  return false
}

// Когнитивная сложность = 9
function getStatus(todo: Todo): string {
  if (todo.done) {                         // +1
    if (todo.archivedAt) {                 // +2 (вложенность!)
      return "archived"
    } else {                               // +1
      if (todo.completedAt) {              // +3 (двойная вложенность!)
        return "completed"
      }
    }
  } else {                                 // +1
    if (todo.dueDate && todo.dueDate < new Date()) { // +2
      return "overdue"
    }
  }
  return "active"
}

Afferent / Efferent Coupling (Ca / Ce)

Afferent coupling (Ca) — сколько модулей зависят от данного модуля. Высокий Ca означает, что модуль широко используется (как Array или String). Такой модуль сложно менять — изменения ломают много потребителей.

Efferent coupling (Ce) — от скольких модулей зависит данный модуль. Высокий Ce означает, что модуль зависит от множества внешних частей. Он нестабилен: любое изменение в зависимостях может его сломать.

Instability = Ce / (Ca + Ce) — метрика нестабильности. Значение 0 — максимально стабильный модуль (много зависимых, мало зависимостей). Значение 1 — максимально нестабильный.

Правило: зависимости должны быть направлены в сторону стабильности. Нестабильные модули зависят от стабильных, не наоборот. Домен (бизнес-логика) должен быть стабильным — от него зависят все, а он ни от кого.

Connascence (Meilir Page-Jones, 1996)

Connascence — мера того, как именно связаны два модуля. Виды — от слабого к сильному:

  • Connascence of Name — два модуля используют одно имя (наименее вредно)
  • Connascence of Type — согласованность по типам данных
  • Connascence of Meaning — «магические» значения (status: 1 означает «выполнено»)
  • Connascence of Position — зависимость от порядка аргументов
  • Connascence of Algorithm — оба модуля должны реализовать одинаковый алгоритм (хеширование паролей)
  • Connascence of Execution — порядок вызовов имеет значение
  • Connascence of Timing — зависимость от тайминга (гонки)
  • Connascence of Identity — зависимость от конкретного экземпляра

Принцип: стремитесь к более слабым формам connascence и минимизируйте сильные. Effect-ts идеально решает Connascence of Name/Type/Meaning через свою систему типов и Service/Layer.


Закон Конвея и его влияние на архитектуру

«Организации, проектирующие системы, ограничены структурой, которая копирует коммуникационную структуру этих организаций.» — Мелвин Конвей, 1968

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

Обратный манёвр Конвея: определите желаемую архитектуру сначала, а затем организуйте команды так, чтобы они ей соответствовали. Hexagonal Architecture поддерживает этот подход: чёткие границы между ядром и адаптерами позволяют разным командам работать независимо.


Энтропия программного обеспечения

Второй закон термодинамики имеет программный аналог: без целенаправленных усилий система движется к хаосу. Каждый «быстрый хак», каждый // TODO: fix later, каждый скопированный блок кода увеличивает энтропию.

Леман и Белади формализовали это в «Законах эволюции программ» (1974):

  1. Закон непрерывного изменения — система, которая используется, должна меняться, иначе она становится неадекватной.
  2. Закон возрастающей сложности — по мере эволюции сложность системы возрастает, если не прилагать усилий для её снижения.
  3. Закон саморегуляции — скорость разработки стремится к постоянной, если не менять процесс.
  4. Закон сохранения организационной стабильности — средняя скорость работы над системой инвариантна относительно ресурсов.

Закон #2 — ключевой: сложность растёт сама по себе. Архитектура — это целенаправленное противодействие этому росту. Без него проект обречён.


Практический пример: эволюция Todo-приложения

Вернёмся к нашему Todo-проекту. Вот как выглядит типичная эволюция без архитектуры:

Месяц 1: 1 файл, 100 строк. Работает отлично.

Месяц 3: 5 файлов, 800 строк. server.ts содержит 15 роутов. SQL-запросы прямо в обработчиках. Появились первые баги из-за забытой валидации.

Месяц 6: 20 файлов, 3000 строк. Папка utils/ содержит 40 функций. Половина из них используется в одном месте. Тесты покрывают 15% кода и падают при каждом изменении схемы БД.

Месяц 12: 60 файлов, 10000 строк. Два разработчика постоянно конфликтуют в одних и тех же файлах. Время на добавление фичи выросло в 5 раз. Половина спринта уходит на исправление регрессий.

А теперь та же система с Hexagonal Architecture:

Месяц 1: 10 файлов, 200 строк. Чуть больше начального boilerplate, но границы уже определены.

Месяц 3: 20 файлов, 600 строк. Новые фичи добавляются в изолированные модули. Тесты домена не зависят от БД.

Месяц 6: 40 файлов, 2000 строк. Замена SQLite на PostgreSQL затрагивает только один адаптер. Тесты покрывают 90% бизнес-логики.

Месяц 12: 70 файлов, 5000 строк. Два разработчика работают параллельно, не мешая друг другу. Новая фича добавляется за предсказуемое время.


Почему «рефакторинг потом» не работает

Одна из самых опасных иллюзий в разработке — «мы сделаем быстро, а потом отрефакторим». Это не работает по трём причинам.

Во-первых, «потом» не наступает. Бизнес всегда требует новые фичи. Время на рефакторинг никто не выделяет, потому что рефакторинг не приносит видимой ценности для стейкхолдеров.

Во-вторых, рефакторинг без архитектуры — это перекладывание хаоса. Если вы не знаете, к какой целевой архитектуре стремитесь, рефакторинг — просто перемещение кода из одного файла в другой. Через месяц новая структура станет такой же запутанной, как старая.

В-третьих, стоимость рефакторинга растёт экспоненциально. Рефакторинг 500 строк — задача на день. Рефакторинг 50 000 строк — задача на квартал, с риском полной поломки продукта. Чем дольше вы откладываете, тем дороже обходится каждый следующий шаг.


Что такое архитектура и что она НЕ такое

Архитектура — это набор решений, которые сложно изменить потом. Выбор базы данных — архитектурное решение. Выбор имени переменной — нет.

Архитектура — это не фреймворк. Фреймворк — деталь реализации. Архитектура определяет, как ваша система организована концептуально: какие есть части, как они взаимодействуют, какие зависимости допустимы.

Архитектура — это не документ на 50 страниц, написанный «архитектором» и положенный на полку. Архитектура — это живые решения, воплощённые в коде: в структуре папок, в направлении импортов, в типах на границах модулей.

Архитектура — это не преждевременная оптимизация. Архитектура касается структуры и зависимостей, а не производительности. Можно (и нужно) думать об архитектуре с первого дня и при этом не заниматься premature optimization.


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

  1. Сложность бывает существенной и привнесённой. Архитектура борется с привнесённой.
  2. Без целенаправленных усилий сложность растёт сама. Это закон, а не мнение.
  3. Стоимость изменения — главная метрика здоровья системы. Если добавить поле в сущность стоит неделю — система больна.
  4. Пять корневых причин: отсутствие границ, неявные зависимости, циклы, мутабельное состояние, отсутствие контрактов.
  5. «Рефакторинг потом» — иллюзия. Инвестировать в архитектуру нужно с самого начала.
  6. Архитектура — это не фреймворк, не документ и не преждевременная оптимизация. Это набор структурных решений, воплощённых в коде.

Далее: 02 — Связанность (coupling) и связность (cohesion) — формальные метрики качества структуры кода.