Files
progcode/editorial/agent-rewrites/139.json
T

8 lines
24 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>"
}