Принципы разработки Принципы разработки: зачем нужны и как не превратить их в карго-культ
0%

Принципы разработки: зачем нужны и как не превратить их в карго-культ

Принципы разработки: зачем нужны и как не превратить их в карго-культ

Почти каждый разработчик проходит один и тот же путь. Сначала он не знает принципов и пишет код, который через полгода невозможно менять. Потом узнаёт 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»: островитяне строили из дерева наушники и вышку, потому что видели, что перед прилётом самолётов с грузом это было. Форма воспроизведена идеально, самолёты не прилетают.

Мини-тест на карго-культ

Для любой практики в вашем коде задайте три вопроса:

  1. Какую конкретную боль это предотвращает? Если ответ «так принято» или «так в книге» — тревожный сигнал.
  2. Как выглядел бы код без этого и что бы сломалось? Если ничего — практика лишняя.
  3. Что должно произойти, чтобы мы это убрали? Если ничто — это догма, а не инженерное решение.

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. Алгоритм принятия решения

Когда принципы конфликтуют, нужен не спор о том, чья книга авторитетнее, а процедура. Вот рабочая версия, которую можно повесить в вики команды.

Тот же алгоритм в виде исполняемой эвристики — полезно, когда нужно объяснить решение на ревью цифрами, а не вкусом:

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. Типичные ошибки

  1. Принцип как аргумент в споре. «Это нарушает SRP» — не аргумент, пока не сказано, какое будущее изменение станет дороже. Требуйте сценарий, а не ярлык.
  2. Оптимизация под воображаемое будущее. Абстракции «на всякий случай» почти никогда не попадают в реальную ось изменений: когда требование приходит, оно оказывается перпендикулярным заготовленной точке расширения. См. Yagni Фаулера.
  3. Смешение простоты и лёгкости. Рич Хикки в докладе Simple Made Easy разводит simple (не переплетено, мало сущностей в одном месте) и easy (привычно, близко). Много «лёгких» решений — фреймворковая магия, неявные глобальные состояния — усложняют систему.
  4. Метрика вместо цели. Как только «покрытие тестами ≥ 80%» становится KPI, появляются тесты без ассертов. Это закон Гудхарта: мера, ставшая целью, перестаёт быть мерой.
  5. Одинаковая планка для разного кода. Прототип на две недели, внутренний админский скрипт и ядро платёжной системы не должны проходить один и тот же обряд.
  6. Снос забора без понимания. «Забор Честертона»: прежде чем убрать странный код, выясните, почему он появился. Часто ответ — инцидент, который вы сейчас воспроизведёте.
  7. Рефакторинг под видом фичи. Смешанный PR («заодно причесал») делает ревью невозможным и прячет риск. Разделяйте: сначала подготовка, потом изменение — это и есть «make the change easy».
  8. Копирование чужого масштаба. Практики Google/Netflix решают проблемы тысяч инженеров и миллиардов запросов. Команде из пяти человек они чаще вредят: цена координации в них заложена как приемлемая.

11. Как читать этот трек

Дальше принципы разбираются по слоям — от структуры кода к процессу:

Полезный смежный контекст: понимание парадигм даёт язык, на котором принципы вообще формулируются — см. обзор парадигм программирования. Многие принципы исторически выражались через ООП-конструкции, но переносятся и на функциональный стиль; в треке по Go видно, как язык с плоской системой типов реализует те же идеи иначе — через интерфейсы, определяемые потребителем, и композицию. Отдельные треки портала по паттернам проектирования, архитектурным паттернам и DDD показывают следующий уровень: типовые решения, собранные из этих принципов.


12. Мини-итог

  • Принципы существуют, чтобы удерживать стоимость изменения от нелинейного роста; всё остальное — следствие.
  • Ценность → принцип → эвристика → правило. Карго-культ — это применение правила без понимания принципа над ним.
  • Любой принцип имеет цену: абстракция, косвенность, лишние файлы и прыжки при чтении. Решение всегда локальное и зависит от контекста: горизонт жизни, размер команды, цена ошибки.
  • Принципы конфликтуют по построению. Разрешать конфликт нужно данными (история изменений, метрики) и фиксировать решение (ADR), а не спорить ярлыками.
  • То, что проверяется машиной, должно проверяться машиной. Ревью — для смысла, а не для запятых.
  • Признак зрелости — не «мы соблюдаем все принципы», а «мы знаем, где сознательно их нарушаем и почему».

Источники


Что дальше

Следующий шаг — самый известный и самый неправильно понимаемый свод принципов. Разберём каждую букву по существу: исходную формулировку, реальную цель, рабочий пример и обоснованную критику.

SOLID: пять принципов с честным разбором и критикой

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов