Files

8 lines
25 KiB
JSON
Raw Permalink 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": 141,
"slug": "editorial-2024-02-practice-modular-monolith",
"title": "Модульный монолит: как сделать границы зависимостей проверяемыми",
"excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в тесты.",
"contentHtml": "<p>Сбой начинается с безобидного импорта. Код оформления заказа берёт <code>ProductParser</code> из <code>catalog.internal</code>, потому что нужного метода в API каталога пока нет. Сборка проходит, тест на один сценарий тоже. Через месяц изменение формата цены требует искать потребителей в checkout и оплате. Команда уже не знает, какой класс можно менять локально, а какой стал неявным контрактом. Цена ошибки — связанный релиз и ревью, в котором границу приходится восстанавливать по памяти.</p>\n<p>Папка с названием домена не защищает модуль. Она только помогает найти файлы. Защита появляется, когда команда называет владельца, публичную поверхность и разрешённое направление связи, а затем проверяет это правило на фактическом коде. Ниже — учебная схема и рабочий Java-пример; они отвечают на узкий вопрос: как ловить протечки внутренних пакетов и циклы до выделения сервисов.</p>\n<h2>Модуль — это контракт, а не каталог</h2>\n<p>У модуля есть предоставляемая и требуемая стороны. Предоставляемая сторона — команда, запрос, порт, тип или событие, которым могут пользоваться другие части системы. Требуемая сторона — контракты, от которых модуль зависит. Одного списка публичных классов мало: он не объясняет, кому разрешено обращение и зачем.</p>\n<p>Для каждой связи записывайте тройку <code>consumer → owner.surface</code>. Запись <code>checkout → catalog</code> слишком широка: она допускает API, repository и внутренний mapper. Запись <code>checkout → catalog.api</code> уже задаёт объект проверки. Если нужен новый смысл, владелец каталога решает, добавить ли узкий метод, событие или оставить операцию в checkout.</p>\n<p>Учебная модель использует четыре условных модуля: <code>catalog</code> владеет товарами, <code>checkout</code> собирает заказ, <code>payments</code> проводит оплату, <code>notifications</code> отправляет уведомления. Разрешены три связи. Эти имена, стрелки и выводы не описывают реальный продукт, репозиторий или production-метрики.</p>\n<div class=\"table-scroll\"><table><caption>Матрица разрешений для учебного сценария</caption><thead><tr><th scope=\"col\">Потребитель</th><th scope=\"col\">Владелец и surface</th><th scope=\"col\">Решение</th><th scope=\"col\">Граница</th></tr></thead><tbody><tr><td>checkout</td><td><code>catalog.api</code></td><td>разрешено</td><td>цена читается через контракт каталога</td></tr><tr><td>checkout</td><td><code>payments.api</code></td><td>разрешено</td><td>заказ не знает внутреннюю оплату</td></tr><tr><td>payments</td><td><code>notifications.api</code></td><td>разрешено</td><td>уведомление вызывается через отдельную поверхность</td></tr><tr><td>любой модуль</td><td>чужой <code>*.internal</code></td><td>запрещено</td><td>деталь остаётся у владельца</td></tr><tr><td>catalog</td><td><code>payments.api</code></td><td>запрещено в модели</td><td>новая стрелка требует сценария и владельца</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2024/modular-monolith-2024-allowed-directions.svg\" alt=\"Схема учебного модульного монолита: checkout вызывает catalog.api и payments.api, payments вызывает notifications.api, а обращение к catalog.internal отмечено как запрещённое\" loading=\"lazy\" /><figcaption>Схема отделяет public API от internal-зоны и показывает направление связи. Это проектное правило учебного примера, а не результат сканирования исходников.</figcaption></figure>\n<h2>Как поверхность удерживает границу</h2>\n<p>Хороший API выражает потребность потребителя, а не внутреннее хранение. Если checkout нужен итоговый товар для расчёта цены, ему не нужен <code>ProductEntity</code>, JPA repository и внутренний parser. Ему нужен узкий порт. Владелец может поменять таблицу и способ разбора данных, если сохраняет смысл порта и его контракт.</p>\n<pre><code>/* Учебный пример: публичный порт catalog. */\nexport type ProductQuote = {\n sku: string;\n price: number;\n currency: string;\n};\n\nexport interface CatalogReader {\n quote(sku: string): Promise&lt;ProductQuote&gt;;\n}\n\n// Разрешённый импорт:\nimport type { CatalogReader } from '../catalog/public';\n\n// Запрещённый импорт:\nimport { ProductParser } from '../catalog/internal/ProductParser';</code></pre>\n<p>Публичный модификатор и архитектурный API — не одно и то же. В монолите технически доступный класс часто можно импортировать из соседнего пакета. Поэтому нужны два слоя: средства языка ограничивают физическую видимость, а архитектурное правило фиксирует смысловую границу. Нельзя считать любой доступный символ частью обещанного API.</p>\n<p>Запрет на internal не решает обратные зависимости. Если checkout вызывает payments, а payments вызывает checkout, граф замыкается. Цикл не доказывает, что предметная модель ошибочна, но показывает: владелец операции или сообщения не назван. Пока цикл живёт в графе, изменение одного модуля требует держать в голове другой, а поэтапная миграция дорожает.</p>\n<p>Разрыв выбирают по смыслу. Результат операции можно отправить событием в одну сторону. Общий неизменяемый тип можно вынести без поведения. Операцию, которую ошибочно вызвали из другого модуля, можно вернуть владельцу. Пакет <code>common</code> сам по себе ничего не исправляет: без владельца он превращается в новое место для скрытых связей.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика протечки границы</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Следующее действие</th></tr></thead><tbody><tr><td>Изменение внутреннего класса требует правок у потребителя</td><td>Потребитель импортирует деталь вместо контракта</td><td>Выписать <code>consumer</code>, <code>owner</code> и <code>surface</code></td><td>Сформировать узкий порт и перевести один вызов</td></tr><tr><td>Два модуля ссылаются друг на друга</td><td>Не выбран владелец процесса или сообщения</td><td>Построить граф прямых зависимостей</td><td>Выбрать событие, orchestration или перенос операции</td></tr><tr><td>Новые вызовы уходят в <code>shared</code></td><td>Временный helper стал общей точкой входа</td><td>Проверить владельца каждого типа и поведения</td><td>Вернуть код владельцу или разделить контракты</td></tr><tr><td>Граница зелёная, но API отдаёт таблицу целиком</td><td>Структурное правило приняли за бизнес-контракт</td><td>Сопоставить ответ с конкретной потребностью</td><td>Сузить DTO и добавить функциональный тест</td></tr><tr><td>Тест ничего не нашёл</td><td>Проверен не тот package graph или только модель</td><td>Сверить область анализа и фактические импорты</td><td>Исправить scope, затем повторить отрицательный тест</td></tr></tbody></table>\n<h2>Проверка в Java-проекте</h2>\n<p>Учебная матрица помогает договориться, но не читает исходники. Для Java-проекта с JUnit 5 можно подключить ArchUnit и проверять скомпилированные классы. Версия <code>1.1.0</code> была доступна в феврале 2024 года; в новом проекте версию нужно сверить с JDK, JUnit и сборкой, а не копировать без проверки.</p>\n<pre><code>// src/test/java/com/example/ArchitectureTest.java\npackage com.example;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;\nimport static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;\n\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchRule;\n\n@AnalyzeClasses(packages = \"com.example\")\nclass ArchitectureTest {\n @ArchTest\n static final ArchRule checkout_does_not_use_catalog_internals =\n noClasses()\n .that().resideInAnyPackage(\"..checkout..\")\n .should().dependOnClassesThat()\n .resideInAnyPackage(\"..catalog.internal..\");\n\n @ArchTest\n static final ArchRule modules_are_free_of_cycles =\n slices().matching(\"com.example.(*)..\").should().beFreeOfCycles();\n}</code></pre>\n<p>Минимальная настройка Gradle и запуск конкретного теста выглядят так:</p>\n<pre><code>testImplementation(\"com.tngtech.archunit:archunit-junit5:1.1.0\")\n\n./gradlew test --tests com.example.ArchitectureTest</code></pre>\n<p>Первое правило запрещает классам checkout зависеть от пакета <code>catalog.internal</code>. Второе рассматривает сегмент после <code>com.example</code> как slice и ищет цикл между такими сегментами. Если в проекте другая структура пакетов, шаблон нужно изменить. Иначе тест может быть зелёным просто потому, что анализирует не тот scope.</p>\n<p>На этапе диагностики можно быстро найти очевидные нарушения:</p>\n<pre><code>rg -n \"import .*catalog\\.internal|from .*catalog/internal\" src/main src/test\n\n# После миграции production-исходники не должны дать результатов.\nrg -n \"catalog\\.internal\" src/main || true</code></pre>\n<p>Поиск строк не заменяет архитектурный тест: он пропускает алиасы, статические вызовы, сгенерированный код и зависимости через тип поля. Его результат — список кандидатов для проверки. Финальное правило должно понимать язык и запускаться на том наборе классов или модулей, который действительно нужно защитить.</p>\n<h2>Как описать связь до написания кода</h2>\n<p>Для ревью полезно хранить не только стрелку, но и её смысл. Владелец отвечает за инвариант и изменение контракта. Потребитель отвечает за сценарий и не использует API как скрытый repository.</p>\n<table><caption>Минимальная запись межмодульного контракта</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Пример</th><th scope=\"col\">Проверяемый вопрос</th></tr></thead><tbody><tr><td>Сценарий</td><td>рассчитать цену позиции</td><td>какую потребность покрывает вызов?</td></tr><tr><td>Потребитель</td><td><code>checkout</code></td><td>кто инициирует обращение?</td></tr><tr><td>Владелец</td><td><code>catalog</code></td><td>кто меняет инвариант и контракт?</td></tr><tr><td>Поверхность</td><td><code>catalog.api.CatalogReader</code></td><td>какой символ разрешён?</td></tr><tr><td>Запрет</td><td><code>catalog.internal.*</code></td><td>какой близкий путь должен ломать тест?</td></tr><tr><td>Направление</td><td><code>checkout → catalog</code></td><td>не появился ли обратный вызов?</td></tr></tbody></table>\n<p>Если для метода нельзя заполнить сценарий и владельца, проблема находится раньше реализации. Не публикуйте целый namespace «на будущее»: поверхность растёт, а решение о данных откладывается. Узкий контракт проще проверить и заменить.</p>\n<h2>Порядок внедрения без большой переделки</h2>\n<ol><li><strong>Выберите один болезненный стык.</strong> Возьмите импорт, который регулярно тянет чужую внутренность или замыкает обратную связь. Не перестраивайте весь монолит до появления первого правила.</li><li><strong>Снимите baseline.</strong> Соберите фактические межмодульные ссылки и отделите старый долг от разрешённых исключений. Иначе первая проверка смешает регрессии с уже известными нарушениями.</li><li><strong>Назначьте владельца.</strong> Запишите, какой модуль отвечает за данные, инвариант и изменение контракта. Потребитель не становится владельцем только потому, что первым вызвал функцию.</li><li><strong>Сузьте поверхность.</strong> Оставьте команду, запрос, порт или событие, выражающие потребность. Entity, repository и mapper не должны попасть в API случайно.</li><li><strong>Опишите граф.</strong> Зафиксируйте <code>from → to.surface</code>, разрешённые направления и запрет на internal-доступ. Для временного исключения добавьте владельца и дату пересмотра.</li><li><strong>Добавьте положительный и отрицательный тест.</strong> Разрешённый вызов должен проходить, internal-импорт и цикл — ломаться с понятной причиной. Положительный тест без отказа легко перестаёт защищать границу.</li><li><strong>Переведите один вызов.</strong> Замените один импорт на публичный порт, запустите тесты и убедитесь, что старый символ больше не нужен. Удаляйте внутренний доступ отдельным изменением, если так проще откатить результат.</li><li><strong>Запускайте правило вместе с кодом.</strong> Архитектурный тест должен входить в обычную проверку проекта. Диаграмма без автоматического отрицательного пути быстро устаревает.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Проверка зависимостей отвечает на структурный вопрос: кто обращается к чьему коду и через какую поверхность. Она не отвечает за цену, транзакции, права доступа, задержку, размер ответа, совместимость событий и владение таблицами. Разрешённая стрелка может вести к медленному или небезопасному API, поэтому нужны отдельные функциональные, нагрузочные и security-тесты.</p>\n<p>Обычные пакеты Java не равны именованным модулям Java Platform Module System. JLS описывает <code>exports</code> и явные зависимости для модульной системы, но приложение на classpath может оставить больше доступных типов. Spring Modulith добавляет модель логических модулей Spring Boot и проверку API-пакетов, циклов и разрешённых зависимостей. Это framework-specific механизм, а не обязательная архитектура любого монолита.</p>\n<p>ArchUnit анализирует импортированные скомпилированные классы. Он проверяет только область, указанную в <code>@AnalyzeClasses</code>, а качество результата зависит от базового пакета и исключений. Reflection, SQL-зависимости, сгенерированный код и внешние сервисы требуют других проверок. Поэтому «цикл не найден» означает «цикл не найден в проверенном графе классов», а не «архитектура доказана».</p>\n<p>Цикл не нужно вырезать механически. Сначала определите, является ли обратная связь командой, событием, общим типом или ошибочно выбранным владельцем. Если связь предметно необходима, оставьте её как явно названное исключение, примите стоимость и проверьте отдельно. Пакет <code>common</code> без владельца не уменьшает связанность, а прячет её.</p>\n<h2>Критерий готовности</h2>\n<p>Участок оформлен, когда для каждого межмодульного вызова команда показывает сценарий, владельца, точную поверхность и направление. Автоматическая проверка проходит по фактическому набору исходников или байткода: разрешённый вызов проходит, internal-импорт ломается, неизвестная стрелка ломается, а цикл получает отдельное сообщение. Учебная карта может проверить только форму договора и должна так себя называть.</p>\n<p>Если один ответ отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца, перенесите операцию к нему или оставьте код локальным. Польза модульного монолита — не в красивом дереве папок, а в меньшей области изменения, которую можно подтвердить следующим запуском теста.</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> — официальная спецификация о пакетах, видимости, модулях, экспортируемых пакетах и зависимостях. Ограничение: это правила Java Platform Module System, а не модель доменных модулей для любого языка.</li><li><a href=\"https://docs.spring.io/spring-modulith/reference/1.1/fundamentals.html\" target=\"_blank\" rel=\"noopener noreferrer\">Spring Modulith 1.1: Fundamentals</a> — официальная документация о logical application modules, API, internal packages и allowed dependencies в Spring Boot. Страница поддерживает ветку 1.1.x; перед применением сверяйте точный patch-релиз и совместимость проекта.</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-пакеты и явно разрешённых зависимостей. Ограничение: <code>ApplicationModules.verify()</code> относится к приложению Spring Modulith и не заменяет проверки данных, безопасности или runtime.</li><li><a href=\"https://www.archunit.org/userguide/html/000_Index.html\" target=\"_blank\" rel=\"noopener noreferrer\">ArchUnit User Guide</a> — официальное руководство с правилами package dependencies, slices without cycles, импортом class files и JUnit-интеграцией. Пример в статье ограничен Java-классами, указанными в <code>@AnalyzeClasses</code>.</li><li><a href=\"https://github.com/TNG/ArchUnit/releases/tag/v1.1.0\" target=\"_blank\" rel=\"noopener noreferrer\">ArchUnit 1.1.0 release</a> — официальный релиз, на который ссылается пример для временного контекста февраля 2024 года; в новом проекте проверяйте доступную версию и совместимость вместо автоматического копирования.</li></ul>"
}