8 lines
19 KiB
JSON
8 lines
19 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>Модульный монолит не запрещает модулям общаться. Он делает эту связь проверяемой. Для каждой стрелки нужно назвать consumer, owner, публичную поверхность и направление. Если хотя бы одно поле неизвестно, импорт ещё не является понятным контрактом. Такой разбор не доказывает корректность всего приложения. Он отвечает на более узкий вопрос: кто имеет право вызвать кого и что произойдёт с границей после следующего изменения.</p>\n<p>Ниже приведён учебный пример с фиксированными именами <code>catalog</code>, <code>checkout</code>, <code>payments</code> и <code>notifications</code>. Он не описывает реальный репозиторий, метрики или результат CI. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.</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>Граница модуля состоит из трёх договорённостей. Владелец отвечает за данные и инварианты. Поверхность API перечисляет возможности, которые владелец готов поддерживать. Направление зависимости ограничивает сценарии, в которых другой модуль может этой возможностью пользоваться. Папка помогает увидеть структуру, но сама по себе границу не создаёт.</p>\n<p>Проверка начинается не с названия класса, а с конкретной ссылки. Запишите её как <code>source → target.surface</code>. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.</p>\n<pre><code>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</code></pre>\n<p>Код выше — учебная запись, а не готовая библиотека. В реальном Java-проекте вместо строки <code>surface</code> понадобятся пакеты, named interface или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.</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<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><ul><li><a href=\"https://docs.spring.io/spring-modulith/reference/verification.html\" target=\"_blank\" rel=\"noopener\">Spring Modulith: Verifying Application Module Structure</a> — проверка циклов и явно разрешённых зависимостей.</li><li><a href=\"https://docs.spring.io/spring-modulith/reference/fundamentals.html\" target=\"_blank\" rel=\"noopener\">Spring Modulith: Fundamentals</a> — модель application modules и структурная валидация.</li><li><a href=\"https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html\" target=\"_blank\" rel=\"noopener\">Java Language Specification, глава 7: Packages</a> — официальный контекст пакетов и зависимостей типов.</li></ul>"
|
||
}
|