Принципы разработки Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates
0%

Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates

Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates

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

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

Форма правила Кто соблюдает Что происходит через год
«У нас так принято» тот, кто слышал забыто
Строка в вики тот, кто нашёл документ устарел
Пункт чек-листа ревью ревьюер, если не устал соблюдается наполовину
Предупреждение линтера никто, если предупреждений сотни шум
Падающая проверка в CI все соблюдается
Невыразимо в типах/API все, включая тех, кто не согласен не может быть нарушено

Отсюда главный тезис статьи: принцип, который вы не смогли перевести на одну-две ступени вниз по этой таблице, будет соблюдаться ровно до первого дедлайна. Задача этой статьи — показать, как делать такой перевод и как при этом не превратить репозиторий в минное поле, где никто не может слить PR.


1. Лестница обратной связи: где ловить дефект

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

Лестница обратной связи: стоимость обнаружения дефекта на разных стадиях

Классическое утверждение «дефект, найденный в проде, дороже в 100 раз» восходит к работам Барри Боэма (Software Engineering Economics, 1981) и часто цитируется бездумно — точные множители зависят от типа системы, а исходные данные собраны на водопадных проектах. Но качественный вывод устойчив и подтверждается повседневным опытом: стоимость исправления растёт нелинейно, потому что растёт не столько работа, сколько потеря контекста — автор уже не помнит, почему написал так.

Практический вывод: для каждого правила задавайте вопрос «какая самая ранняя стадия, на которой это можно поймать?» — и реализуйте именно её.

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


2. Типы: самый дешёвый гейт из существующих

Тип — это правило, которое невозможно нарушить, потому что программа просто не соберётся. Ни один линтер не даёт такой гарантии.

Три уровня применения, по возрастанию отдачи:

Уровень 1: включить строгий режим. mypy --strict, tsconfig со strict: true, <Nullable>enable</Nullable> в C#, -Xlint и NullAway в Java, #![deny(warnings)] в Rust. Это разовое действие, которое переводит целый класс ошибок (null, забытый возврат, необработанный вариант) из рантайма в сборку.

Уровень 2: доменные типы вместо примитивов. Разбиралось в запахах кода как лечение Primitive Obsession; здесь важна другая сторона — это дешёвая проверка, работающая во всех точках кода сразу:

from typing import NewType

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)

def refund(user: UserId, order: OrderId) -> None: ...

refund(OrderId(7), UserId(42))   # mypy: error — аргументы перепутаны местами

Уровень 3: сделать неверное состояние невыразимым. Вместо проверки «если статус paid, то paid_at не может быть None» — тип, в котором такая комбинация не собирается:

// ПЛОХО: четыре поля, шестнадцать комбинаций, из которых валидны три.
type Order = {
  status: "new" | "paid" | "cancelled";
  paidAt?: Date;
  cancelReason?: string;
};

// ХОРОШО: размеченное объединение — невалидных состояний не существует.
type Order =
  | { status: "new" }
  | { status: "paid"; paidAt: Date }
  | { status: "cancelled"; reason: string };

function describe(order: Order): string {
  switch (order.status) {
    case "new": return "ожидает оплаты";
    case "paid": return `оплачен ${order.paidAt.toISOString()}`;
    case "cancelled": return `отменён: ${order.reason}`;
    // Ветки default нет намеренно: при добавлении нового статуса
    // компилятор с "strict" укажет на все неполные switch в проекте.
  }
}

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

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

# mypy.ini — строгость по умолчанию, послабления перечислены явно и уменьшаются
[mypy]
strict = True
warn_unused_ignores = True     # устаревшие подавления сами становятся ошибкой

[mypy-legacy.billing.*]        # исключение с датой и владельцем в комментарии
ignore_errors = True           # BILL-4471, @petrov, цель — снять до Q3

3. Форматтеры и линтеры: правила без переговоров

Форматирование не обсуждается. Единственная правильная стратегия — детерминированный форматтер без настроек или с минимальными: gofmt, black, prettier, rustfmt, dotnet format. Ценность не в конкретном стиле, а в том, что стиль перестаёт быть темой: исчезают споры на ревью, исчезают шумные диффы, git blame перестаёт указывать на переформатирование.

Разовое приведение всего репозитория к форматтеру делают отдельным коммитом и добавляют его хеш в .git-blame-ignore-revs, иначе история авторства сломается:

# один коммит, только форматирование, никакой логики
black . && git commit -am "style: применить black ко всему репозиторию"
git rev-parse HEAD >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs   # локально и в CI

