Архитектурные тесты для границ модулей в быстрых бэкендах
Узнайте, как архитектурные тесты для границ модулей останавливают плохие импорты, сохраняют модульность и защищают бэкенд команды, которая быстро выпускает.

Почему границы ломаются после нескольких быстрых релизов
Быстрые команды редко рушат бэкенд одним драматичным действием. Обычно это происходит через маленькие обходные пути, которые в тот момент кажутся разумными. Нужно исправление до обеда, и разработчик импортирует код из другого модуля вместо того, чтобы добавить правильный интерфейс. Приложение работает, тикет закрыт, и обходный путь остаётся.
Один плохой импорт кажется безобидным. Затем он связывает два модуля так, как никто не планировал. Код ордеров начинает вызывать внутренние методы платежей. Функции доставки читают пользовательские данные прямо из биллинга. Со временем изменение в одном месте может поломать что-то далеко, и никто не чувствует себя уверенно, лезя в обе зоны.
Это становится хуже, когда жесткие сроки длятся неделями. Команды перестают спрашивать: «Должен ли этот модуль знать о том модуле?» и начинают спрашивать: «Сможем ли мы выпустить это сегодня?» Один такой компромисс кажется приемлемым один раз. После пяти или десяти релизов это становится нормой.
Шаблон знаком: одно срочное исправление переходит границу. Кто‑то повторяет подход в следующей задаче. Третье изменение зависит от того обхода. Тесты покрывают поведение, и связность начинает выглядеть легитимной.
Копипаст ускоряет повреждение. Если одна фича берет данные из неправильного модуля, следующий разработчик часто повторно использует тот же код, потому что он уже работает. Бэкенд растёт вокруг случайностей, а не ясного дизайна.
Уборка становится сложнее с каждым релизом. Всё больше кода зависит от обхода. Всё больше тестов фиксируют неправильную структуру. Больше людей предполагают, что зависимость нормальна, потому что видят её в нескольких местах. Исправление уже не кажется маленьким рефактором — это рискованная и дорогая работа, которую легко отложить.
Именно поэтому быстрым командам нужны архитектурные тесты вокруг границ модулей. Они ловят первый плохой импорт до того, как он станет привычкой. Для маленькой продуктовой команды, которая часто выпускает, это очень важно. К десятому релизу одна неосторожная зависимость может сделать весь бэкенд хрупким.
Что именно проверяют эти тесты
Архитектурные тесты проверяют форму кодовой базы. Им не важно, работает ли фича для пользователя. Их интересует, тянется ли одна часть бэкенда в места, до которых ей не следовало бы дотрагиваться.
Чаще всего они читают пути импорта, названия пакетов или неймспейсы и сравнивают их с файлом правил. Если модуль orders импортирует внутренние части billing, или API‑обработчик лезет прямо в код базы данных, тест падает. Такое падение полезно: оно ловит структурный обход, пока он ещё мал.
Начните с простого вопроса: кто кому может импортировать? Определите допустимые пути между модулями, а затем пусть тест проверяет каждую новую зависимость по этой карте. Это снимает много ручной работы при ревью.
Эти тесты также должны защищать приватный код модуля. Модуль может открывать небольшую публичную поверхность и скрывать остальное. Если другой разработчик импортирует orders/internal/price_rules вместо публичного API orders, тест должен остановить это. Так рефакторы остаются дешевле, потому что приватный код можно менять, не ломая половину бэкенда.
Направление зависимостей тоже важно. В модульном бэкенде бизнес‑код не должен зависеть от кода доставки, например HTTP‑хендлеров, а общие пакеты не должны тихо зависеть от продуктовых модулей. Как только направление меняется, код быстро становится липким. Малые изменения начинают распространяться туда, где их быть не должно.
Одно правило экономит много будущих споров: держите исключения в одном видимом месте. Иногда нужна специальная уступка — например, разрешить модулю отчётов читать данные заказов напрямую для скорости. Поместите такое исключение в конфиг тестов, добавьте краткую причину и оставьте его на виду. Скрытые исключения — это место, где чистая архитектура начинает разваливаться.
Хорошая настройка тестов делает очевидными четыре вещи: какие модули могут зависеть друг от друга, какие папки приватны, в каком направлении должны идти зависимости и какие исключения команда принимает сознательно. Обычно этого достаточно, чтобы модульный бэкенд не превратился в лабиринт потайных дверей.
Выберите границы до того, как писать правила
Хорошие архитектурные тесты начинаются с простого языка. Назовите части системы так, как ваша команда уже о них говорит: orders, billing, auth, notifications, admin. Если имя модуля звучит расплывчато, например «core» или «utils», скорее всего там скрыты смешанные обязанности.
У каждого модуля должен быть один публичный вход. Это может быть пакет уровня модуля, интерфейс сервиса или небольшая API‑поверхность. Внешний код должен использовать только этот вход и не лезть глубже. Если другой модуль импортирует приватные репозитории, приватные модели или вспомогательные файлы, граница уже слабая до того, как вы добавите хоть один тест.
Общий код требует большей дисциплины, чем многие команды предполагают. Реальный общий код — скучный и стабильный: логирование, идентификаторы, утилиты времени, обвязка базы данных, может быть несколько общих типов. Бизнес‑правила редко бывают общими. Если логика цен, валидация заказов или содержание писем попадают в общую папку слишком рано, код функций начинает протекать по всему бэкенду.
Быстрый фильтр помогает. Может ли другой модуль использовать этот код, не изучая бизнес‑правила? Зачем более чем одному модулю зависеть от него? Открывает ли он небольшой API, а не кучу хелперов? Разместили бы вы его там, если бы новую фичу вёл другой тим‑владелец?
После этого решите, какие импорты вы никогда не дозволите. Начните с очевидных: фичевые модули не должны импортировать внутренности друг друга, транспортный слой не должен лезть в чужое хранилище, и ничего вне модуля не должно импортировать его internal или приватные пакеты. Короткий список жёстких запретов работает лучше, чем длинный файл правил с кучей исключений.
Это особенно важно на раннем этапе бэкенда стартапа. Auth может выдавать идентичность пользователя через публичный интерфейс, а notifications — потреблять этот интерфейс. Notifications не должен импортировать модели базы данных auth только потому, что так быстрее на десять минут. Такие обходы выживают шесть релизов и превращают написание правил в удаление долгов, а не в защиту.
Выберите швы сначала. Затем напишите тесты, которые их охраняют.
Настройка первых правил по шагам
Начните с малого. Возьмите один сервис, а не весь бэкенд. Обычно хорошо подходят orders или billing, потому что другие модули часто их трогают, и проблемы границ проявляются быстро.
Если пытаться покрыть все модули в первый день, команда потратит больше времени на исправление старого хаоса, чем на создание полезной защиты. Один чистый правило в одном сервисе лучше двадцати правил, которым никто не доверяет.
Начните с блокировки импортов в приватные папки. Это быстрая победа с минимальными спорами. Если кто‑то импортирует из orders/internal или orders/private, тест падает.
Это правило важно, потому что приватный код — то место, где часто начинаются обходы. Разработчику нужен один хелпер, он тянет что‑то из другого модуля, и через шесть недель бэкенд снова запутан.
Далее сделайте путь разрешённых импортов очевидным. Дайте модулю один публичный вход, например пакет верхнего уровня или небольшой фасад, который другие модули могут вызывать. Тогда правило остается простым: внешний код может использовать публичный вход и ничего больше.
Практический rollout короткий:
- Добавьте один архитектурный тест для одного модуля.
- Запретите импорты в папки, помеченные internal или private.
- Разрешите импорты только через публичную поверхность модуля.
- Запускайте тест в каждой сборке.
Не ждите, пока вся кодовая база станет идеальной, прежде чем включать это. Если старые нарушения уже есть, зафиксируйте их и блокируйте только новые. Это позволит команде двигаться, пока код постепенно очищают.
Сборка должна падать при каждом новом нарушении границы. Если правило только выводит предупреждение, люди проигнорируют его в первый раз, когда будут спешить. Падающая сборка строгая, но она экономит много работы по ремонту после нескольких быстрых релизов.
Держите правила рядом с защищаемым кодом. Поместите тест в тот же репозиторий и рядом с модулем, а не в отдельном углу, который никто не открывает. Когда модуль меняется, правило границы может измениться в том же pull request.
Этого достаточно, чтобы начать. Не нужен большой план развертывания — нужен один модуль, одна публичная дверь и сборка, которая говорит «нет», когда кто‑то пытается пролезть в окно.
Простой пример из системы заказов
Клиент оформляет заказ, и бэкенду нужно списать карту и отправить подтверждение по почте. Звучит просто. Быстро становится грязно, если один модуль начнёт тянуть внутренности другого.
Держите разделение скучным и строгим. Модуль orders создаёт заказ, хранит свои данные и вызывает payments через интерфейс, например PaymentGateway. Модуль payments отвечает за логику списаний, повторные попытки и ошибки провайдера. orders должен знать, что платеж был запрошен, но не то, как именно происходит списание.
То же правило в обратную сторону. payments не должен читать таблицы заказов или импортировать репозитории orders напрямую. Если payments нужны orderId, amount или customerEmail, orders должен передать эти поля в запросе. Другой вариант — событие с точными полями, которые нужны payments. Это сохраняет ясность владения.
Код отправки писем должен быть вне обоих модулей. Небольшой модуль notifications может слушать события вроде OrderPaid и отправлять сообщение. Тогда ни orders, ни payments не хранят SMTP‑клиенты, шаблоны или логику повторной доставки в бизнес‑коде.
Базовый набор правил может выглядеть так:
orders -\u003e may import payments.api
orders -\u003e may not import payments.internal
payments -\u003e may not import orders.storage
payments -\u003e may not import orders.repository
orders -\u003e may not import notifications.email
payments -\u003e may not import notifications.email
Теперь представьте срочный релиз. Разработчик добавляет проверку возврата и импортирует orders/repository внутри payments, потому что так быстрее, чем менять интерфейс. Фича работает у него локально. Архитектурный тест падает сразу, прежде чем обход попадёт в main.
Это важно, потому что обходы растут. Один прямой импорт превращается в два. Потом задача платежей зависит от деталей схемы заказов, и изменение таблицы ломает оба модуля.
Вот реальная выгода: тест превращает смутное правило дизайна в жёсткую блокировку. Командам не нужен ещё один долгий комментарий на ревью про «держать чистоту». Сборка говорит «нет», и код остаётся модульным.
Где команды обычно спотыкаются
Большинство правил границ не проваливаются потому, что инструмент сложен. Они проваливаются потому, что команды принимают маленькие «временные» решения, пока карта и код больше не совпадают.
Распространённая ошибка начинается с общего кода. Хелпер выглядит безобидно, и кто‑то бросает его в общую папку. Через время там аккумулируются форматирование дат, клиенты API, проверки аутентификации и куски бизнес‑логики из нескольких модулей. После этого любой модуль может добраться до любого другого через потайную дверь.
Исключения вызывают следующий протек. Команды часто добавляют их в первый день, потому что один импорт неудобен, одна миграция ещё открыта или один тест требует особого обращения. Пару узких исключений можно терпеть. Длинный allowlist обычно — тихий способ отключить правило.
Ещё одна ошибка — проверять только названия пакетов, а не реальные пути файлов. Правило может запрещать orders импортировать billing, но алиасы, обёртки или странная структура папок всё равно пересекут границу. Правило будет выглядеть строгим на бумаге, но сборка по‑прежнему подтянет код из неправильного места.
Сгенерированные файлы и тестовые папки создают другую проблему. Некоторые команды полностью игнорируют их; тогда сгенерированные клиенты тащат запрещённые зависимости, или тестовые утилиты начинают вести себя как продакшн‑код. Другие команды запускают все правила на всех папках, что создаёт шум и ложные срабатывания.
Сигналы беды обычно очевидны. Общая папка растёт быстрее, чем любой настоящий модуль. Файлы правил собирают пометки вроде «пропустить пока» или «временно». Одно исключение превращается в несколько за несколько недель. Тестовые хелперы импортируют большую часть приложения. Сгенерированный код лежит в тех же путях, что и ручной.
Пропущенные правила — первое, что нужно править. Команды оставляют их месяцами, потому что пока ничего не ломается. Но пропущенное правило говорит всем, что граница опциональна. Если правило неверно, удалите его и перепишите позже. Если правило верно, включите его обратно и решайте проблему, пока она ещё мала.
Именно здесь архитектурные тесты приносят больше всего пользы. Они ловят не только один плохой импорт, но и останавливают медленное дрейфование, прежде чем модульный бэкенд превратится в единый код с красивыми названиями папок.
Как сохранить правила полезными по мере роста кода
Набор правил, который работал в первых релизах, может превратиться в фоновый шум к десятому. Команды добавляют одно исключение, чтобы выпустить быстрее, затем ещё одно, и скоро тест проходит, хотя дизайн дрейфует.
Держите срабатывания там, где разработчики уже обращают внимание. Если правило импорта падает в pull request, автор может исправить проблему, пока изменение ещё мало. Если то же падение ждёт ночной джоб, люди часто откладывают его до тех пор, пока никто не захочет это трогать.
Архитектурные тесты должны меняться в том же темпе, что и код. Когда вы создаёте новый модуль, добавляйте его допустимые импорты в тот же pull request. Такая привычка экономит время позже, потому что никому не приходится угадывать, на что модуль должен ссылаться.
Несколько практик помогают. Добавляйте правило границы при появлении нового модуля. Поясняйте любое исключение короткой заметкой и назначайте владельца. Удаляйте исключения сразу после рефактора. Перепроверяйте зависимости, когда один модуль разделяется на два.
Старые исключения — вот где большинство наборов правил портятся. Временный allowlist часто остаётся месяцами после того, как команда уже убрала необходимость в нём. Если рефактор устранил причину исключения, удалите его в том же изменении. Относитесь к нему как к мёртвому коду: оставлять — значит приглашать следующую хитрость.
Разделение модулей требует особого внимания. Один модуль «user» может позже распасться на «auth» и «profile». Если вы сохраните старые правила импорта, другие части бэкенда всё ещё будут тянуться через оба модуля, будто ничего не изменилось. Пересмотрите границы сразу, иначе разделение станет косметическим.
Это особенно важно для маленьких команд, которые часто релизят. Один человек может поддерживать набор правил живым, делая короткий обзор в код‑ревью или при подготовке релиза. Если команда хочет внешнего взгляда, Oleg Sotnikov (oleg.is) работает со стартапами и малыми бизнесами в роли Fractional CTO и советника, помогая исправить архитектурные решения до того, как они превратятся в дорогие привычки.
Если правило больше не соответствует системе — обновите его. Если исключение потеряло причину — удалите. Хороший набор правил должен казаться немного строгим и вызывать доверие.
Быстрая проверка перед каждым релизом
Быстрым командам не нужен тяжёлый ритуал проверки перед каждым деплоем. Им нужен короткий gate, который ловит одни и те же ошибки границ каждый раз, особенно после насыщенного спринта.
Хорошая проверка релиза вписывается в CI и занимает всего пару минут чтения при падении. Проверьте, что никакой модуль не импортирует файлы из приватных папок другого модуля. Если код лезет в internal, private или фичеспецифичные подпапки, считайте это нарушенной границей.
Проверьте публичный API каждого модуля. Если другие модули зависят от слишком большого числа экспортов, сузьте поверхность и оставьте только то, что нужно. Проверьте и владение общим кодом. У каждого общего пакета должен быть один владелец — команда или человек, который решает, что туда можно класть, а что нет.
Пересмотрите каждое исключение. Если вы разрешили нарушение правила ради дедлайна, дайте ему владельца и дату окончания, иначе оно останется навсегда. Затем убедитесь, что провал архитектурного теста блокирует слияния. Предупреждение, которое люди могут игнорировать, не является настоящим ограничителем.
Маленькие API важнее, чем команды думают. Когда модуль открывает слишком много, другие части бэкенда начинают зависеть от деталей, которые должны оставаться локальными. Через месяц маленький рефактор превращается в рискованный релиз.
Общий код требует такой же дисциплины. Команды часто кидают случайные хелперы в общую папку, потому что так кажется быстрее. На деле результат обратный: вскоре никто не знает, кто может менять этот код, и любое изменение рискует сломать три несвязанных модуля.
Временные исключения нуждаются в реальной дате истечения, а не в расплывчатой заметке в тикете. Поместите причину, владельца и дату удаления рядом с правилом, чтобы вся команда видела это при ревью.
Последняя проверка самая простая. Если тест падает — merge останавливается. Человеческая память ненадежна, особенно в маленькой команде, которая часто релизит. Автоматические ворота скучны — и именно поэтому они работают.
Что делать дальше с вашей командой
Выберите один сервис бэкенда, который ежеспринтово вызывает мелкие споры. Нарисуйте его модули на одной странице и дайте каждому модулю понятную задачу. Если двое людей описывают один модуль по‑разному, сначала исправьте это.
Простая карта достаточна. Возможно, у вас получится auth, billing, orders, notifications и shared infrastructure. Цель не в красивой диаграмме, а в том, чтобы импорты стали очевидны, а не предметом спора.
Затем добавляйте только несколько правил. Команды обычно проваливаются, когда пишут двадцать правил прежде чем исправят первое нарушение. Начните с двух‑трёх импортных правил, которые блокируют самые распространённые обходы: фичевые модули не должны импортировать друг друга напрямую, общие утилиты должны быть свободны от бизнес‑логики, и API‑хендлеры не должны лезть в код базы данных через границы модулей.
Такой небольшой набор позволит начать без превращения тестовой базы в второй документ политики.
Исправьте текущие нарушения прежде, чем добавлять больше правил. Если отчёт теста уже показывает тридцать плохих импортов, никто ему не поверит. Очистите очевидные вещи, вмержите правила и покажите команде, что настройка соответствует реальному коду.
Держите настройку скучной. Если людям нужна встреча, чтобы понять одно правило, правило слишком сложное. Хорошие тесты границ должны читаться как простые договорённости команды, а не юридический текст.
Простой ритм работает: нарисуйте один сервис, выберите два‑три правила, уберите текущие нарушения, запускайте проверки в CI и пересмотрите правила после следующего релиза.
Если несколько команд трогают один бэкенд, внешний взгляд экономит время. Свежие глаза чаще замечают размытое владение быстрее, чем те, кто работает внутри кода каждый день.
Результат прост: разработчики перестают гадать, ревью становятся короче, и один срочный релиз не отменяет шесть месяцев очистки. Если первые правила заблокируют хотя бы один плохой межмодульный импорт до следующего деплоя — это уже хороший старт.