{ "index": 139, "slug": "editorial-2024-02-field-modular-monolith", "title": "Модульный монолит под ревью: как остановить протечку границ", "excerpt": "Три похожих импорта могут незаметно связать каталог, checkout и оплату. Разбираем границы на учебном примере, проверяем API и направление зависимостей, а затем выбираем обратимое исправление.", "contentHtml": "

В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает приватный formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется. Тест на один сценарий проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду. Цена ошибки — не эстетика архитектуры. Она выражается в связанных релизах, длинном ревью и скрытом риске сломать соседний сценарий.

\n

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

\n

Ниже приведён учебный пример с фиксированными именами catalog, checkout, payments и notifications. Он не описывает реальный репозиторий, метрики или результат CI. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.

\n

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

\n

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

\n

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

\n

Тезис и механизм

\n

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

\n

Проверка начинается не с названия класса, а с конкретной ссылки. Запишите её как source → target.surface. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.

\n
record BoundaryLink(\n    String source,\n    String target,\n    String surface,\n    String scenario\n) {}\n\nBoundaryLink link = new BoundaryLink(\n    \"checkout\", \"catalog\", \"api\", \"show product card\"\n);\n\n// Проверяем отдельно:\n// 1. surface опубликована владельцем;\n// 2. source -> target разрешено картой;\n// 3. новая ссылка не замыкает цикл;\n// 4. scenario не переносит чужой инвариант.\n
\n

Код выше — учебная запись, а не готовая библиотека. В реальном Java-проекте вместо строки surface понадобятся пакеты, named interface или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.

\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
  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

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

" }