Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates
Весь трек до этого места описывал знания, живущие в голове инженера: как назвать функцию, куда направить зависимость, как не сломать контракт, как подступиться к легаси. У такого знания есть свойство, которое обнуляет большую часть усилий: оно испаряется. Человек, который держал архитектурное правило в голове, уходит в другую команду. Новый разработчик про правило не знает. В конце квартала все спешат. Через год граф зависимостей выглядит так, будто договорённости не было — и, строго говоря, её и не было: была устная традиция.
В обзоре трека мы строили «лестницу нормативности» — от вкуса до закона. У этой лестницы есть практическая проекция, лестница исполнимости: во что превращено правило.
| Форма правила | Кто соблюдает | Что происходит через год |
|---|---|---|
| «У нас так принято» | тот, кто слышал | забыто |
| Строка в вики | тот, кто нашёл | документ устарел |
| Пункт чек-листа ревью | ревьюер, если не устал | соблюдается наполовину |
| Предупреждение линтера | никто, если предупреждений сотни | шум |
| Падающая проверка в CI | все | соблюдается |
| Невыразимо в типах/API | все, включая тех, кто не согласен | не может быть нарушено |
Отсюда главный тезис статьи: принцип, который вы не смогли перевести на одну-две ступени вниз по этой таблице, будет соблюдаться ровно до первого дедлайна. Задача этой статьи — показать, как делать такой перевод и как при этом не превратить репозиторий в минное поле, где никто не может слить PR.
1. Лестница обратной связи: где ловить дефект
Ключевая метрика при выборе механизма проверки — не «насколько правило строгое», а как быстро разработчик узнаёт о нарушении. Между «подчёркнуто красным в редакторе» и «нашли в проде» — несколько порядков по стоимости и по контексту в голове автора.
Классическое утверждение «дефект, найденный в проде, дороже в 100 раз» восходит к работам Барри Боэма (Software Engineering Economics, 1981) и часто цитируется бездумно — точные множители зависят от типа системы, а исходные данные собраны на водопадных проектах. Но качественный вывод устойчив и подтверждается повседневным опытом: стоимость исправления растёт нелинейно, потому что растёт не столько работа, сколько потеря контекста — автор уже не помнит, почему написал так.
Практический вывод: для каждого правила задавайте вопрос «какая самая ранняя стадия, на которой это можно поймать?» — и реализуйте именно её.
~0 сек"] --> B["Форматтер + линтер в редакторе
секунды"] B --> C["pre-commit хук
секунды"] C --> D["Быстрые тесты в CI
минуты"] D --> E["Архитектурные и контрактные проверки
минуты"] E --> F["Интеграционные и e2e
десятки минут"] F --> G["Ревью человеком
часы"] G --> H["Канарейка / прод
дни"] A -. "дешевле в 10 раз на каждой ступени влево" .-> H style A fill:none,stroke:#3f9c8a style H fill:none,stroke:#c05a44
Из этой картинки следует и обратное правило, которое экономит нервы команды: не поднимайте проверку выше, чем нужно. Форматирование, проверенное человеком на ревью, — потерянные часы; согласованность архитектуры, проверенная только в проде, — потерянный квартал.
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, небезопасные конструкции. Правила выбора набора:
- Автофикс — по умолчанию. Правило, которое инструмент умеет чинить сам (
ruff --fix,eslint --fix,golangci-lint --fix), не должно доходить до человека вообще. - Ноль предупреждений. Предупреждения, которые никто не чинит, обучают команду игнорировать вывод инструментов. Либо правило включено как ошибка, либо выключено.
- Подавление — с причиной и владельцем.
# noqa: E501без объяснения — мусор; правило CI может требовать формат# noqa: E501 — длинный URL в докстроке, PLAT-221. - Не более одного инструмента на класс проблем. Два линтера с пересекающимися правилами дают противоречивые требования и вдвое больше времени сборки.
Отдельно про производительность: линтер, который работает минуту, запускают в pre-commit; линтер, который работает пять минут, отключают. Поэтому современные инструменты (ruff, golangci-lint, Biome) выигрывают не строгостью, а скоростью — и это инженерно правильный критерий выбора.
4. Архитектурные фитнес-функции
Самое интересное начинается там, где правило нельзя выразить ни типом, ни готовым правилом
линтера: «домен не знает про HTTP», «в модуле оплат нет прямых SQL-запросов», «публичные функции
пакета документированы», «никаких новых зависимостей от legacy.*». Термин для таких проверок —
фитнес-функции (Ford, Parsons, Kua, Building Evolutionary Architectures): исполняемые
утверждения об архитектурных свойствах системы.
фитнес-функции)) Структура направление зависимостей между слоями отсутствие циклов между пакетами запрет импорта инфраструктуры в домен публичный API пакета не растёт стихийно Контракты обратная совместимость protobuf и OpenAPI схема событий проходит режим совместимости миграции БД обратимы Эксплуатация время старта сервиса ниже порога размер образа не растёт бюджет по p99 на ключевых сценариях Безопасность нет секретов в коде зависимости без известных CVE запрет опасных функций Качество кода сложность нового кода ниже порога покрытие diff не ниже порога нет TODO без тикета
Технически это обычные тесты — они живут в репозитории, запускаются в 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 (храповик): фиксируем текущий уровень и запрещаем ухудшение.
Ключевой приём — «новый код строже старого». Технически это реализуется тремя способами:
- Baseline-файл. Поддерживают SonarQube («new code period»), ESLint (
--quiet+ baseline-плагины), mypy (--baselineв некоторых обёртках), Psalm, Detekt. Список известных нарушений в репозитории; CI падает, если появилось нарушение не из списка. - Проверка только изменённых строк.
git diff+ прогон линтера по затронутым файлам,diff-coverдля покрытия. Даёт «чистый diff» без обязательства чинить весь файл. - Правило по директориям. Строгий конфиг в новых модулях, мягкий — в легаси-зоне; граница постепенно двигается (см. работу с легаси).
Метрика, за которой стоит следить при таком подходе, — не абсолютное число нарушений, а тренд: если baseline не уменьшается три месяца, правило не работает как инструмент улучшения и его надо либо усилить (выделить время), либо честно отменить.
6. Дизайн quality gate: что блокирует, а что советует
Гейт — это не «включить всё в blocking». Гейт — это решение о том, чьё время дороже: автора, которого остановили, или тех, кто будет разбираться с последствиями.
| Проверка | Режим | Почему |
|---|---|---|
| Компиляция, типы | блокирует | без неё дальнейшее бессмысленно |
| Форматирование | автофикс, затем блокирует | нулевая стоимость исправления |
| Быстрые тесты | блокирует | основная страховка |
| Правила зависимостей | блокирует | деградация архитектуры необратима |
| Совместимость API | блокирует с процедурой обхода | ломающее изменение бывает осознанным |
| Покрытие diff | блокирует с порогом | защищает от «фича без тестов» |
| Сложность нового кода | предупреждает | метрика приблизительна, нужен человек |
| Дублирование | предупреждает | не всякое дублирование — дефект |
| Уязвимости зависимостей | блокирует при critical/high | иначе исправление не случится никогда |
| Производительность (бюджеты) | предупреждает, блокирует при регрессии > X% | шум измерений |
Четыре правила, которые отличают работающие гейты от ненавидимых:
- Время сборки — это бюджет внимания. Если PR-проверка идёт 40 минут, разработчик уходит
переключаться на другую задачу, и стоимость обратной связи возрастает на порядок. Целевые
ориентиры: быстрый набор — до 10 минут, полный — до 30. Разделяйте наборы:
pre-commit→pull request→merge queue→nightly. - У каждой проверки есть владелец. Иначе при первом же ложном срабатывании её отключат «временно», и это будет навсегда.
- Обход должен существовать и оставлять след. Метка на PR, аппрув владельца правила, запись в лог — но не «закомментировать шаг в конвейере».
- Флаки-проверка хуже отсутствия проверки. Она обучает команду перезапускать сборку не глядя — и настоящие падения тоже начинают перезапускать. Флаки-тест карантинится в тот же день.
Пример конвейера с разделением по бюджету времени:
# .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. Типичные ошибки
- Включить всё и сразу. 4000 нарушений в первый день → правило отключено на второй.
- Предупреждения, которые никто не чинит. Обучают игнорировать вывод инструментов целиком.
- Гейт на весь репозиторий вместо нового кода. Наказывает того, кто случайно тронул старый файл, а не того, кто ухудшил.
- Медленный конвейер. 40-минутная проверка стоит дороже, чем ловит; разработчики начинают мержить «после ревью, не дожидаясь».
- Флаки-проверки без карантина. Убивают доверие ко всем проверкам сразу.
- Правило без владельца и без причины. Никто не может объяснить, зачем оно, но все обходят.
- Дублирование инструментов. Два линтера с конфликтующими правилами — гарантированный источник раздражения.
- Метрика как цель. Покрытие, сложность, число замечаний — индикаторы; в качестве целей они деградируют по Гудхарту.
- Автоматизация вкусовщины. Правило, по которому нет консенсуса, в CI превращается в политический конфликт; сначала договорённость (ADR), потом автоматизация.
- Проверки без документации. Сообщение об ошибке должно содержать «почему» и ссылку — иначе новичок просто добавит подавление.
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, «новый код строже старого», тренд вместо абсолютных чисел.
- Гейт — это решение о чужом времени: следите за скоростью конвейера, владельцами правил, процедурой обхода и флаки.
- Машина проверяет форму, человек — смысл. Метрика, ставшая целью, деградирует по Гудхарту.
Источники
- Neal Ford, Rebecca Parsons, Patrick Kua. Building Evolutionary Architectures — O’Reilly; обзор фитнес-функций
- Titus Winters, Tom Manshreck, Hyrum Wright. Software Engineering at Google, главы про статический анализ и крупномасштабные изменения — бесплатно онлайн
- Caitlin Sadowski и др. Lessons from Building Static Analysis Tools at Google, CACM 2018 — почему важны скорость, отсутствие ложных срабатываний и момент показа результата
- Barry Boehm. Software Engineering Economics, 1981 — исходные данные о росте стоимости исправления дефекта
- Martin Fowler. ContinuousIntegration, BroadStackTest
- ArchUnit, import-linter, dependency-cruiser, deptrac
- ruff, golangci-lint, pre-commit, diff-cover
- SonarSource. Clean as You Code — концепция «нового кода» и quality gate
- DORA: четыре ключевые метрики
Что дальше
На этом трек «Принципы разработки» закончен. Путь стоит увидеть целиком: от вопроса, зачем принципы нужны через SOLID, DRY/KISS/YAGNI и связанность со связностью к чистому коду, рефакторингу, тестированию, twelve-factor, отказоустойчивости и код-ревью — а затем через четыре темы, которые превращают личную аккуратность в свойство системы: зависимости, совместимость, работу с легаси и автоматические проверки из этой статьи.
Принципы — это язык, но не словарь готовых решений. Дальше есть три естественных направления:
- Готовые решения повторяющихся задач проектирования — если принципы говорят «уменьшай связанность», то паттерны показывают конкретные способы это сделать: паттерны проектирования и следом архитектурные паттерны.
- Фундамент под кодом — структуры данных и алгоритмы: никакая чистота кода не спасёт от неверно выбранной структуры данных.
- Язык, на котором всё это писать — треки Go, TypeScript, C# и Elixir: в каждом принципы этого трека выглядят немного по-своему.
Общая карта всех треков портала и рекомендованный порядок прохождения — в дорожной карте.