{ "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. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.
Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.
Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.
Граница модуля состоит из трёх договорённостей. Владелец отвечает за данные и инварианты. Поверхность API перечисляет возможности, которые владелец готов поддерживать. Направление зависимости ограничивает сценарии, в которых другой модуль может этой возможностью пользоваться. Папка помогает увидеть структуру, но сама по себе границу не создаёт.
\nПроверка начинается не с названия класса, а с конкретной ссылки. Запишите её как source → target.surface. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.
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 или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.
Допустимая поверхность. 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Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.
\nНе всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.
\nИнструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.
\nИзменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.
Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.
\n