Принципы разработки: зачем нужны и как не превратить их в карго-культ
Почти каждый разработчик проходит один и тот же путь. Сначала он не знает принципов и пишет код, который через полгода невозможно менять. Потом узнаёт SOLID, DRY, «чистый код» — и начинает писать код, который невозможно менять уже по другой причине: пять слоёв абстракции, семь интерфейсов с одной реализацией и фабрика фабрик. И только на третьем шаге приходит понимание, что принцип — это не заклинание, а сжатый опыт о том, где обычно болит, и применять его нужно, понимая, какую именно боль он лечит и сколько стоит лечение.
Эта статья — вводная к треку. Здесь мы не разбираем SOLID или DRY по пунктам (для этого есть отдельные статьи), а строим систему координат: что такое принцип, откуда принципы взялись исторически, какую экономику они оптимизируют, почему они постоянно конфликтуют друг с другом и как принимать решение, когда два уважаемых принципа тянут в разные стороны.
1. Зачем вообще нужны принципы
Интуиция: шахматные эвристики
В шахматах есть правила игры («слон ходит по диагонали») и есть эвристики («развивай фигуры, контролируй центр, не выводи ферзя рано»). Правила нарушать нельзя — партия просто не состоится. Эвристики нарушать можно и иногда нужно: гроссмейстер выводит ферзя на четвёртом ходу, потому что видит конкретную позицию, а не потому, что не слышал про эвристику.
Принципы разработки — это эвристики, а не правила. Компилятор не откажется собирать код с нарушением Single Responsibility. Программа с божественным классом работает. Разница проявляется не в момент запуска, а через шесть месяцев — в стоимости следующего изменения.
Строгая формулировка: принципы оптимизируют стоимость изменения
Полезно думать о проекте как о потоке изменений. Каждое изменение стоит:
C_change = C_find + C_understand + C_edit + C_verify + C_risk
C_find— найти места, которые нужно тронуть;C_understand— понять, что там происходит, и не сломать смежное;C_edit— собственно написать код;C_verify— убедиться, что не сломалось (тесты, ревью, стенд);C_risk— матожидание стоимости инцидента, если всё-таки сломалось.
Начинающий разработчик оптимизирует C_edit — он считает, что «писать код» и есть работа. Реальность в том, что на зрелом проекте C_edit — это единицы процентов, а доминируют C_understand и C_verify. Все принципы разработки без исключения — это способы удержать C_understand, C_find и C_risk от роста по мере роста системы.
Отсюда сразу следует важный вывод: принцип, который в вашем контексте не снижает эти слагаемые, — бесполезен, даже если он написан в очень уважаемой книге.
Почему стоимость растёт нелинейно
Возьмём систему из n модулей. Если модуль может напрямую обращаться к любому другому, число потенциальных связей — n·(n−1)/2 = O(n²). Чтобы уверенно изменить один модуль, в худшем случае нужно рассмотреть все его связи, и стоимость понимания системы растёт квадратично.
Если же связи ограничены слоями/модулями и каждый компонент общается только с несколькими соседями через явные контракты, число рёбер становится O(n), а стоимость понимания одного изменения — O(1) относительно размера системы: вы читаете модуль и его контракт, а не весь проект.
Это и есть главный «алгоритмический» смысл принципов: перевести рост сложности из квадратичного в линейный. Модульность, инкапсуляция, слои, границы контекстов, инверсия зависимостей — всё это разные способы удалить рёбра из графа связей.
График выше — вариация «design payoff line» Мартина Фаулера (Design Stamina Hypothesis). Проектирование стоит денег сразу, а окупается позже. До точки пересечения дисциплина — чистый убыток; после — единственное, что удерживает проект на плаву. Практический смысл: горизонт жизни кода — обязательный параметр решения. Скрипт на выброс и биллинг, который проживёт десять лет, — это разные экономики, и одинаковые принципы к ним применять нельзя.
2. Что такое принцип: лестница нормативности
Слова «принцип», «правило», «паттерн», «практика» в статьях употребляют вперемешку, и это одна из причин карго-культа. Разведём их.
- Ценность — что мы считаем хорошим. «Код читают в десятки раз чаще, чем пишут». Ценности не доказываются, они выбираются и почти не меняются десятилетиями.
- Принцип — направление, следующее из ценности. «Уменьшай связанность», «модуль должен иметь одну причину для изменения». Принцип не говорит, что именно делать в коде, — он говорит, что считать улучшением.
- Эвристика / паттерн — типовой ход, реализующий принцип в узнаваемой ситуации. «Правило трёх», Strategy вместо цепочки
if, вынесение интерфейса на границе процесса. - Правило — механически проверяемое утверждение. «Строка ≤ 120 символов», «домен не импортирует инфраструктуру». Правила можно и нужно автоматизировать: линтер, форматтер, CI.
Карго-культ рождается ровно тогда, когда человек выучил нижние два уровня и не знает верхних. Он видит, что «у взрослых» много интерфейсов, и создаёт интерфейс на каждый класс — не понимая, что интерфейс существует, чтобы разорвать связь, а интерфейс с единственной реализацией и единственным потребителем ничего не разрывает, зато добавляет файл, прыжок при чтении и лишний шаг при отладке.
Термин — из эссе Ричарда Фейнмана «Cargo Cult Science»: островитяне строили из дерева наушники и вышку, потому что видели, что перед прилётом самолётов с грузом это было. Форма воспроизведена идеально, самолёты не прилетают.
Мини-тест на карго-культ
Для любой практики в вашем коде задайте три вопроса:
- Какую конкретную боль это предотвращает? Если ответ «так принято» или «так в книге» — тревожный сигнал.
- Как выглядел бы код без этого и что бы сломалось? Если ничего — практика лишняя.
- Что должно произойти, чтобы мы это убрали? Если ничто — это догма, а не инженерное решение.
3. Краткая история: откуда всё это взялось
Принципы — не мода 2010-х. Почти всё, чем мы пользуемся, сформулировано в 1968–1988 годах, когда индустрия впервые столкнулась с системами, которые невозможно удержать в голове.
Что важно вынести из истории:
- Принципы отвечали на конкретный кризис. Сокрытие информации Парнаса (оригинальная статья 1972 года) появилось не из эстетики: Парнас показал экспериментально, что декомпозиция «по шагам обработки» разваливается при изменении требований, а декомпозиция «по скрываемым решениям» — нет.
- Формулировки старше своих названий. «SOLID» как акроним придумал Майкл Фезерс уже в 2000-х, а сами принципы Роберт Мартин собрал в статье Design Principles and Design Patterns.
- Контекст был другим. Многие принципы формулировались для монолитных ООП-систем на статически типизированных языках с дорогой компиляцией и без нормальных тестов. Часть их веса в 2020-х снизилась: например, OCP частично закрывается фича-флагами и быстрым деплоем, а не иерархиями классов.
4. Карта трека: как принципы связаны между собой
Читать трек можно подряд, но полезно держать в голове зависимость: связанность и связность — фундамент, на котором стоит всё остальное. SOLID — это, по сути, пять частных рецептов снижения связанности и повышения связности в ООП. DRY — попытка убрать дублирование знания, которое создаёт неявную связанность. Тесты — инструмент, который делает связанность видимой (сильно связанный код трудно тестировать, и это самый честный детектор). Разбор фундамента — в статье Связанность, связность и закон Деметры.
5. Как принцип превращается в ритуал
Механизм превращения живого принципа в карго-культ повторяется настолько устойчиво, что его удобно описать как жизненный цикл.
Ключевой переход — от Правила к Ритуалу. Он происходит бесшумно: правило не меняется, меняется контекст. Пример: правило «каждый сервис прячем за интерфейсом» имело огромный смысл, когда моки писали руками, а тестовые двойники нельзя было создать иначе. С появлением нормальных мок-библиотек и внедрения зависимостей через конструктор ценность правила упала в разы — но правило осталось, потому что его никто не отменял.
Отсюда практическое следствие: у каждого командного правила должна быть записана его причина. Не «мы так делаем», а «мы так делаем, потому что «боль»; отменим, если «условие»». Это ровно то, что делают Architecture Decision Records — короткие записи о решениях с контекстом и последствиями (см. шаблон Майкла Найгарда).
6. Разбор на коде: одна и та же формула — не одно и то же знание
Самый частый и самый дорогой карго-культ — механический DRY. Разберём его подробно, потому что пример показателен для всех остальных принципов.
Есть две функции. Они выглядят одинаково.
# billing/invoice.py — расчёт НДС для счёта клиенту
def invoice_tax(amount: float) -> float:
return round(amount * 0.20, 2)
# reporting/vat_report.py — расчёт НДС для квартального отчёта в налоговую
def report_tax(amount: float) -> float:
return round(amount * 0.20, 2)
Разработчик видит дублирование и «исправляет» его:
# common/tax.py
def calc_tax(amount: float) -> float:
return round(amount * 0.20, 2)
Через полгода приходят два независимых требования: для счетов вводится льготная ставка 10% на часть категорий, а в отчётности округление должно идти не до копеек, а вниз до целого рубля. Общая функция начинает обрастать:
# common/tax.py — как это выглядит через полгода
def calc_tax(
amount: float,
*,
category: str | None = None,
for_report: bool = False,
legacy_rounding: bool = False,
) -> float:
rate = 0.10 if category in REDUCED_RATE_CATEGORIES else 0.20
if for_report:
rate = 0.20 # в отчёте льготы учитываются отдельной строкой
tax = amount * rate
if for_report and not legacy_rounding:
return float(int(tax)) # вниз до рубля
return round(tax, 2)
Это классическая «неправильная абстракция». Сэнди Метц сформулировала её последствие точнее всех: «дублирование дешевле неправильной абстракции». Функция теперь:
- имеет флаги, значение которых понятно только тому, кто знает обе доменные области;
- нарушает Single Responsibility гораздо грубее, чем исходное дублирование;
- создаёт связанность между биллингом и отчётностью, которой в реальности нет: изменение в одном домене физически способно сломать другой.
Правильное прочтение DRY — из «Прагматичного программиста»: «Каждый фрагмент знания должен иметь единственное, непротиворечивое и авторитетное представление в системе». Ключевое слово — знание, а не текст. Совпадение текста двух функций было случайным: ставка 20% в счёте и ставка 20% в отчёте — это два разных бизнес-факта, которые сегодня численно равны.
Что действительно стоило вынести — это доменный факт, а не арифметику:
# tax/rates.py — единственный источник правды о ставках
from decimal import Decimal
from datetime import date
STANDARD_VAT = Decimal("0.20")
REDUCED_VAT = Decimal("0.10")
def vat_rate(category: str, on: date) -> Decimal:
"""Ставка НДС для категории на дату. Здесь — одно знание: какие бывают ставки."""
...
# billing/invoice.py — своя политика округления, своя ответственность
def invoice_tax(amount: Decimal, category: str, on: date) -> Decimal:
return (amount * vat_rate(category, on)).quantize(Decimal("0.01"))
# reporting/vat_report.py — другая политика, намеренно отдельный код
def report_tax(amount: Decimal) -> Decimal:
return (amount * STANDARD_VAT).quantize(Decimal("1"), rounding="ROUND_DOWN")
Обратите внимание: строк стало больше. Дублирование amount * rate осталось. Но связанность между модулями исчезла, а единственное настоящее общее знание — таблица ставок — вынесено явно. Подробнее эта развилка разбирается в статье
DRY, KISS, YAGNI и цена преждевременной абстракции.
Тот же сюжет в статически типизированном коде
// Карго-культ: интерфейс на каждый класс "чтобы соблюдать DIP"
export interface IUserService {
getUser(id: string): Promise<User>;
}
export class UserService implements IUserService { /* единственная реализация */ }
// Что здесь плохо:
// 1) нет второй реализации и не предвидится — связь не разорвана, а размазана;
// 2) переход к определению метода теперь ведёт в интерфейс, а не в код;
// 3) правки требуют синхронного изменения двух файлов.
// Осмысленный DIP: абстракция появляется там, где реально есть граница.
export interface PaymentGateway { // за границей — внешняя система
charge(order: OrderId, sum: Money): Promise<ChargeResult>;
}
export class StripeGateway implements PaymentGateway { /* ... */ }
export class SandboxGateway implements PaymentGateway { /* для тестов и демо */ }
Правило-подсказка: абстракция оправдана там, где проходит граница изменчивости — граница процесса, внешней системы, команды, релизного цикла или лицензии. Внутри одного модуля, который меняется целиком и одной командой, абстракция чаще всего только мешает.
7. Алгоритм принятия решения
Когда принципы конфликтуют, нужен не спор о том, чья книга авторитетнее, а процедура. Вот рабочая версия, которую можно повесить в вики команды.
хочу добавить абстракцию] --> B{Это одно знание
или совпадение текста?} B -- совпадение --> Z[Оставить как есть
и записать TODO-наблюдение] B -- одно знание --> C{Меняются ли места
вместе исторически?} C -- нет --> Z C -- да --> D{Сколько повторений?} D -- 2 --> E[Подождать третьего случая
правило трёх] D -- 3 и более --> F{Абстракция выражается
без флагов и режимов?} F -- нет --> G[Абстракция преждевременна:
границу выбрали неверно] F -- да --> H{Горизонт жизни кода
больше нескольких месяцев?} H -- нет --> Z H -- да --> I[Выделять абстракцию] G --> J[Искать другую ось
разделения: по домену, по слою] J --> B I --> K[Записать в ADR:
какую боль лечим и когда откатим] style Z fill:#3f9c8a,stroke:#2c6f63,color:#fff style I fill:#4a8fd1,stroke:#356ba0,color:#fff style G fill:#d1704b,stroke:#a2543a,color:#fff
Тот же алгоритм в виде исполняемой эвристики — полезно, когда нужно объяснить решение на ревью цифрами, а не вкусом:
from dataclasses import dataclass
@dataclass
class AbstractionCase:
occurrences: int # сколько раз повторяется код
changed_together: int # сколько раз из последних правок менялись синхронно
changed_apart: int # сколько раз менялись независимо друг от друга
crosses_boundary: bool # пересекает ли границу домена/сервиса/команды
lifetime_months: int # ожидаемый горизонт жизни кода
params_needed: int # сколько флагов/режимов потребует общая версия
def should_extract(c: AbstractionCase) -> tuple[bool, str]:
"""Грубая, но честная модель: абстракция полезна, если места меняются вместе,
объединяются без флагов и код проживёт достаточно долго."""
if c.changed_apart > c.changed_together:
return False, "меняются независимо — это не одно знание, а совпадение"
if c.params_needed >= 2:
return False, "нужны флаги режима — граница абстракции выбрана неверно"
if c.crosses_boundary and c.occurrences < 4:
return False, "связывать домены дороже, чем дублировать"
if c.lifetime_months < 3:
return False, "код не проживёт до окупаемости"
if c.occurrences < 3:
return False, "правило трёх: подождать третьего случая"
return True, "выделять: одно знание, стабильная граница, окупится"
Сложность самого решения — O(1), а вот сбор входных данных стоит денег: changed_together считается по истории репозитория. Это делается за один проход по логу:
# Файлы, которые чаще всего меняются в одном коммите с целевым (индекс "логической связанности")
git log --format='%H' --name-only -- src/billing/invoice.py \
| grep -v '^$' \
| sort | uniq -c | sort -rn | head -20
Такой анализ — основа метода «code maat» Адама Торнхилла (Your Code as a Crime Scene): если два файла постоянно меняются вместе, между ними есть связь, даже если в коде нет ни одного импорта. Это самая недооценённая метрика связанности — временна́я связанность видна в истории, а не в статическом анализе.
8. Конфликты принципов: их не избежать, ими управляют
Принципы противоречат друг другу по построению — каждый оптимизирует свою ось.
| Конфликт | Одна сторона | Другая сторона | Как разрешают |
|---|---|---|---|
| DRY против расцепления | не повторяй знание | не связывай независимые модули | по признаку «меняется ли вместе» |
| SRP против «не плоди файлы» | один класс — одна причина изменения | навигация не должна требовать 12 прыжков | по размеру команды и частоте правок |
| OCP против YAGNI | расширяй, не меняя | не проектируй под гипотезы | по числу реальных, а не воображаемых вариаций |
| Инкапсуляция против отладки | скрывай детали | детали нужны при инциденте | наблюдаемость: логи, трассировки, метрики |
| Тесты юнит против интеграционных | быстро и точечно | проверяет реальность | по стоимости дефекта в конкретной зоне |
| Явность против краткости | всё видно в коде | меньше шума | явность на границах, краткость внутри |
Универсального разрешения нет — есть контекстные вопросы: сколько людей трогает этот код, как часто, какова цена ошибки, каков горизонт жизни. Ровно поэтому в принципах нельзя быть «фундаменталистом»: любая позиция, доведённая до предела, начинает вредить.
Хорошая формулировка эталона — у Кента Бека: «Сначала сделай изменение простым (внимание: это может быть трудно), затем сделай простое изменение» (оригинал). Это метапринцип: не «соблюдай SOLID», а «приведи код в состояние, в котором нужное изменение становится локальным».
чтобы через год не повторять
Обратите внимание на структуру спора: аргумент «DRY» сам по себе ничего не решает, потому что принцип — это направление, а не вердикт. Решает проверяемый факт (история изменений) плюс зафиксированное решение. Это же — рабочая модель код-ревью; подробнее в статье Код-ревью, командные стандарты и Definition of Done.
9. Как это выглядит в проде
Принципы, оставшиеся в головах, деградируют. В зрелых командах они существуют в трёх формах.
9.1. Автоматизированные правила (дешёвые и безжалостные)
То, что можно проверить машиной, не должно обсуждаться на ревью. Пример: запрет зависимости домена от инфраструктуры на Python через import-linter:
# setup.cfg
[importlinter]
root_package = shop
[importlinter:contract:layers]
name = Слои не могут смотреть вверх
type = layers
layers =
shop.web
shop.application
shop.domain
[importlinter:contract:independence]
name = Домены независимы друг от друга
type = independence
modules =
shop.domain.billing
shop.domain.reporting
Аналоги: ArchUnit для Java/Kotlin, go vet плюс depguard для Go,
dependency-cruiser для TypeScript,
.editorconfig и Roslyn-анализаторы для C#. Такие проверки называют
архитектурными fitness-функциями:
принцип превращается в тест, который падает в CI.
9.2. Метрики, а не мнения
DORA-исследования (dora.dev/research) показали, что четыре метрики — частота деплоя, время от коммита до прода, доля неудачных изменений и время восстановления — коррелируют с организационным результатом. Практическая польза: спор «стало ли лучше от нашей дисциплины» переводится в измеримую плоскость. Если после введения десяти новых правил lead time вырос, а change failure rate не упал — правила не работают.
Полезные локальные метрики того же рода:
- доля PR, требующих правок более чем в 3 модулях (растёт — связанность растёт);
- медианное время до первого зелёного CI;
- количество файлов, которые меняются вместе чаще, чем в 60% коммитов;
- возраст самого старого «мы это потом отрефакторим».
9.3. Записанные решения
ADR (Architecture Decision Record) — короткий файл в репозитории: контекст, решение, последствия, статус. Ценность не в бюрократии, а в том, что через год новый человек видит причину, а не только результат. Это единственное известное противоядие от превращения правила в ритуал: ритуал не переживает записанного «отменим, когда…».
10. Типичные ошибки
- Принцип как аргумент в споре. «Это нарушает SRP» — не аргумент, пока не сказано, какое будущее изменение станет дороже. Требуйте сценарий, а не ярлык.
- Оптимизация под воображаемое будущее. Абстракции «на всякий случай» почти никогда не попадают в реальную ось изменений: когда требование приходит, оно оказывается перпендикулярным заготовленной точке расширения. См. Yagni Фаулера.
- Смешение простоты и лёгкости. Рич Хикки в докладе Simple Made Easy разводит simple (не переплетено, мало сущностей в одном месте) и easy (привычно, близко). Много «лёгких» решений — фреймворковая магия, неявные глобальные состояния — усложняют систему.
- Метрика вместо цели. Как только «покрытие тестами ≥ 80%» становится KPI, появляются тесты без ассертов. Это закон Гудхарта: мера, ставшая целью, перестаёт быть мерой.
- Одинаковая планка для разного кода. Прототип на две недели, внутренний админский скрипт и ядро платёжной системы не должны проходить один и тот же обряд.
- Снос забора без понимания. «Забор Честертона»: прежде чем убрать странный код, выясните, почему он появился. Часто ответ — инцидент, который вы сейчас воспроизведёте.
- Рефакторинг под видом фичи. Смешанный PR («заодно причесал») делает ревью невозможным и прячет риск. Разделяйте: сначала подготовка, потом изменение — это и есть «make the change easy».
- Копирование чужого масштаба. Практики Google/Netflix решают проблемы тысяч инженеров и миллиардов запросов. Команде из пяти человек они чаще вредят: цена координации в них заложена как приемлемая.
11. Как читать этот трек
Дальше принципы разбираются по слоям — от структуры кода к процессу:
- SOLID: пять принципов с честным разбором и критикой — что каждый из них реально означает, где работает и за что его справедливо критикуют.
- DRY, KISS, YAGNI и цена преждевременной абстракции — самая практичная тройка и её тёмная сторона.
- Связанность, связность и закон Деметры — фундамент, к которому сводится почти всё остальное.
- Чистый код: имена, функции, границы, комментарии — уровень ежедневных решений.
- Запахи кода и каталог рефакторингов — как замечать проблему и как её механически исправлять.
- Принципы тестирования — почему тесты являются детектором плохого дизайна, а не только защитой от регрессий.
- Twelve-Factor App и cloud-native принципы — принципы для кода, который живёт в эксплуатации.
- Обработка ошибок, контракты и отказоустойчивое мышление — проектирование поведения при сбоях.
- Код-ревью, командные стандарты и Definition of Done — как принципы живут в команде, а не в голове одного человека.
Полезный смежный контекст: понимание парадигм даёт язык, на котором принципы вообще формулируются — см. обзор парадигм программирования. Многие принципы исторически выражались через ООП-конструкции, но переносятся и на функциональный стиль; в треке по Go видно, как язык с плоской системой типов реализует те же идеи иначе — через интерфейсы, определяемые потребителем, и композицию. Отдельные треки портала по паттернам проектирования, архитектурным паттернам и DDD показывают следующий уровень: типовые решения, собранные из этих принципов.
12. Мини-итог
- Принципы существуют, чтобы удерживать стоимость изменения от нелинейного роста; всё остальное — следствие.
- Ценность → принцип → эвристика → правило. Карго-культ — это применение правила без понимания принципа над ним.
- Любой принцип имеет цену: абстракция, косвенность, лишние файлы и прыжки при чтении. Решение всегда локальное и зависит от контекста: горизонт жизни, размер команды, цена ошибки.
- Принципы конфликтуют по построению. Разрешать конфликт нужно данными (история изменений, метрики) и фиксировать решение (ADR), а не спорить ярлыками.
- То, что проверяется машиной, должно проверяться машиной. Ревью — для смысла, а не для запятых.
- Признак зрелости — не «мы соблюдаем все принципы», а «мы знаем, где сознательно их нарушаем и почему».
Источники
- David L. Parnas. On the Criteria To Be Used in Decomposing Systems into Modules, 1972 — PDF
- Frederick Brooks. No Silver Bullet: Essence and Accidents of Software Engineering, 1986 — PDF
- Melvin Conway. How Do Committees Invent?, 1968 — melconway.com
- Martin Fowler. Is Design Dead? — martinfowler.com
- Martin Fowler. Design Stamina Hypothesis — martinfowler.com
- Sandi Metz. The Wrong Abstraction — sandimetz.com
- John Ousterhout. A Philosophy of Software Design — страница книги
- Rich Hickey. Simple Made Easy — InfoQ
- Richard Feynman. Cargo Cult Science — Caltech
- DORA. State of DevOps research — dora.dev
- Google. Engineering Practices: Code Review — google.github.io/eng-practices
- The Twelve-Factor App — 12factor.net
Что дальше
Следующий шаг — самый известный и самый неправильно понимаемый свод принципов. Разберём каждую букву по существу: исходную формулировку, реальную цель, рабочий пример и обоснованную критику.