Линтеры ловят то, что форматтер не видит: неиспользуемые переменные, подозрительные сравнения, забытые await, небезопасные конструкции. Правила выбора набора:

  1. Автофикс — по умолчанию. Правило, которое инструмент умеет чинить сам (ruff --fix, eslint --fix, golangci-lint --fix), не должно доходить до человека вообще.
  2. Ноль предупреждений. Предупреждения, которые никто не чинит, обучают команду игнорировать вывод инструментов. Либо правило включено как ошибка, либо выключено.
  3. Подавление — с причиной и владельцем. # noqa: E501 без объяснения — мусор; правило CI может требовать формат # noqa: E501 — длинный URL в докстроке, PLAT-221.
  4. Не более одного инструмента на класс проблем. Два линтера с пересекающимися правилами дают противоречивые требования и вдвое больше времени сборки.

Отдельно про производительность: линтер, который работает минуту, запускают в pre-commit; линтер, который работает пять минут, отключают. Поэтому современные инструменты (ruff, golangci-lint, Biome) выигрывают не строгостью, а скоростью — и это инженерно правильный критерий выбора.


4. Архитектурные фитнес-функции

Самое интересное начинается там, где правило нельзя выразить ни типом, ни готовым правилом линтера: «домен не знает про HTTP», «в модуле оплат нет прямых SQL-запросов», «публичные функции пакета документированы», «никаких новых зависимостей от legacy.*». Термин для таких проверок — фитнес-функции (Ford, Parsons, Kua, Building Evolutionary Architectures): исполняемые утверждения об архитектурных свойствах системы.

Технически это обычные тесты — они живут в репозитории, запускаются в CI и падают так же, как любые другие. Пример на Python: правило «домен не импортирует инфраструктуру» без внешних инструментов, чтобы было видно, что внутри:

"""Архитектурный тест: домен не должен зависеть от инфраструктуры.

Работает по AST, не импортируя проверяемые модули (важно: импорт легаси может
иметь побочные эффекты). Время O(размер исходников), память O(1) на файл.
"""
import ast
import pathlib

import pytest

DOMAIN = pathlib.Path("shop/domain")
FORBIDDEN_PREFIXES = ("sqlalchemy", "httpx", "flask", "shop.infrastructure")

# Исключения — с тикетом и сроком; список ТОЛЬКО сокращается
ALLOWLIST = {
    ("shop/domain/legacy_pricing.py", "sqlalchemy"),   # BILL-4471, снять до Q3
}


