{ "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-проекте. Их можно заменить своими пакетами и прогнать те же проверки на настоящем репозитории.

\n

Сначала отделите факт от предположения

\n

В ревью легко перепутать наблюдение с объяснением. Факт — checkout действительно импортирует класс из catalog.internal. Гипотеза — этот импорт появился потому, что публичный API не выражает нужный сценарий. Решение — перенести операцию к каталогу или добавить узкий публичный контракт. Каждый слой требует своей проверки.

\n
Минимальная карточка одной межмодульной связи
ПолеПримерКак подтвердить
ConsumercheckoutФайл вызывающего кода и его пакет
OwnercatalogВладелец инварианта и данных операции
Surfacecatalog.api.ProductViewПубличный пакет, интерфейс или команда
ScenarioПоказать карточку перед оформлениемТест или описание пользовательского пути
Directioncheckout → catalogКарта разрешённых зависимостей и поиск обратного пути
\n

Запись «checkout зависит от catalog» слишком широкая. Она одинаково скрывает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api уже даёт объект для ревью. Если нельзя назвать сценарий или owner, новый импорт лучше остановить до выяснения ответственности.

\n

Что именно ломается

\n

Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.

\n

Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.

\n

Граница состоит из поверхности и направления

\n

Модуль владеет смыслом операции, данными и правилами их изменения. Публичная поверхность — это обещание другому модулю, а не все классы с модификатором public. Она может быть командой, запросом, событием, портом или небольшим интерфейсом. Внутренний formatter, ORM-репозиторий и таблица не становятся API только из-за удобства импорта.

\n

В учебной карте разрешены три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Обращение checkout → catalog.internal запрещено. Обращение payments → checkout.api также требует решения: если checkout уже вызывает payments, оно замыкает цикл и оставляет неясным владельца orchestration — последовательности действий.

\n
\"Петля
Схема показывает порядок проверки связи: сначала фиксируются source и surface, затем сверяются API, направление и цикл. Это модель процесса, а не результат сканирования конкретного репозитория.
\n

Если проект использует Java Platform Module System, часть границы можно закрепить физически. Например, каталог экспортирует только API-пакет:

\n
module 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 не превращает папку в изолированный модуль.

\n

Три импорта под микроскопом

\n

Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.

\n

Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.

\n

Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.

\n
СимптомПричинаПроверкаДействие
Новый вызов проходит компиляцию, но его цель неяснаНет записи о сценарии и владельцеВыписать source, target.surface и инвариантОставить ссылку только после явного контракта
Consumer импортирует internalПубличная поверхность не покрывает потребностьСравнить импорт с опубликованными пакетами или интерфейсамиВернуть операцию владельцу или добавить узкий API
Два модуля вызывают друг другаНовая обратная связь добавлена без владельца процессаПостроить граф и найти циклВыбрать orchestration или event contract
Ссылка ведёт в неизвестный модульКарта зависимостей устарела или неполнаСверить имя с исходниками и конфигурацией модулейОстановить изменение до обновления карты
После переноса тесты зелёные, но граница снова открытаПроверка была только примером, без правилаЗапустить структурную проверку и отрицательный тестЗакрепить запрет на уровне сборки или тестового набора
\n
\"Петля
Учебная схема показывает порядок boundary review. Она не является отчётом CI и не доказывает наличие такой связи в конкретном проекте.
\n

Отрицательный путь важнее зелёной ветки

\n

Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.

\n

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

\n

Запустите проверку на фактическом коде

\n

Сначала найдите реальные ссылки, не полагаясь на карту из памяти. Команда ниже подходит для Java-проекта, где исходники лежат в src/main/java; путь и имена пакетов нужно заменить на проектные:

\n
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, его можно запустить так:

\n
./gradlew test --tests \\\n  'com.acme.architecture.ModuleBoundaryTest'\n
\n

Этот тест проверяет зависимости классов, попавших в импорт ArchUnit. Он не видит автоматически SQL-связи, вызовы через reflection, конфигурацию контейнера, внешние очереди и бизнес-правильность транзакции. Поэтому результат нужно читать точно: «две заданные архитектурные проверки не нашли нарушение в импортированном наборе классов», а не «модуль полностью изолирован».

\n

Исправление должно уменьшать связность

\n
  1. Остановите обсуждение на конкретной ссылке. Укажите файл, consumer, owner, поверхность и сценарий. Формулировка «модули связаны» слишком широка для решения.
  2. Разделите факт и гипотезу. Реальный import подтвердите исходником или разрешённым анализатором. Не выдавайте учебную карту за evidence.
  3. Проверьте поверхность. Сопоставьте вызов с опубликованным API. Если consumer использует internal, решите, где должен жить инвариант.
  4. Проверьте направление. Добавьте стрелку в карту и найдите обратный путь. При цикле назначьте orchestration или сформулируйте событие.
  5. Выберите малое обратимое изменение. Перенесите один вызов, добавьте узкий адаптер или ограничьте API. Зафиксируйте, как удалить временное решение.
  6. Закрепите правило. Добавьте структурный тест, проверку модульной схемы или иной автоматический сигнал. Отдельно проверьте запрещённый импорт.
  7. Запишите решение. Оставьте владельца, сценарий, разрешённое направление, исключение, дату пересмотра и подтверждённый источник факта.
\n

Ограничения

\n

Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.

\n

Не всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.

\n

Инструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.

\n

Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.

\n

Проверяемые источники

\n" }