{ "index": 141, "slug": "editorial-2024-02-practice-modular-monolith", "title": "Модульный монолит: как сделать границы зависимостей проверяемыми", "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в тесты.", "contentHtml": "
Сбой начинается с безобидного импорта. Код оформления заказа берёт ProductParser из catalog.internal, потому что нужного метода в API каталога пока нет. Сборка проходит, тест на один сценарий тоже. Через месяц изменение формата цены требует искать потребителей в checkout и оплате. Команда уже не знает, какой класс можно менять локально, а какой стал неявным контрактом. Цена ошибки — связанный релиз и ревью, в котором границу приходится восстанавливать по памяти.
Папка с названием домена не защищает модуль. Она только помогает найти файлы. Защита появляется, когда команда называет владельца, публичную поверхность и разрешённое направление связи, а затем проверяет это правило на фактическом коде. Ниже — учебная схема и рабочий Java-пример; они отвечают на узкий вопрос: как ловить протечки внутренних пакетов и циклы до выделения сервисов.
\nУ модуля есть предоставляемая и требуемая стороны. Предоставляемая сторона — команда, запрос, порт, тип или событие, которым могут пользоваться другие части системы. Требуемая сторона — контракты, от которых модуль зависит. Одного списка публичных классов мало: он не объясняет, кому разрешено обращение и зачем.
\nДля каждой связи записывайте тройку consumer → owner.surface. Запись checkout → catalog слишком широка: она допускает API, repository и внутренний mapper. Запись checkout → catalog.api уже задаёт объект проверки. Если нужен новый смысл, владелец каталога решает, добавить ли узкий метод, событие или оставить операцию в checkout.
Учебная модель использует четыре условных модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены три связи. Эти имена, стрелки и выводы не описывают реальный продукт, репозиторий или production-метрики.
| Потребитель | Владелец и surface | Решение | Граница |
|---|---|---|---|
| checkout | catalog.api | разрешено | цена читается через контракт каталога |
| checkout | payments.api | разрешено | заказ не знает внутреннюю оплату |
| payments | notifications.api | разрешено | уведомление вызывается через отдельную поверхность |
| любой модуль | чужой *.internal | запрещено | деталь остаётся у владельца |
| catalog | payments.api | запрещено в модели | новая стрелка требует сценария и владельца |
Хороший API выражает потребность потребителя, а не внутреннее хранение. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и внутренний parser. Ему нужен узкий порт. Владелец может поменять таблицу и способ разбора данных, если сохраняет смысл порта и его контракт.
/* Учебный пример: публичный порт catalog. */\nexport type ProductQuote = {\n sku: string;\n price: number;\n currency: string;\n};\n\nexport interface CatalogReader {\n quote(sku: string): Promise<ProductQuote>;\n}\n\n// Разрешённый импорт:\nimport type { CatalogReader } from '../catalog/public';\n\n// Запрещённый импорт:\nimport { ProductParser } from '../catalog/internal/ProductParser';\nПубличный модификатор и архитектурный API — не одно и то же. В монолите технически доступный класс часто можно импортировать из соседнего пакета. Поэтому нужны два слоя: средства языка ограничивают физическую видимость, а архитектурное правило фиксирует смысловую границу. Нельзя считать любой доступный символ частью обещанного API.
\nЗапрет на internal не решает обратные зависимости. Если checkout вызывает payments, а payments вызывает checkout, граф замыкается. Цикл не доказывает, что предметная модель ошибочна, но показывает: владелец операции или сообщения не назван. Пока цикл живёт в графе, изменение одного модуля требует держать в голове другой, а поэтапная миграция дорожает.
\nРазрыв выбирают по смыслу. Результат операции можно отправить событием в одну сторону. Общий неизменяемый тип можно вынести без поведения. Операцию, которую ошибочно вызвали из другого модуля, можно вернуть владельцу. Пакет common сам по себе ничего не исправляет: без владельца он превращается в новое место для скрытых связей.
| Симптом | Причина | Проверка | Следующее действие |
|---|---|---|---|
| Изменение внутреннего класса требует правок у потребителя | Потребитель импортирует деталь вместо контракта | Выписать consumer, owner и surface | Сформировать узкий порт и перевести один вызов |
| Два модуля ссылаются друг на друга | Не выбран владелец процесса или сообщения | Построить граф прямых зависимостей | Выбрать событие, orchestration или перенос операции |
Новые вызовы уходят в shared | Временный helper стал общей точкой входа | Проверить владельца каждого типа и поведения | Вернуть код владельцу или разделить контракты |
| Граница зелёная, но API отдаёт таблицу целиком | Структурное правило приняли за бизнес-контракт | Сопоставить ответ с конкретной потребностью | Сузить DTO и добавить функциональный тест |
| Тест ничего не нашёл | Проверен не тот package graph или только модель | Сверить область анализа и фактические импорты | Исправить scope, затем повторить отрицательный тест |
Учебная матрица помогает договориться, но не читает исходники. Для Java-проекта с JUnit 5 можно подключить ArchUnit и проверять скомпилированные классы. Версия 1.1.0 была доступна в феврале 2024 года; в новом проекте версию нужно сверить с JDK, JUnit и сборкой, а не копировать без проверки.
// 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}\nМинимальная настройка Gradle и запуск конкретного теста выглядят так:
\ntestImplementation(\"com.tngtech.archunit:archunit-junit5:1.1.0\")\n\n./gradlew test --tests com.example.ArchitectureTest\nПервое правило запрещает классам checkout зависеть от пакета catalog.internal. Второе рассматривает сегмент после com.example как slice и ищет цикл между такими сегментами. Если в проекте другая структура пакетов, шаблон нужно изменить. Иначе тест может быть зелёным просто потому, что анализирует не тот scope.
На этапе диагностики можно быстро найти очевидные нарушения:
\nrg -n \"import .*catalog\\.internal|from .*catalog/internal\" src/main src/test\n\n# После миграции production-исходники не должны дать результатов.\nrg -n \"catalog\\.internal\" src/main || true\nПоиск строк не заменяет архитектурный тест: он пропускает алиасы, статические вызовы, сгенерированный код и зависимости через тип поля. Его результат — список кандидатов для проверки. Финальное правило должно понимать язык и запускаться на том наборе классов или модулей, который действительно нужно защитить.
\nДля ревью полезно хранить не только стрелку, но и её смысл. Владелец отвечает за инвариант и изменение контракта. Потребитель отвечает за сценарий и не использует API как скрытый repository.
\n| Поле | Пример | Проверяемый вопрос |
|---|---|---|
| Сценарий | рассчитать цену позиции | какую потребность покрывает вызов? |
| Потребитель | checkout | кто инициирует обращение? |
| Владелец | catalog | кто меняет инвариант и контракт? |
| Поверхность | catalog.api.CatalogReader | какой символ разрешён? |
| Запрет | catalog.internal.* | какой близкий путь должен ломать тест? |
| Направление | checkout → catalog | не появился ли обратный вызов? |
Если для метода нельзя заполнить сценарий и владельца, проблема находится раньше реализации. Не публикуйте целый namespace «на будущее»: поверхность растёт, а решение о данных откладывается. Узкий контракт проще проверить и заменить.
\nfrom → to.surface, разрешённые направления и запрет на internal-доступ. Для временного исключения добавьте владельца и дату пересмотра.Проверка зависимостей отвечает на структурный вопрос: кто обращается к чьему коду и через какую поверхность. Она не отвечает за цену, транзакции, права доступа, задержку, размер ответа, совместимость событий и владение таблицами. Разрешённая стрелка может вести к медленному или небезопасному API, поэтому нужны отдельные функциональные, нагрузочные и security-тесты.
\nОбычные пакеты Java не равны именованным модулям Java Platform Module System. JLS описывает exports и явные зависимости для модульной системы, но приложение на classpath может оставить больше доступных типов. Spring Modulith добавляет модель логических модулей Spring Boot и проверку API-пакетов, циклов и разрешённых зависимостей. Это framework-specific механизм, а не обязательная архитектура любого монолита.
ArchUnit анализирует импортированные скомпилированные классы. Он проверяет только область, указанную в @AnalyzeClasses, а качество результата зависит от базового пакета и исключений. Reflection, SQL-зависимости, сгенерированный код и внешние сервисы требуют других проверок. Поэтому «цикл не найден» означает «цикл не найден в проверенном графе классов», а не «архитектура доказана».
Цикл не нужно вырезать механически. Сначала определите, является ли обратная связь командой, событием, общим типом или ошибочно выбранным владельцем. Если связь предметно необходима, оставьте её как явно названное исключение, примите стоимость и проверьте отдельно. Пакет common без владельца не уменьшает связанность, а прячет её.
Участок оформлен, когда для каждого межмодульного вызова команда показывает сценарий, владельца, точную поверхность и направление. Автоматическая проверка проходит по фактическому набору исходников или байткода: разрешённый вызов проходит, internal-импорт ломается, неизвестная стрелка ломается, а цикл получает отдельное сообщение. Учебная карта может проверить только форму договора и должна так себя называть.
\nЕсли один ответ отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца, перенесите операцию к нему или оставьте код локальным. Польза модульного монолита — не в красивом дереве папок, а в меньшей области изменения, которую можно подтвердить следующим запуском теста.
\nApplicationModules.verify() относится к приложению Spring Modulith и не заменяет проверки данных, безопасности или runtime.@AnalyzeClasses.