Антипаттерны: God Object, Big Ball of Mud, Golden Hammer и другие
Весь трек до этого места был про решения, которые работают. Порождающие, структурные, поведенческие, конкурентные и функциональные паттерны — это выкристаллизовавшийся опыт «делай так».
Эта глава — про вторую половину того же опыта. Она отвечает на вопрос, который в каталоге GoF не задан: почему системы всё равно гниют, даже когда команда знает паттерны?
Ответ неприятный: потому что почти каждое решение, которое разрушило систему, в момент принятия выглядело разумным. Никто не приходит утром с намерением написать класс на 4000 строк. Класс на 4000 строк — это 300 отдельных решений «добавлю метод сюда, здесь уже есть нужный контекст», каждое из которых сэкономило час. Антипаттерн — это не про глупость, это про локально рациональные шаги, ведущие в глобально плохое место.
Что такое антипаттерн (и чем он не является)
Термин ввёл Эндрю Кёниг в колонке для C++ Report в 1995 году, а каноническим его сделала книга «AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis» (Brown, Malveau, McCormick, Mowbray, Wiley, 1998).
Определение, которое стоит запомнить дословно. Антипаттерн — это описание из двух частей:
- Часто повторяемое решение, которое кажется уместным, но систематически даёт больше негативных последствий, чем позитивных.
- Рефакторинговое решение — путь выхода, тоже описанный как воспроизводимая процедура.
Без второй части это просто жалоба. Каталог антипаттернов без «как выбираться» бесполезен ровно так же, как каталог болезней без лечения — вы научитесь красиво называть свою боль и не более.
Отсюда три важных разграничения:
| Не антипаттерн | Антипаттерн |
|---|---|
| Ошибка (баг) — единичный дефект | Структурное решение, воспроизводимое разными людьми независимо |
| «Плохой код» — субъективная оценка стиля | Решение с измеримыми последствиями: стоимость изменения растёт |
| Осознанный компромисс с планом выхода | Тот же компромисс, но забытый и застывший |
Последняя строка — ключевая. Прототип на коленке, который через две недели выбросят, антипаттерном не является. Он становится им в тот момент, когда его выкатывают в прод и забывают. Это ровно метафора технического долга Уорда Каннингема (отчёт на OOPSLA'92, c2.com/doc/oopsla92.html): взять в долг можно осмысленно, губит не заём, а невыплата процентов.
Силы, которые порождают антипаттерны
Прежде чем читать каталог, полезно понять механику. Антипаттерны рождают четыре силы, и они физически неустранимы — их можно только компенсировать.
1. Асимметрия горизонтов. Выгода от быстрого решения приходит сегодня и достаётся тому, кто его принял. Издержка приходит через полгода и достаётся другому человеку. Экономика такого обмена всегда в пользу «быстро».
2. Локальная рациональность. Добавить метод в существующий класс дешевле, чем создать новый: не надо придумывать имя, заводить файл, тянуть зависимости, проходить ревью по новому модулю. Каждое такое решение оптимально локально. God Object — интеграл от локально оптимальных решений.
3. Второй закон Лемана. Мануэль Леман сформулировал: система, которая эволюционирует, наращивает сложность, если специально не тратить работу на её снижение (Lehman’s laws, 1980). Энтропия — состояние по умолчанию; порядок надо оплачивать.
4. Эффект разбитых окон. Одно нарушение соглашений снижает порог для следующего. В софт эту метафору перенесли Хант и Томас в «The Pragmatic Programmer» (1999): «не оставляйте разбитых окон». Один хак без теста легитимизирует второй.
обход абстракции, дубль, класс-свалка"] B --> C["Работает. Выгода получена сегодня"] C --> D["Структура чуть хуже:
+1 связь, -1 граница"] D --> E["Следующее изменение обходится дороже"] E --> F{"Есть ли петля
обратной связи?"} F -->|"Нет: метрик нет, боль размазана"| G["Порог 'сделать правильно' вырос"] G --> B F -->|"Да: hotspot-отчёт, ревью, бюджет долга"| H["Возврат долга: рефакторинг,
граница восстановлена"] H --> I["Стоимость изменения стабильна"] style G fill:#c96e6e,stroke:#8b8b8b,color:#fff style I fill:#6fbf73,stroke:#8b8b8b,color:#fff
Обратите внимание: развилка не «хорошие инженеры / плохие инженеры», а есть ли обратная связь. Без измерения деградация невидима, потому что каждый отдельный шаг стоит совсем немного.
Карта каталога
Дальше — разбор ключевых. Для каждого: механика возникновения, как измерить, как выбираться.
God Object (он же Blob, он же класс-бог)
Симптом. Один класс держит большую часть состояния системы и большую часть логики. Остальные
классы вырождаются в пассивные структуры данных. Типичные имена: Manager, Controller, Utils,
Helper, Service без уточнения, Processor, Engine, Core.
Механика. Это чистая сила №2. У класса уже есть подключение к БД, логгер, конфиг, текущий пользователь. Любая новая функция «естественно» ложится сюда, потому что здесь уже собран весь контекст. Через два года класс держит контекст всего приложения, и альтернативы ему нет по построению.
Вот сжатая до 40 строк версия того, что в проде занимает 4000:
class UserAccountManager:
"""Класс-бог: регистрация, аутентификация, корзина, оплата, аудит и почта."""
def __init__(self, db_conn, smtp, payment_api, logger, feature_flags, cache):
# 6 зависимостей в конструкторе — первый и самый надёжный индикатор
self.db = db_conn
self.smtp = smtp
self.payments = payment_api
self.log = logger
self.flags = feature_flags
self.cache = cache
# Состояние двух несвязанных доменов живёт в одном объекте
self.name = None
self.email = None
self.pwd_hash = None
self.items: list[dict] = []
self.total = 0
self.coupon = None
def register(self, name, email, password): ... # идентичность
def login(self, email, password): ... # идентичность
def reset_password(self, email): ... # идентичность
def add_item(self, sku, qty): ... # корзина
def remove_item(self, sku): ... # корзина
def apply_coupon(self, code): ... # корзина
def checkout(self): ... # корзина + оплата
def write_audit(self, event): ... # инфраструктура
def process(self, action: str, payload: dict):
# Диспетчер по строке — обход системы типов и признак того,
# что класс перестал быть объектом и стал неявным модулем
if action == "register":
return self.register(**payload)
elif action == "checkout":
return self.checkout()
# ...ещё 30 веток
Как измерить, а не «почувствовать»
Спор «это God Object или нет» разрешается метриками. Классический набор — из работы Чидамбера и Кемерера («A Metrics Suite for Object Oriented Design», IEEE TSE, 1994):
- WMC (Weighted Methods per Class) — сумма цикломатических сложностей методов. Порог тревоги ≈ 50.
- CBO (Coupling Between Objects) — число классов, с которыми связан данный. Тревога ≈ 15–20.
- RFC (Response For a Class) — методы класса плюс методы, которые он вызывает. Тревога ≈ 50.
- LCOM (Lack of Cohesion in Methods) — насколько методы класса не разделяют общие поля.
LCOM интереснее всех, потому что он прямо подсказывает, как резать. Практичная версия — LCOM4
(Hitz & Montazeri): построим граф, где вершины — методы, а ребро соединяет два метода, если они
трогают общее поле или один вызывает другой. LCOM4 — число компонент связности этого графа.
LCOM4 = 1 — класс когерентен. LCOM4 = 3 — перед вами три класса в одном файле.
Матрица выше — это тот же LCOM4, увиденный глазами. Если строки и столбцы можно переставить так,
что закрашенные клетки собираются в блоки на диагонали, — класс раскладывается ровно на эти блоки.
Инфраструктурный столбец (dbConn, logger) в счёт не идёт: это не общая ответственность, а
общая зависимость, и она выносится инъекцией, а не оставляется как «доказательство связности».
Считается LCOM4 просто — обход графа в ширину:
from collections import defaultdict, deque
def lcom4(method_fields: dict[str, set[str]],
method_calls: dict[str, set[str]],
infra_fields: set[str] = frozenset()) -> int:
"""Число компонент связности графа методов. LCOM4 == 1 — класс когерентен.
method_fields — какие поля трогает метод (получается статическим анализом AST);
method_calls — какие методы того же класса вызывает;
infra_fields — поля-зависимости, которые надо исключить (db, logger, cache),
иначе они склеят всё в одну ложную компоненту.
"""
methods = list(method_fields)
field_owners: dict[str, list[str]] = defaultdict(list)
for m, fields in method_fields.items():
for f in fields - infra_fields:
field_owners[f].append(m)
adj: dict[str, set[str]] = defaultdict(set)
for owners in field_owners.values(): # общее поле связывает методы
for a in owners:
for b in owners:
if a != b:
adj[a].add(b)
for m, callees in method_calls.items(): # вызов тоже связывает
for c in callees:
if c in method_fields:
adj[m].add(c)
adj[c].add(m)
seen, components = set(), 0
for start in methods:
if start in seen:
continue
components += 1
queue = deque([start])
seen.add(start)
while queue:
cur = queue.popleft()
for nxt in adj[cur] - seen:
seen.add(nxt)
queue.append(nxt)
return components
Сложность. Пусть M — число методов, F — полей, k — среднее число методов на поле.
Построение графа — O(Σ_f k_f²), в худшем случае (все методы трогают все поля) O(M²·F);
на реальных классах k мал и это близко к O(M·F). Обход — O(M + E), память O(M + E).
Для класса на 80 методов считается за миллисекунды, так что метрику спокойно вешают на CI.
Рефакторинг: как резать
Порядок операций (по Фаулеру, Refactoring, 2-е изд., и Физерсу, «Working Effectively with Legacy Code»):
- Сначала тесты, потом нож. Пишем характеризационные тесты — они фиксируют не «правильное» поведение, а текущее, включая странности. Задача — сеть безопасности, а не спецификация.
- Посчитать LCOM4 и получить кандидатов на разрез — это объективный ответ, где границы.
- Extract Class по одной компоненте за раз. Первым выносим тот блок, у которого меньше всего связей с остальными (обычно самый свежий код).
- Move Method / Move Field — переносим, оставляя в God Object делегирующий метод-заглушку. Внешние вызывающие пока не трогаем: это позволяет мержиться каждый день.
- Inline делегатов — по одному вызывающему за раз, отдельными коммитами.
- Удалить пустой класс. Если он не опустел за квартал — вы нашли ещё одну компоненту, вернитесь к шагу 2.
Ловушка. Соблазн распилить God Object «за один подход в отдельной ветке». Такая ветка живёт две недели, накапливает конфликты со всем, что параллельно правят в том же классе (а правят его все — он же God Object), и погибает. Резать нужно коммитами по 100–200 строк, каждый из которых самостоятельно вливается в основную ветку.
Big Ball of Mud
Термин из статьи Брайана Фута и Джозефа Йодера, PLoP 1997 — laputan.org/mud. Авторы начали её знаменитым наблюдением: это самая распространённая архитектура в индустрии, и обсуждать её надо всерьёз, а не морализировать.
Определение. Система без различимой структуры: код и данные размазаны, границы модулей либо отсутствуют, либо систематически нарушаются, информация, которая должна быть локальной, глобальна.
Картинка выше про арифметику, а не про эстетику. При n модулях максимум связей — n(n−1)/2,
то есть O(n²). Модульность — это дисциплина не выбирать большую часть этих рёбер. Стоимость
понимания системы пропорциональна не числу модулей, а числу рёбер, которые надо держать в голове
при изменении одного из них. Именно об этом писал Парнас в 1972 году
(«On the Criteria To Be Used in Decomposing Systems into Modules»):
делить надо не по шагам обработки, а по скрываемым решениям — так, чтобы изменение решения
задевало один модуль.
Ключевой вывод Фута и Йодера, который часто пропускают: ком грязи — не следствие некомпетентности. Это результат давления реальности (сроки, текучка, меняющиеся требования) на систему, у которой нет механизма поддержания структуры. И он выигрывает эволюционно: систему без границ проще менять в первые полгода — именно поэтому она и появляется.
Жизненный цикл
Практический смысл диаграммы в одном переходе: Эрозия → Модульная дёшев, КомГрязи → Модульная
дорог. Вся ценность метрик и ревью — в том, чтобы ловить систему в состоянии «Эрозия», где
возврат стоит один спринт, а не год.
Что реально работает против кома грязи:
- Проверяемые границы. Не «договорились не импортировать напрямую», а линтер, который валит
сборку:
import-linterв Python, ArchUnit в Java/.NET,depguardиgo listв Go,eslint-plugin-boundariesв TypeScript. Договорённость без проверки живёт до первого дедлайна. - Границы по данным, а не по слоям. Разделение «контроллеры / сервисы / репозитории» не препятствует кому грязи: связи идут горизонтально внутри слоя. Работают вертикальные срезы по поддоменам — об этом трек по предметно-ориентированному проектированию (DDD) и архитектурные паттерны.
- Sacrificial architecture. Сознательное решение, что вот эта часть проживёт год и будет выброшена, с записанным сроком (Fowler, SacrificialArchitecture). Ком грязи с датой утилизации — уже не антипаттерн, а компромисс.
Golden Hammer
«Если единственный ваш инструмент — молоток, соблазнительно рассматривать всё как гвоздь.» — Абрахам Маслоу, «The Psychology of Science», 1966 (закон инструмента, ранее — Каплан, 1964)
Симптом. Технология или паттерн, однажды успешно применённые, начинают применяться везде, причём выбор больше не обсуждается. Признак в разговоре: аргумент вида «мы всегда так делаем» вместо «вот силы задачи, вот почему инструмент им соответствует».
Живые примеры:
- Kafka как единственный способ передать сообщение — включая RPC «запрос-ответ» между двумя сервисами, где она добавляет задержку, топик и оператора кластера.
- Микросервисы для команды из четырёх человек: распределённая система на пять машин с сетевыми сбоями и eventual consistency там, где хватало модульного монолита.
- Kubernetes под один stateless-сервис с тремя запросами в секунду.
- GraphQL поверх одной таблицы.
- «Всё есть класс со Strategy внутри»: применение паттерна GoF к точке, где вариативности нет и не предвидится — это уже Speculative Generality.
Почему это устойчиво. Знакомый инструмент реально снижает риск исполнения: команда умеет его эксплуатировать, знает грабли, есть готовые пайплайны. Это настоящая ценность, и её нельзя просто объявить заблуждением. Golden Hammer начинается там, где стоимость несоответствия задаче перестают сравнивать со стоимостью освоения альтернативы.
Правый нижний квадрант — и есть Golden Hammer: инструмент, удобный команде и неподходящий задаче. Опасен он тем, что ощущается как левый верхний — «зато мы это умеем».
Противоядие — процедурное, а не интеллектуальное:
- ADR (Architecture Decision Record). Короткий документ: контекст, рассмотренные варианты, решение, последствия (adr.github.io). Обязательный раздел «что мы рассматривали и почему отвергли» физически не даёт пропустить шаг сравнения.
- Правило двух альтернатив. Ни одно инфраструктурное решение не принимается с одним кандидатом в списке.
- Явная запись сил. Что именно в задаче требует этого инструмента: объём? Задержка? Долговечность? Порядок доставки? Если ни одна сила не названа — вы выбираете по привычке.
Lava Flow, Boat Anchor и мёртвый код
Lava Flow (термин из книги AntiPatterns): куски кода, оставшиеся от прошлых архитектурных
подходов, которые «затвердели» — их никто не понимает и все боятся удалять. Симптомы: комментарии
вида # не удалять, ломается прод, классы с суффиксом V2/Old/New/Legacy, конфиги с
флагами, значение которых никто не может объяснить.
Boat Anchor — компонент, оставленный «на всякий случай»: подключенная библиотека без использования, закомментированный блок на 200 строк, целый неиспользуемый модуль.
Механика одна: удаление ощущается как риск без выгоды. Оставить — 0 минут и 0 риска, удалить — 2 часа проверок и ненулевой шанс уронить прод. Экономика опять против правильного действия.
Что ломает эту экономику:
- Инструменты покрытия в проде. Не тестового покрытия, а рантайм-трейс: какие ветки реально
исполнялись за 90 дней.
coverage.pyна sampling-режиме, JaCoCo-агент, eBPF-профилировщики, логирование входа в подозрительные функции. Код, не выполнявшийся квартал под полной нагрузкой (включая отчётные периоды и распродажи), — кандидат на удаление с фактами, а не с ощущениями. - Git как страховка. Удалённый код не утрачен, он в истории. Это надо проговаривать вслух:
главный страх «а вдруг понадобится» решается одной командой
git revert. - Deprecation с датой. Флаг
@deprecated(remove_after="2026-09-01"), который после даты валит сборку. Без даты депрекация — просто вежливая форма Boat Anchor.
# Практичный приём: «мёртвый код с сигнализацией».
# Перед удалением подозрительной ветки не вырезаем её, а ставим маячок на один релизный цикл.
import logging, functools
log = logging.getLogger("deadcode")
def suspected_dead(reason: str):
"""Помечает функцию как предположительно мёртвую и логирует факт вызова.
Через 30–90 дней смотрим счётчик: ноль вызовов под полной нагрузкой — удаляем
с фактическим обоснованием, а не 'вроде не используется'."""
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
log.warning("DEAD_CODE_HIT %s (%s)", fn.__qualname__, reason)
return fn(*args, **kwargs)
return wrapper
return deco
@suspected_dead(reason="миграция на новый биллинг, RFC-114")
def legacy_invoice_export(order_id: int) -> bytes:
...
Стоимость приёма — один вызов логгера на исполнение, O(1). Выгода — превращение спора о рисках
в измерение.
Copy-Paste Programming и Shotgun Surgery
Скопированный блок — самый дешёвый способ не сломать существующее. Цена приходит потом: логика расходится, баг чинится в трёх местах из пяти, и появляется Shotgun Surgery — одно изменение требований заставляет править десяток файлов.
Диагностика объективна: детекторы клонов (jscpd, PMD CPD, SonarQube) плюс метрика связанных
изменений из истории git — какие файлы систематически меняются в одном коммите
(«temporal coupling», Adam Tornhill, «Software Design X-Rays»).
Но здесь нужна осторожность, и это одно из самых важных мест главы. Сэнди Метц, «The Wrong Abstraction» (2016):
Дублирование гораздо дешевле, чем неправильная абстракция.
Механика провала: три похожих куска склеили в общую функцию. Появилось четвёртое требование, чуть
другое — добавили параметр if is_premium. Потом пятое — ещё флаг. Через год у функции семь
булевых параметров, она обслуживает семь несовместимых сценариев, и распутать её дороже, чем
было бы поддерживать семь копий. Преждевременное устранение дублирования — самостоятельный
антипаттерн.
Рабочее правило:
- Дублирование знания (одна бизнес-формула в двух местах) — устранять сразу, это DRY в исходном смысле Ханта и Томаса: «у каждого знания должно быть единственное авторитетное представление».
- Дублирование текста при разном знании (два похожих по форме, но независимо эволюционирующих сценария) — оставлять. Совпадение формы не означает общей причины изменения.
- Проверочный вопрос ровно один: изменятся ли эти два места по одной и той же причине? Это же и есть Single Responsibility Principle в формулировке Мартина — подробно в треке принципов.
Speculative Generality и Premature Optimization
Два антипаттерна с общим корнем: работа под предсказание будущего, которое не сбылось.
Speculative Generality — абстракция, введённая «на будущее». Интерфейс с единственной реализацией «на случай если появится вторая». Фабрика, создающая один тип. Параметр конфигурации, который никогда не менялся. Слой репозиториев «чтобы можно было сменить БД» (её не сменили за восемь лет ни разу).
Цена: каждый читатель кода обязан пройти лишний уровень косвенности; каждая правка требует изменения двух файлов вместо одного; IDE «go to definition» приводит в интерфейс, а не в код.
// Speculative generality: три уровня косвенности ради одной реализации,
// которая читает переменную окружения.
interface ConfigProvider { get(key: string): string | undefined; }
interface ConfigProviderFactory { create(env: string): ConfigProvider; }
class EnvConfigProvider implements ConfigProvider {
get(key: string) { return process.env[key]; }
}
class DefaultConfigProviderFactory implements ConfigProviderFactory {
create(_env: string): ConfigProvider { return new EnvConfigProvider(); }
}
// Всё, что здесь реально происходит:
const cfg = new DefaultConfigProviderFactory().create("prod").get("DB_URL");
// Честная версия. Шов появится тогда, когда появится вторая реализация —
// и вводить его будет проще, чем поддерживать всё время до этого.
const dbUrl = process.env.DB_URL;
Правило — YAGNI (Fowler, bliki): стройте абстракцию на втором реальном случае, не на первом воображаемом. Рефакторинг «извлечь интерфейс» в любой современной IDE — операция на 10 секунд; поддержка ненужного интерфейса — операция на годы.
Premature Optimization — вторая половина. Полная цитата Кнута («Structured Programming with go to Statements», Computing Surveys, 1974), которую почти всегда обрезают:
Программисты тратят огромное количество времени, размышляя о скорости некритичных частей программ… В примерно 97% случаев следует забыть о малой эффективности: преждевременная оптимизация — корень всех зол. Но мы не должны упускать возможности в этих критических 3%.
Второе предложение — не оговорка. Кнут не запрещает оптимизацию, он требует сначала измерить,
какие 3% критичны. Практическая процедура: профилировщик → находим горячий участок → оптимизируем
его → снова профилируем и подтверждаем выигрыш на реальном профиле нагрузки. Оптимизация без
профиля — это угадывание, и оно систематически промахивается: узкое место чаще оказывается в
N+1-запросе или в сериализации, а не в том цикле, который вы переписали на векторные инструкции.
Оговорка: к асимптотике и к выбору структуры данных это не относится. Заменить O(n²) на
O(n log n) в момент написания — не преждевременная оптимизация, а нормальное проектирование:
переделать это потом стоит на порядок дороже. Подробности — в треках
структуры данных и
алгоритмы.
Anemic Domain Model
Классы домена содержат только поля и геттеры/сеттеры, а вся логика лежит в «сервисах», которые эти поля читают и пишут. Мартин Фаулер назвал это антипаттерном в AnemicDomainModel (2003): формально это ООП, фактически — процедурный код с лишним слоем, потому что данные и операции над ними разделены, а это ровно то, против чего вводили инкапсуляцию.
# Анемично: инварианты не защищены — любой код может сделать total отрицательным
class Order:
def __init__(self): self.items, self.status, self.total = [], "new", 0
class OrderService:
def add_item(self, order: Order, item: dict) -> None:
order.items.append(item)
order.total += item["price"] * item["qty"] # правило живёт снаружи объекта
# Богатая модель: инвариант «total = сумма позиций» невозможно нарушить извне
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class LineItem:
sku: str
qty: int
price: Decimal
def __post_init__(self):
if self.qty <= 0:
raise ValueError("количество должно быть положительным")
class Order:
def __init__(self) -> None:
self._items: list[LineItem] = []
self._status = "new"
def add(self, item: LineItem) -> None:
if self._status != "new":
raise RuntimeError("нельзя менять подтверждённый заказ")
self._items.append(item)
@property
def total(self) -> Decimal: # вычисляемое, а не хранимое —
return sum((i.price * i.qty for i in self._items), Decimal(0)) # рассинхрон невозможен
def confirm(self) -> None:
if not self._items:
raise RuntimeError("пустой заказ нельзя подтвердить")
self._status = "confirmed"
Важная оговорка. Это самый спорный пункт каталога. Анемичная модель уместна, когда домен
действительно тонкий (CRUD-админка, ETL-пайплайн), и является нормой в функциональных языках, где
данные и функции разделены осознанно, а инварианты защищены типами и конструкторами-смарт-функциями
(см. функциональные паттерны). Антипаттерн — не
«данные отдельно от функций», а «объявили DDD, завели Entity и ValueObject, а логику всё равно
сложили в Service»: платим полную цену церемонии, не получая её выгод.
Организационные антипаттерны
Больше половины технических антипаттернов имеют организационную причину. Закон Конвея работает в обе стороны: структура коммуникации отпечатывается в архитектуре.
| Антипаттерн | В чём проявляется | Что делать |
|---|---|---|
| Cargo Cult Programming | Копируют форму практики без её причины: скрам-ритуалы без обратной связи, микросервисы без независимого деплоя, тесты ради процента покрытия | Для каждой практики письменно: какую проблему решает и по какому сигналу поймём, что перестала работать |
| Design by Committee | Решение — объединение всех пожеланий: 14 флагов конфигурации, три способа сделать одно и то же | Один владелец решения с правом сказать «нет»; ADR фиксирует отвергнутое |
| Analysis Paralysis | Полгода проектирования без единой строки кода | Тайм-бокс исследования; прототип как способ получить информацию, а не как обязательство |
| Not Invented Here | Свой ORM, свой формат сериализации, своя система очередей | Правило: пишем сами, только если это ядро бизнеса; всё остальное — зрелая библиотека |
| Death March | Сроки, в которые не верит никто, включая менеджмент (термин Эда Йордона, 1997) | Честная оценка с диапазоном; сокращение объёма, а не сжатие оценки |
| Second System Effect | Вторая система у того же архитектора обрастает всем, что не влезло в первую (Брукс, «Мифический человеко-месяц») | Явный бюджет объёма; фичи, вошедшие только «потому что теперь можем», вырезаются |
| Big Rewrite | «Перепишем с нуля, старое безнадёжно» | Strangler fig; см. ниже |
Отдельно про Big Rewrite. Джоэл Спольски, «Things You Should Never Do, Part I» (2000), на примере Netscape: переписывание с нуля выбрасывает не код, а знание. Каждая некрасивая ветка в старом коде — это, как правило, исправленный баг, о котором никто уже не помнит. Новая система будет чистой ровно до момента, когда встретит те же граничные случаи реального мира — и снова обрастёт ветками, но уже без тестов и без опыта.
Работающая альтернатива — Strangler Fig (Fowler): перед старой системой ставится фасад, функциональность переносится по кускам, каждый кусок сразу идёт в прод, старый код удаляется по мере освобождения. Ценность в том, что откатиться можно на любом шаге.
Шаг 2 — тот, который чаще всего пропускают, и зря: теневой запуск даёт объективную метрику расхождений до того, как расхождение увидит пользователь.
Как обнаруживать: hotspot-анализ
Главная практическая проблема — не «какие бывают антипаттерны», а где именно в моём репозитории на 300 000 строк они мне вредят. Сложность сама по себе не проблема: сложный, но никогда не меняющийся модуль вас не беспокоит. Проблема — на пересечении сложности и частоты изменений.
Идею предложил Майкл Физерс (churn vs complexity), развил и довёл до инструмента Адам Торнхилл в «Your Code as a Crime Scene» и CodeScene. Ниже — самодостаточная реализация на стандартной библиотеке: сложность оценивается по отступам (whitespace complexity — грубая, но на удивление хорошо коррелирующая с цикломатической прокси-метрика, работающая для любого языка).
"""Hotspot-анализ: пересечение частоты изменений и сложности.
Запуск: python hotspots.py /путь/к/репозиторию
"""
import subprocess
import sys
from collections import Counter
from pathlib import Path
CODE_EXT = {".py", ".go", ".ts", ".js", ".java", ".cs", ".rb", ".kt", ".ex"}
def churn(repo: Path, since: str = "12 months ago") -> Counter[str]:
"""Сколько коммитов затронуло каждый файл. O(K) по числу строк git log."""
out = subprocess.run(
["git", "-C", str(repo), "log", f"--since={since}",
"--no-merges", "--name-only", "--format="],
capture_output=True, text=True, check=True,
).stdout
counts: Counter[str] = Counter()
for line in out.splitlines():
line = line.strip()
if line and Path(line).suffix in CODE_EXT:
counts[line] += 1
return counts
def indent_complexity(path: Path, tab_width: int = 4) -> float:
"""Прокси сложности: сумма уровней отступа по значимым строкам.
Логика: вложенность (if внутри for внутри try) — то же самое, что измеряет
цикломатическая сложность, но считается для любого языка без парсера.
O(L) по числу строк файла."""
total = 0.0
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return 0.0
for raw in text.splitlines():
stripped = raw.lstrip()
if not stripped or stripped.startswith(("#", "//", "*", "/*")):
continue # комментарии и пустые строки не считаем
prefix = raw[: len(raw) - len(stripped)]
spaces = prefix.count(" ") + prefix.count("\t") * tab_width
total += spaces / tab_width
return total
def hotspots(repo: Path, top: int = 20) -> list[tuple[str, int, float, float]]:
"""Ранжирование по произведению нормированных churn и complexity.
Произведение (а не сумма) выбрано намеренно: файл, слабый по любой
из осей, не должен попадать в топ. Сложность: O(K + Σ L_f)."""
ch = churn(repo)
if not ch:
return []
rows = []
for rel, commits in ch.items():
p = repo / rel
if not p.exists(): # файл удалён — уже не проблема
continue
rows.append((rel, commits, indent_complexity(p)))
max_c = max(r[1] for r in rows) or 1
max_x = max(r[2] for r in rows) or 1.0
scored = [(rel, c, x, (c / max_c) * (x / max_x)) for rel, c, x in rows]
scored.sort(key=lambda r: r[3], reverse=True)
return scored[:top]
if __name__ == "__main__":
repo = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
print(f"{'файл':<58}{'коммитов':>10}{'сложность':>12}{'score':>9}")
for rel, commits, cx, score in hotspots(repo):
print(f"{rel:<58}{commits:>10}{cx:>12.0f}{score:>9.3f}")
Сложность. git log — O(K) по числу записей истории; чтение файлов — O(Σ L_f) по суммарному
размеру изменявшихся файлов; сортировка — O(F log F). На репозитории в 300 тысяч строк с
пятилетней историей отрабатывает за секунды. Память — O(F).
Как читать результат. Верхние 5–10 строк — это, как правило, буквально ваши God Object’ы: они и сложны, и меняются постоянно. Это и есть очередь рефакторинга, отсортированная по фактической цене, а не по субъективному «мне тут не нравится». Хороший ход — приносить этот отчёт на планирование: разговор «вот файл, который менялся 214 раз за год и в котором каждое изменение занимает три дня» идёт заметно продуктивнее, чем «нам надо порефакторить».
Ограничения, которые надо знать. Метрика по отступам врёт на языках без принятого отступа и на
автоформатированном коде с длинными цепочками. Массовый churn может быть артефактом переименования
или прогона форматтера — такие коммиты стоит отфильтровать (git log --no-merges уже помогает,
плюс исключение известных «косметических» хешей). И главное: hotspot — это сигнал, а не диагноз;
файл routes.py, который меняется при каждой новой ручке, может быть совершенно здоров.
Что ещё поставить в CI
- Порог сложности на изменение, а не на репозиторий. Валить сборку из-за исторического долга бессмысленно — команда отключит проверку. Работает правило «новый или изменённый код не должен ухудшать метрику» (ratchet): SonarQube называет это Clean as You Code.
- Проверка границ (
import-linter, ArchUnit) — единственная защита от эрозии модульности. - Детектор клонов с порогом на новые дубликаты.
- Отчёт по temporal coupling — какие файлы меняются вместе. Пара файлов из разных модулей с 90% совместных изменений — это неявная связь, которую не видно ни в одном импорте.
Типичные ошибки при борьбе с антипаттернами
- Рефакторинг без тестов. Самая частая и самая дорогая. По определению рефакторинг — изменение структуры без изменения поведения; без тестов вы не «рефакторите», а переписываете вслепую. Порядок всегда: характеризационные тесты → изменение структуры.
- Рефакторинг ради чистоты, а не ради цены изменения. Приводить в порядок стабильный модуль, который никто не трогает год, — работа с нулевой отдачей. Начинайте с hotspot-топа.
- Одна большая ветка на три месяца. Умирает от конфликтов. Только короткоживущие ветки и feature toggles.
- Замена антипаттерна на другой антипаттерн. God Object распилили на 40 анемичных классов, между которыми теперь плавает Poltergeist-оркестратор. Проверка одна: LCOM4 = 1 у каждого куска и число межмодульных рёбер уменьшилось.
- Перфекционизм по каталогу. «У нас Anemic Domain Model» в CRUD-админке — не проблема, требующая исправления. Антипаттерн становится проблемой, только когда за него уже платят временем или инцидентами.
- Борьба без бюджета. Рефакторинг «в свободное время» не происходит никогда. Работает фиксированная доля спринта (обычно 10–20%), защищённая от переноса, — или явные задачи в бэклоге.
Мини-итог
| Антипаттерн | Ранний сигнал | Метрика | Выход |
|---|---|---|---|
| God Object | >5 зависимостей в конструкторе, имя *Manager |
LCOM4 > 1, WMC > 50, CBO > 15 | Extract Class по компонентам связности |
| Big Ball of Mud | «Проще добавить сюда, чем понять, куда правильно» | Плотность рёбер графа зависимостей, число циклов | Проверяемые границы + strangler fig |
| Golden Hammer | «Мы всегда так делаем» вместо разбора сил | Число рассмотренных альтернатив в ADR | Правило двух альтернатив, явная запись сил |
| Lava Flow / Boat Anchor | Комментарии «не трогать», суффиксы Old/V2 |
Покрытие в проде за 90 дней | Маячок → факты → удаление |
| Copy-Paste / Shotgun Surgery | Правка бага в трёх местах из пяти | Клоны + temporal coupling | Устранять дублирование знания, не текста |
| Speculative Generality | Интерфейс с одной реализацией | Число реализаций на интерфейс | Удалить косвенность, вернуть на втором случае |
| Premature Optimization | Оптимизация без профиля | Доля времени в участке по профилю | Профилировать → чинить 3% → перепроверять |
| Anemic Domain Model | Инварианты проверяются в сервисах | Логика в *Service против сущностей |
Переносить правила к данным, которые они охраняют |
| Big Rewrite | «Старое безнадёжно, начнём с нуля» | — | Strangler fig с теневым запуском |
Главная мысль главы: антипаттерн — это не про эстетику кода, а про производную стоимости изменения по времени. Если добавление похожей фичи сегодня стоит дороже, чем полгода назад, — у вас антипаттерн, даже если код проходит все линтеры. Если не стоит дороже — возможно, у вас просто некрасивый, но здоровый код, и трогать его не нужно.
Источники
- William Brown, Raphael Malveau, Hays McCormick, Thomas Mowbray, «AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis», Wiley, 1998 — страница издателя.
- Brian Foote, Joseph Yoder, «Big Ball of Mud», PLoP 1997 — laputan.org/mud.
- David Parnas, «On the Criteria To Be Used in Decomposing Systems into Modules», CACM, 1972 — dl.acm.org.
- Martin Fowler, «Refactoring: Improving the Design of Existing Code», 2-е изд., 2018 — martinfowler.com/books/refactoring.html; каталог запахов и рефакторингов также на refactoring.guru/refactoring/smells.
- Michael Feathers, «Working Effectively with Legacy Code», 2004 — характеризационные тесты, швы, разрыв зависимостей.
- Adam Tornhill, «Your Code as a Crime Scene» (2-е изд., 2024) и «Software Design X-Rays» (2018) — pragprog.com.
- Shyam Chidamber, Chris Kemerer, «A Metrics Suite for Object Oriented Design», IEEE TSE, 1994 — ieeexplore.ieee.org.
- Donald Knuth, «Structured Programming with go to Statements», ACM Computing Surveys, 1974 — dl.acm.org.
- Sandi Metz, «The Wrong Abstraction», 2016 — sandimetz.com.
- Martin Fowler, bliki: AnemicDomainModel, Yagni, StranglerFigApplication, SacrificialArchitecture, TechnicalDebtQuadrant.
- Joel Spolsky, «Things You Should Never Do, Part I», 2000 — joelonsoftware.com.
- Frederick Brooks, «The Mythical Man-Month», 1975/1995 — second-system effect, глава 5.
- Ward Cunningham, отчёт о метафоре долга, OOPSLA 1992 — c2.com/doc/oopsla92.html.
- Инструменты: import-linter, ArchUnit, jscpd, SonarQube Clean as You Code, ADR.
Что дальше
Мы посмотрели на решения, которые разрушают систему. Осталась самая тонкая часть: паттерны из каталога GoF тоже умеют разрушать систему — когда их применяют без сил, которые их оправдывают. Следующая глава — про то, где проходит граница между «применил паттерн» и «усложнил без причины», и как выглядят те же паттерны в реальных кодовых базах.