def imported_modules(path: pathlib.Path) -> set[str]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    modules: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            modules.update(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
            modules.add(node.module)
    return modules


@pytest.mark.parametrize("path", sorted(DOMAIN.rglob("*.py")), ids=str)
def test_domain_has_no_infrastructure_imports(path: pathlib.Path) -> None:
    violations = {
        module
        for module in imported_modules(path)
        for prefix in FORBIDDEN_PREFIXES
        if module.startswith(prefix) and (str(path), prefix) not in ALLOWLIST
    }
    assert not violations, (
        f"{path} импортирует инфраструктуру: {sorted(violations)}. "
        "Домен обязан зависеть только от абстракций — см. статью о зависимостях трека."
    )

В зрелых экосистемах такие правила декларируются готовыми инструментами: import-linter (пример конфига — в статье о зависимостях), ArchUnit для JVM, NetArchTest для .NET, deptrac для PHP, dependency-cruiser для JS/TS, depguard в golangci-lint. Проверки совместимости API (buf breaking, oasdiff, japicmp) — тоже фитнес-функции, только про контракт, а не про структуру; про них — в статье о совместимости.

Три свойства хорошей фитнес-функции:

  • Детерминированность. Она не должна зависеть от порядка файлов, сети или времени, иначе превращается во флаки-тест и будет отключена (см. разбор флаки в принципах тестирования).
  • Внятное сообщение об ошибке. Не «нарушено правило layers», а «shop/domain/pricing.py импортирует sqlalchemy; вынесите доступ к данным за порт, пример — в shop/domain/ports.py».
  • Явный список исключений с владельцем. Не бывает правил без легаси-исключений; бывает контроль за тем, чтобы их число падало.

5. Храповик: как включить правило в живом репозитории

Главная причина, по которой хорошие проверки не приживаются: их пытаются включить сразу на всём репозитории, получают 4000 нарушений, отключают. Работающая стратегия называется ratcheting (храповик): фиксируем текущий уровень и запрещаем ухудшение.

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

  1. Baseline-файл. Поддерживают SonarQube («new code period»), ESLint (--quiet + baseline-плагины), mypy (--baseline в некоторых обёртках), Psalm, Detekt. Список известных нарушений в репозитории; CI падает, если появилось нарушение не из списка.
  2. Проверка только изменённых строк. git diff + прогон линтера по затронутым файлам, diff-cover для покрытия. Даёт «чистый diff» без обязательства чинить весь файл.
  3. Правило по директориям. Строгий конфиг в новых модулях, мягкий — в легаси-зоне; граница постепенно двигается (см. работу с легаси).

Метрика, за которой стоит следить при таком подходе, — не абсолютное число нарушений, а тренд: если baseline не уменьшается три месяца, правило не работает как инструмент улучшения и его надо либо усилить (выделить время), либо честно отменить.


6. Дизайн quality gate: что блокирует, а что советует

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

Проверка Режим Почему
Компиляция, типы блокирует без неё дальнейшее бессмысленно
Форматирование автофикс, затем блокирует нулевая стоимость исправления
Быстрые тесты блокирует основная страховка
Правила зависимостей блокирует деградация архитектуры необратима
Совместимость API блокирует с процедурой обхода ломающее изменение бывает осознанным
Покрытие diff блокирует с порогом защищает от «фича без тестов»
Сложность нового кода предупреждает метрика приблизительна, нужен человек
Дублирование предупреждает не всякое дублирование — дефект
Уязвимости зависимостей блокирует при critical/high иначе исправление не случится никогда
Производительность (бюджеты) предупреждает, блокирует при регрессии > X% шум измерений

Четыре правила, которые отличают работающие гейты от ненавидимых:

  1. Время сборки — это бюджет внимания. Если PR-проверка идёт 40 минут, разработчик уходит переключаться на другую задачу, и стоимость обратной связи возрастает на порядок. Целевые ориентиры: быстрый набор — до 10 минут, полный — до 30. Разделяйте наборы: pre-commitpull requestmerge queuenightly.
  2. У каждой проверки есть владелец. Иначе при первом же ложном срабатывании её отключат «временно», и это будет навсегда.
  3. Обход должен существовать и оставлять след. Метка на PR, аппрув владельца правила, запись в лог — но не «закомментировать шаг в конвейере».
  4. Флаки-проверка хуже отсутствия проверки. Она обучает команду перезапускать сборку не глядя — и настоящие падения тоже начинают перезапускать. Флаки-тест карантинится в тот же день.

Пример конвейера с разделением по бюджету времени:

# .github/workflows/pr.yml — обязательные проверки на каждый PR
name: pr
on: [pull_request]

jobs:
  fast:                                   # цель: < 5 минут, блокирует
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ruff format --check . && ruff check .        # формат + линтер
      - run: mypy .                                       # типы, strict
      - run: pytest tests/unit -q --maxfail=1             # быстрые тесты

  architecture:                           # цель: < 3 минут, блокирует
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: lint-imports                                 # правила зависимостей
      - run: pytest tests/architecture -q                 # фитнес-функции
      - run: python tools/check_baseline.py --no-growth   # храповик: baseline не растёт

  diff-quality:                           # цель: < 5 минут, блокирует по порогу
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pytest --cov=shop --cov-report=xml tests/unit
      - run: diff-cover coverage.xml --compare-branch=origin/main --fail-under=80

  slow:                                   # ночью и в merge queue, не в каждом PR
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest tests/integration -q
      - run: pytest tests/mutation -q                     # мутационное — по горячим модулям

Про устройство самих конвейеров и их эксплуатацию — в основах CI и тестах в CI; про хуки на стороне разработчика — в автоматизации Git.


7. Закон Гудхарта: чего автоматизация не умеет

Когда мера становится целью, она перестаёт быть хорошей мерой. — закон Гудхарта

Каждая метрика, поставленная как обязательный порог, немедленно начинает оптимизироваться — и не всегда тем способом, которого вы ждали:

  • Покрытие 80% → тесты без ассертов, покрывающие геттеры; настоящая логика по-прежнему не проверена. Противоядие: покрытие как индикатор (и только на новом коде) плюс мутационное тестирование там, где цена ошибки высока.
  • Ноль замечаний линтера → массовые # noqa. Противоядие: подавления требуют причины и считаются как долг.
  • Цикломатическая сложность ниже 10 → функция разрезана на восемь бессмысленных кусков. Противоядие: сложность — предупреждение, а не блокировка.
  • Число PR или строк кода → мелкая нарезка ради счётчика. Противоядие: командные метрики потока (DORA) вместо индивидуальных.

Из этого следует граница автоматизации: машина проверяет форму, человек — смысл. Ни один инструмент не скажет, что модуль решает не ту задачу, что абстракция преждевременна (см. YAGNI), что имя вводит в заблуждение или что тест проверяет реализацию вместо поведения. Именно поэтому автоматические проверки не заменяют ревью, а освобождают его от рутины — как мы обсуждали в статье про код-ревью.


8. Типичные ошибки

  1. Включить всё и сразу. 4000 нарушений в первый день → правило отключено на второй.
  2. Предупреждения, которые никто не чинит. Обучают игнорировать вывод инструментов целиком.
  3. Гейт на весь репозиторий вместо нового кода. Наказывает того, кто случайно тронул старый файл, а не того, кто ухудшил.
  4. Медленный конвейер. 40-минутная проверка стоит дороже, чем ловит; разработчики начинают мержить «после ревью, не дожидаясь».
  5. Флаки-проверки без карантина. Убивают доверие ко всем проверкам сразу.
  6. Правило без владельца и без причины. Никто не может объяснить, зачем оно, но все обходят.
  7. Дублирование инструментов. Два линтера с конфликтующими правилами — гарантированный источник раздражения.
  8. Метрика как цель. Покрытие, сложность, число замечаний — индикаторы; в качестве целей они деградируют по Гудхарту.
  9. Автоматизация вкусовщины. Правило, по которому нет консенсуса, в CI превращается в политический конфликт; сначала договорённость (ADR), потом автоматизация.
  10. Проверки без документации. Сообщение об ошибке должно содержать «почему» и ссылку — иначе новичок просто добавит подавление.

9. Как это выглядит в проде

  • Один способ запустить всё локально: make check или task check, который выполняет ровно то же, что CI. Расхождение локального и серверного набора — источник «у меня работало».
  • pre-commit с автофиксами (формат, импорты, trailing whitespace) и быстрым линтером; тяжёлое — в CI.
  • Required checks в настройках репозитория плюс merge queue: main всегда зелёный, потому что проверка идёт на итоговом состоянии, а не на устаревшей ветке.
  • Baseline-файлы в репозитории с датой, тикетом и владельцем каждой строки исключений; еженедельный отчёт по тренду.
  • Каталог правил — короткий документ «какие проверки есть, зачем, кто владелец, как обойти». Каждое нетривиальное правило рождается из ADR (см. архитектурные решения).
  • Бюджет на инструменты: время сборки отслеживается как метрика продукта; регресс времени конвейера разбирается так же, как регресс производительности сервиса.
  • Регулярная прополка правил: раз в полгода выключают то, что не срабатывало ни разу или срабатывает только ложно. Набор проверок — тоже код, и у него тоже бывает мёртвый код.
  • Метрики потока (DORA: частота деплоев, lead time, доля неудачных изменений, время восстановления) как контрольная группа: если гейты растут, а lead time ухудшается без падения доли отказов, вы платите больше, чем получаете.

10. Мини-итог

  • Принцип живёт ровно до той ступени исполнимости, на которую вы его перевели: устная традиция → документ → чек-лист → предупреждение → блокирующая проверка → невыразимость в типах.
  • Выбирайте самую раннюю стадию, на которой дефект можно поймать: тип дешевле линтера, линтер дешевле теста, тест дешевле ревью, ревью дешевле прода.
  • Типы — единственная проверка со стопроцентной гарантией: строгий режим, доменные типы, невыразимость неверных состояний.
  • Форматирование не обсуждается, линтеры — с автофиксом, нулевой терпимостью к предупреждениям и подавлениями с причиной.
  • Фитнес-функции превращают архитектурные договорённости в обычные падающие тесты; у них должны быть детерминированность, внятное сообщение и контролируемый список исключений.
  • Храповик — единственный способ внедрить правило в живом репозитории: baseline, «новый код строже старого», тренд вместо абсолютных чисел.
  • Гейт — это решение о чужом времени: следите за скоростью конвейера, владельцами правил, процедурой обхода и флаки.
  • Машина проверяет форму, человек — смысл. Метрика, ставшая целью, деградирует по Гудхарту.

Источники


Что дальше

На этом трек «Принципы разработки» закончен. Путь стоит увидеть целиком: от вопроса, зачем принципы нужны через SOLID, DRY/KISS/YAGNI и связанность со связностью к чистому коду, рефакторингу, тестированию, twelve-factor, отказоустойчивости и код-ревью — а затем через четыре темы, которые превращают личную аккуратность в свойство системы: зависимости, совместимость, работу с легаси и автоматические проверки из этой статьи.

Принципы — это язык, но не словарь готовых решений. Дальше есть три естественных направления:

  • Готовые решения повторяющихся задач проектирования — если принципы говорят «уменьшай связанность», то паттерны показывают конкретные способы это сделать: паттерны проектирования и следом архитектурные паттерны.
  • Фундамент под кодомструктуры данных и алгоритмы: никакая чистота кода не спасёт от неверно выбранной структуры данных.
  • Язык, на котором всё это писать — треки Go, TypeScript, C# и Elixir: в каждом принципы этого трека выглядят немного по-своему.

Общая карта всех треков портала и рекомендованный порядок прохождения — в дорожной карте.

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

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

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

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