{ "index": 139, "slug": "editorial-2024-02-field-modular-monolith", "title": "Модульный монолит под ревью: как остановить протечку границ", "excerpt": "Три похожих импорта могут незаметно связать каталог, checkout и оплату. Разбираем границы на учебном примере, проверяем API и направление зависимостей, а затем выбираем обратимое исправление.", "contentHtml": "
В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает внутренний formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется, а тест одного сценария проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду.
\nПроблема не в красоте дерева каталогов. Скрытая связь расширяет область изменения: внутренний тип становится контрактом, цикл усложняет порядок вызовов, а общий релиз приходится проверять целиком. Модульный монолит помогает только тогда, когда границы можно назвать и проверить: кто владеет смыслом операции, какая поверхность опубликована и в каком направлении разрешено обращение.
\nНиже — воспроизводимая модель с модулями catalog, checkout, payments и notifications. Имена и связи вымышлены, поэтому это не отчёт о конкретном production-проекте. Их можно заменить своими пакетами и прогнать те же проверки на настоящем репозитории.
В ревью легко перепутать наблюдение с объяснением. Факт — checkout действительно импортирует класс из catalog.internal. Гипотеза — этот импорт появился потому, что публичный API не выражает нужный сценарий. Решение — перенести операцию к каталогу или добавить узкий публичный контракт. Каждый слой требует своей проверки.
| Поле | Пример | Как подтвердить |
|---|---|---|
| Consumer | checkout | Файл вызывающего кода и его пакет |
| Owner | catalog | Владелец инварианта и данных операции |
| Surface | catalog.api.ProductView | Публичный пакет, интерфейс или команда |
| Scenario | Показать карточку перед оформлением | Тест или описание пользовательского пути |
| Direction | checkout → catalog | Карта разрешённых зависимостей и поиск обратного пути |
Запись «checkout зависит от catalog» слишком широкая. Она одинаково скрывает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api уже даёт объект для ревью. Если нельзя назвать сценарий или owner, новый импорт лучше остановить до выяснения ответственности.
Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.
Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.
Модуль владеет смыслом операции, данными и правилами их изменения. Публичная поверхность — это обещание другому модулю, а не все классы с модификатором public. Она может быть командой, запросом, событием, портом или небольшим интерфейсом. Внутренний formatter, ORM-репозиторий и таблица не становятся API только из-за удобства импорта.
В учебной карте разрешены три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Обращение checkout → catalog.internal запрещено. Обращение payments → checkout.api также требует решения: если checkout уже вызывает payments, оно замыкает цикл и оставляет неясным владельца orchestration — последовательности действий.
Если проект использует Java Platform Module System, часть границы можно закрепить физически. Например, каталог экспортирует только API-пакет:
\nmodule com.acme.catalog {\n exports com.acme.catalog.api;\n}\n\nmodule com.acme.checkout {\n requires com.acme.catalog;\n}\n\nТакой module-info.java ограничивает доступ к неэкспортированным пакетам на уровне JPMS. Но многие Java-приложения работают на classpath или используют собственное разбиение пакетов. В них понадобится архитектурный тест и правило сборки; один namespace не превращает папку в изолированный модуль.
Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.
Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.
Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый вызов проходит компиляцию, но его цель неясна | Нет записи о сценарии и владельце | Выписать source, target.surface и инвариант | Оставить ссылку только после явного контракта |
Consumer импортирует internal | Публичная поверхность не покрывает потребность | Сравнить импорт с опубликованными пакетами или интерфейсами | Вернуть операцию владельцу или добавить узкий API |
| Два модуля вызывают друг друга | Новая обратная связь добавлена без владельца процесса | Построить граф и найти цикл | Выбрать orchestration или event contract |
| Ссылка ведёт в неизвестный модуль | Карта зависимостей устарела или неполна | Сверить имя с исходниками и конфигурацией модулей | Остановить изменение до обновления карты |
| После переноса тесты зелёные, но граница снова открыта | Проверка была только примером, без правила | Запустить структурную проверку и отрицательный тест | Закрепить запрет на уровне сборки или тестового набора |
Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.
Учебная фикстура полезна для проверки формы рассуждения: фиксированный набор модулей, точный список связей, ожидаемые решения и отрицательные входы. Она не читает файлы проекта, не строит граф по настоящим импортам, не обращается к сети и не сообщает о состоянии production. В реальной проверке эти источники должны быть названы отдельно. Отсутствие скана означает только отсутствие скана, а не отсутствие нарушения.
\nСначала найдите реальные ссылки, не полагаясь на карту из памяти. Команда ниже подходит для Java-проекта, где исходники лежат в src/main/java; путь и имена пакетов нужно заменить на проектные:
rg -n --glob '*.java' \\\n '^\\s*import\\s+.*(catalog|checkout|payments)' \\\n src/main/java\n\nПоиск даёт список кандидатов, но не доказывает архитектурное нарушение: он не понимает, какой пакет экспортирован, и может найти комментарий или строку. Для автоматического запрета удобнее импортировать байткод в ArchUnit. Минимальный тест ниже запрещает checkout ссылаться на внутренность каталога и требует ацикличности срезов:
\n@AnalyzeClasses(packages = \"com.acme\")\nclass ModuleBoundaryTest {\n\n @ArchTest\n static final ArchRule checkout_uses_catalog_api =\n noClasses().that().resideInAnyPackage(\"..checkout..\")\n .should().dependOnClassesThat()\n .resideInAnyPackage(\"..catalog.internal..\");\n\n @ArchTest\n static final ArchRule modules_have_no_cycles =\n slices().matching(\"com.acme.(*)..\")\n .should().beFreeOfCycles();\n}\n\nЕсли тест находится в Gradle-проекте с именем класса ModuleBoundaryTest, его можно запустить так:
./gradlew test --tests \\\n 'com.acme.architecture.ModuleBoundaryTest'\n\nЭтот тест проверяет зависимости классов, попавших в импорт ArchUnit. Он не видит автоматически SQL-связи, вызовы через reflection, конфигурацию контейнера, внешние очереди и бизнес-правильность транзакции. Поэтому результат нужно читать точно: «две заданные архитектурные проверки не нашли нарушение в импортированном наборе классов», а не «модуль полностью изолирован».
\nГраф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.
\nНе всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.
\nИнструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.
\nИзменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.
Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.
\nrequires и exports, а также доступа к экспортированным пакетам.