8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 139,
|
||
"slug": "editorial-2024-02-field-modular-monolith",
|
||
"title": "Модульный монолит под ревью: как остановить протечку границ",
|
||
"excerpt": "Три похожих импорта могут незаметно связать каталог, checkout и оплату. Разбираем границы на учебном примере, проверяем API и направление зависимостей, а затем выбираем обратимое исправление.",
|
||
"contentHtml": "<p>В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает внутренний formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется, а тест одного сценария проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду.</p>\n<p>Проблема не в красоте дерева каталогов. Скрытая связь расширяет область изменения: внутренний тип становится контрактом, цикл усложняет порядок вызовов, а общий релиз приходится проверять целиком. Модульный монолит помогает только тогда, когда границы можно назвать и проверить: кто владеет смыслом операции, какая поверхность опубликована и в каком направлении разрешено обращение.</p>\n<p>Ниже — воспроизводимая модель с модулями <code>catalog</code>, <code>checkout</code>, <code>payments</code> и <code>notifications</code>. Имена и связи вымышлены, поэтому это не отчёт о конкретном production-проекте. Их можно заменить своими пакетами и прогнать те же проверки на настоящем репозитории.</p>\n<h2>Сначала отделите факт от предположения</h2>\n<p>В ревью легко перепутать наблюдение с объяснением. Факт — checkout действительно импортирует класс из <code>catalog.internal</code>. Гипотеза — этот импорт появился потому, что публичный API не выражает нужный сценарий. Решение — перенести операцию к каталогу или добавить узкий публичный контракт. Каждый слой требует своей проверки.</p>\n<table><caption>Минимальная карточка одной межмодульной связи</caption><thead><tr><th>Поле</th><th>Пример</th><th>Как подтвердить</th></tr></thead><tbody><tr><td>Consumer</td><td><code>checkout</code></td><td>Файл вызывающего кода и его пакет</td></tr><tr><td>Owner</td><td><code>catalog</code></td><td>Владелец инварианта и данных операции</td></tr><tr><td>Surface</td><td><code>catalog.api.ProductView</code></td><td>Публичный пакет, интерфейс или команда</td></tr><tr><td>Scenario</td><td>Показать карточку перед оформлением</td><td>Тест или описание пользовательского пути</td></tr><tr><td>Direction</td><td><code>checkout → catalog</code></td><td>Карта разрешённых зависимостей и поиск обратного пути</td></tr></tbody></table>\n<p>Запись «checkout зависит от catalog» слишком широкая. Она одинаково скрывает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись <code>checkout → catalog.api</code> уже даёт объект для ревью. Если нельзя назвать сценарий или owner, новый импорт лучше остановить до выяснения ответственности.</p>\n<h2>Что именно ломается</h2>\n<p>Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета <code>internal</code>, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в <code>api</code>, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.</p>\n<p>Рассмотрим три связи. <code>checkout → catalog.api</code> может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. <code>checkout → catalog.internal</code> нарушает границу: checkout зависит от детали реализации. <code>payments → checkout.api</code> требует отдельного решения, если checkout уже вызывает <code>payments.api</code>. Публичность метода не делает любое направление безопасным.</p>\n<h2>Граница состоит из поверхности и направления</h2>\n<p>Модуль владеет смыслом операции, данными и правилами их изменения. Публичная поверхность — это обещание другому модулю, а не все классы с модификатором <code>public</code>. Она может быть командой, запросом, событием, портом или небольшим интерфейсом. Внутренний formatter, ORM-репозиторий и таблица не становятся API только из-за удобства импорта.</p>\n<p>В учебной карте разрешены три связи: <code>checkout → catalog.api</code>, <code>checkout → payments.api</code> и <code>payments → notifications.api</code>. Обращение <code>checkout → catalog.internal</code> запрещено. Обращение <code>payments → checkout.api</code> также требует решения: если checkout уже вызывает payments, оно замыкает цикл и оставляет неясным владельца orchestration — последовательности действий.</p>\n<figure><img src=\"/assets/editorial/2024/modular-monolith-2024-boundary-test-loop.svg\" alt=\"Петля проверки границ модульного монолита: связь проходит сверку публичной поверхности, разрешённого направления и циклов\" loading=\"lazy\" /><figcaption>Схема показывает порядок проверки связи: сначала фиксируются source и surface, затем сверяются API, направление и цикл. Это модель процесса, а не результат сканирования конкретного репозитория.</figcaption></figure>\n<p>Если проект использует Java Platform Module System, часть границы можно закрепить физически. Например, каталог экспортирует только API-пакет:</p>\n<pre><code>module com.acme.catalog {\n exports com.acme.catalog.api;\n}\n\nmodule com.acme.checkout {\n requires com.acme.catalog;\n}\n</code></pre>\n<p>Такой <code>module-info.java</code> ограничивает доступ к неэкспортированным пакетам на уровне JPMS. Но многие Java-приложения работают на classpath или используют собственное разбиение пакетов. В них понадобится архитектурный тест и правило сборки; один namespace не превращает папку в изолированный модуль.</p>\n<h2>Три импорта под микроскопом</h2>\n<p><strong>Допустимая поверхность.</strong> <code>checkout → catalog.api</code> оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.</p>\n<p><strong>Протечка во внутренность.</strong> <code>checkout → catalog.internal</code> обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.</p>\n<p><strong>Обратная зависимость.</strong> <code>payments → checkout.api</code> не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.</p>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Новый вызов проходит компиляцию, но его цель неясна</td><td>Нет записи о сценарии и владельце</td><td>Выписать source, target.surface и инвариант</td><td>Оставить ссылку только после явного контракта</td></tr><tr><td>Consumer импортирует <code>internal</code></td><td>Публичная поверхность не покрывает потребность</td><td>Сравнить импорт с опубликованными пакетами или интерфейсами</td><td>Вернуть операцию владельцу или добавить узкий API</td></tr><tr><td>Два модуля вызывают друг друга</td><td>Новая обратная связь добавлена без владельца процесса</td><td>Построить граф и найти цикл</td><td>Выбрать orchestration или event contract</td></tr><tr><td>Ссылка ведёт в неизвестный модуль</td><td>Карта зависимостей устарела или неполна</td><td>Сверить имя с исходниками и конфигурацией модулей</td><td>Остановить изменение до обновления карты</td></tr><tr><td>После переноса тесты зелёные, но граница снова открыта</td><td>Проверка была только примером, без правила</td><td>Запустить структурную проверку и отрицательный тест</td><td>Закрепить запрет на уровне сборки или тестового набора</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2024/modular-monolith-2024-boundary-test-loop.svg\" alt=\"Петля проверки границ модульного монолита: ссылка проходит проверку поверхности, направления и цикла\" /><figcaption>Учебная схема показывает порядок boundary review. Она не является отчётом CI и не доказывает наличие такой связи в конкретном проекте.</figcaption></figure>\n<h2>Отрицательный путь важнее зелёной ветки</h2>\n<p>Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните <code>catalog → payments.api</code>, если для неё нет сценария и разрешённого направления. Отклоните <code>checkout → catalog.internal</code>, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.</p>\n<p>Учебная фикстура полезна для проверки формы рассуждения: фиксированный набор модулей, точный список связей, ожидаемые решения и отрицательные входы. Она не читает файлы проекта, не строит граф по настоящим импортам, не обращается к сети и не сообщает о состоянии production. В реальной проверке эти источники должны быть названы отдельно. Отсутствие скана означает только отсутствие скана, а не отсутствие нарушения.</p>\n<h2>Запустите проверку на фактическом коде</h2>\n<p>Сначала найдите реальные ссылки, не полагаясь на карту из памяти. Команда ниже подходит для Java-проекта, где исходники лежат в <code>src/main/java</code>; путь и имена пакетов нужно заменить на проектные:</p>\n<pre><code>rg -n --glob '*.java' \\\n '^\\s*import\\s+.*(catalog|checkout|payments)' \\\n src/main/java\n</code></pre>\n<p>Поиск даёт список кандидатов, но не доказывает архитектурное нарушение: он не понимает, какой пакет экспортирован, и может найти комментарий или строку. Для автоматического запрета удобнее импортировать байткод в ArchUnit. Минимальный тест ниже запрещает checkout ссылаться на внутренность каталога и требует ацикличности срезов:</p>\n<pre><code>@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</code></pre>\n<p>Если тест находится в Gradle-проекте с именем класса <code>ModuleBoundaryTest</code>, его можно запустить так:</p>\n<pre><code>./gradlew test --tests \\\n 'com.acme.architecture.ModuleBoundaryTest'\n</code></pre>\n<p>Этот тест проверяет зависимости классов, попавших в импорт ArchUnit. Он не видит автоматически SQL-связи, вызовы через reflection, конфигурацию контейнера, внешние очереди и бизнес-правильность транзакции. Поэтому результат нужно читать точно: «две заданные архитектурные проверки не нашли нарушение в импортированном наборе классов», а не «модуль полностью изолирован».</p>\n<h2>Исправление должно уменьшать связность</h2>\n<ol><li><strong>Остановите обсуждение на конкретной ссылке.</strong> Укажите файл, consumer, owner, поверхность и сценарий. Формулировка «модули связаны» слишком широка для решения.</li><li><strong>Разделите факт и гипотезу.</strong> Реальный import подтвердите исходником или разрешённым анализатором. Не выдавайте учебную карту за evidence.</li><li><strong>Проверьте поверхность.</strong> Сопоставьте вызов с опубликованным API. Если consumer использует internal, решите, где должен жить инвариант.</li><li><strong>Проверьте направление.</strong> Добавьте стрелку в карту и найдите обратный путь. При цикле назначьте orchestration или сформулируйте событие.</li><li><strong>Выберите малое обратимое изменение.</strong> Перенесите один вызов, добавьте узкий адаптер или ограничьте API. Зафиксируйте, как удалить временное решение.</li><li><strong>Закрепите правило.</strong> Добавьте структурный тест, проверку модульной схемы или иной автоматический сигнал. Отдельно проверьте запрещённый импорт.</li><li><strong>Запишите решение.</strong> Оставьте владельца, сценарий, разрешённое направление, исключение, дату пересмотра и подтверждённый источник факта.</li></ol>\n<h2>Ограничения</h2>\n<p>Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.</p>\n<p>Не всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.</p>\n<p>Инструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, когда для каждой новой стрелки существует запись <code>source → target.surface → scenario → owner</code>; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.</p>\n<p>Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html\" target=\"_blank\" rel=\"noopener noreferrer\">Java Language Specification, Java SE 17, глава 7: Packages and Modules</a> — официальное описание пакетов, директив <code>requires</code> и <code>exports</code>, а также доступа к экспортированным пакетам.</li><li><a href=\"https://docs.spring.io/spring-modulith/reference/verification.html\" target=\"_blank\" rel=\"noopener noreferrer\">Spring Modulith: Verifying Application Module Structure</a> — официально описанные проверки циклов, доступа через API-пакеты и явно разрешённых зависимостей.</li><li><a href=\"https://docs.spring.io/spring-modulith/reference/fundamentals.html\" target=\"_blank\" rel=\"noopener noreferrer\">Spring Modulith: Fundamentals</a> — модель application modules и структурная валидация для Spring-приложений.</li><li><a href=\"https://www.archunit.org/userguide/html/000_Index.html\" target=\"_blank\" rel=\"noopener noreferrer\">ArchUnit User Guide</a> — официальный guide по архитектурным правилам, анализу байткода, циклам и интеграции с тестовым фреймворком.</li></ul>"
|
||
}
|