Проблема растущей сложности
В этой главе мы разберем, почему интуитивный подход к разработке неизбежно приводит к «вязкости» кода и росту технического долга. Вы научитесь распознавать симптомы архитектурной деградации и поймете, почему без строгих границ масштабирование проекта превращается в борьбу за выживание.
Два вида сложности
Фред Брукс в эссе «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):
- Закон непрерывного изменения — система, которая используется, должна меняться, иначе она становится неадекватной.
- Закон возрастающей сложности — по мере эволюции сложность системы возрастает, если не прилагать усилий для её снижения.
- Закон саморегуляции — скорость разработки стремится к постоянной, если не менять процесс.
- Закон сохранения организационной стабильности — средняя скорость работы над системой инвариантна относительно ресурсов.
Закон #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.
Ключевые выводы
- Сложность бывает существенной и привнесённой. Архитектура борется с привнесённой.
- Без целенаправленных усилий сложность растёт сама. Это закон, а не мнение.
- Стоимость изменения — главная метрика здоровья системы. Если добавить поле в сущность стоит неделю — система больна.
- Пять корневых причин: отсутствие границ, неявные зависимости, циклы, мутабельное состояние, отсутствие контрактов.
- «Рефакторинг потом» — иллюзия. Инвестировать в архитектуру нужно с самого начала.
- Архитектура — это не фреймворк, не документ и не преждевременная оптимизация. Это набор структурных решений, воплощённых в коде.
Далее: 02 — Связанность (coupling) и связность (cohesion) — формальные метрики качества структуры кода.