{ "index": 141, "slug": "editorial-2024-02-practice-modular-monolith", "title": "Модульный монолит: как сделать границы зависимостей проверяемыми", "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в тесты.", "contentHtml": "

Сбой начинается с безобидного импорта. Код оформления заказа берёт ProductParser из catalog.internal, потому что нужного метода в API каталога пока нет. Сборка проходит, тест на один сценарий тоже. Через месяц изменение формата цены требует искать потребителей в checkout и оплате. Команда уже не знает, какой класс можно менять локально, а какой стал неявным контрактом. Цена ошибки — связанный релиз и ревью, в котором границу приходится восстанавливать по памяти.

\n

Папка с названием домена не защищает модуль. Она только помогает найти файлы. Защита появляется, когда команда называет владельца, публичную поверхность и разрешённое направление связи, а затем проверяет это правило на фактическом коде. Ниже — учебная схема и рабочий Java-пример; они отвечают на узкий вопрос: как ловить протечки внутренних пакетов и циклы до выделения сервисов.

\n

Модуль — это контракт, а не каталог

\n

У модуля есть предоставляемая и требуемая стороны. Предоставляемая сторона — команда, запрос, порт, тип или событие, которым могут пользоваться другие части системы. Требуемая сторона — контракты, от которых модуль зависит. Одного списка публичных классов мало: он не объясняет, кому разрешено обращение и зачем.

\n

Для каждой связи записывайте тройку consumer → owner.surface. Запись checkout → catalog слишком широка: она допускает API, repository и внутренний mapper. Запись checkout → catalog.api уже задаёт объект проверки. Если нужен новый смысл, владелец каталога решает, добавить ли узкий метод, событие или оставить операцию в checkout.

\n

Учебная модель использует четыре условных модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены три связи. Эти имена, стрелки и выводы не описывают реальный продукт, репозиторий или production-метрики.

\n
Матрица разрешений для учебного сценария
ПотребительВладелец и surfaceРешениеГраница
checkoutcatalog.apiразрешеноцена читается через контракт каталога
checkoutpayments.apiразрешенозаказ не знает внутреннюю оплату
paymentsnotifications.apiразрешеноуведомление вызывается через отдельную поверхность
любой модульчужой *.internalзапрещенодеталь остаётся у владельца
catalogpayments.apiзапрещено в моделиновая стрелка требует сценария и владельца
\n
\"Схема
Схема отделяет public API от internal-зоны и показывает направление связи. Это проектное правило учебного примера, а не результат сканирования исходников.
\n

Как поверхность удерживает границу

\n

Хороший API выражает потребность потребителя, а не внутреннее хранение. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и внутренний parser. Ему нужен узкий порт. Владелец может поменять таблицу и способ разбора данных, если сохраняет смысл порта и его контракт.

\n
/* Учебный пример: публичный порт 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 сам по себе ничего не исправляет: без владельца он превращается в новое место для скрытых связей.

\n

Симптом → причина → проверка → действие

\n
Диагностика протечки границы
СимптомПричинаПроверкаСледующее действие
Изменение внутреннего класса требует правок у потребителяПотребитель импортирует деталь вместо контрактаВыписать consumer, owner и surfaceСформировать узкий порт и перевести один вызов
Два модуля ссылаются друг на другаНе выбран владелец процесса или сообщенияПостроить граф прямых зависимостейВыбрать событие, orchestration или перенос операции
Новые вызовы уходят в sharedВременный helper стал общей точкой входаПроверить владельца каждого типа и поведенияВернуть код владельцу или разделить контракты
Граница зелёная, но API отдаёт таблицу целикомСтруктурное правило приняли за бизнес-контрактСопоставить ответ с конкретной потребностьюСузить DTO и добавить функциональный тест
Тест ничего не нашёлПроверен не тот package graph или только модельСверить область анализа и фактические импортыИсправить scope, затем повторить отрицательный тест
\n

Проверка в Java-проекте

\n

Учебная матрица помогает договориться, но не читает исходники. Для Java-проекта с JUnit 5 можно подключить ArchUnit и проверять скомпилированные классы. Версия 1.1.0 была доступна в феврале 2024 года; в новом проекте версию нужно сверить с JDK, JUnit и сборкой, а не копировать без проверки.

\n
// 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 и запуск конкретного теста выглядят так:

\n
testImplementation(\"com.tngtech.archunit:archunit-junit5:1.1.0\")\n\n./gradlew test --tests com.example.ArchitectureTest
\n

Первое правило запрещает классам checkout зависеть от пакета catalog.internal. Второе рассматривает сегмент после com.example как slice и ищет цикл между такими сегментами. Если в проекте другая структура пакетов, шаблон нужно изменить. Иначе тест может быть зелёным просто потому, что анализирует не тот scope.

\n

На этапе диагностики можно быстро найти очевидные нарушения:

\n
rg -n \"import .*catalog\\.internal|from .*catalog/internal\" src/main src/test\n\n# После миграции production-исходники не должны дать результатов.\nrg -n \"catalog\\.internal\" src/main || true
\n

Поиск строк не заменяет архитектурный тест: он пропускает алиасы, статические вызовы, сгенерированный код и зависимости через тип поля. Его результат — список кандидатов для проверки. Финальное правило должно понимать язык и запускаться на том наборе классов или модулей, который действительно нужно защитить.

\n

Как описать связь до написания кода

\n

Для ревью полезно хранить не только стрелку, но и её смысл. Владелец отвечает за инвариант и изменение контракта. Потребитель отвечает за сценарий и не использует API как скрытый repository.

\n
Минимальная запись межмодульного контракта
ПолеПримерПроверяемый вопрос
Сценарийрассчитать цену позициикакую потребность покрывает вызов?
Потребительcheckoutкто инициирует обращение?
Владелецcatalogкто меняет инвариант и контракт?
Поверхностьcatalog.api.CatalogReaderкакой символ разрешён?
Запретcatalog.internal.*какой близкий путь должен ломать тест?
Направлениеcheckout → catalogне появился ли обратный вызов?
\n

Если для метода нельзя заполнить сценарий и владельца, проблема находится раньше реализации. Не публикуйте целый namespace «на будущее»: поверхность растёт, а решение о данных откладывается. Узкий контракт проще проверить и заменить.

\n

Порядок внедрения без большой переделки

\n
  1. Выберите один болезненный стык. Возьмите импорт, который регулярно тянет чужую внутренность или замыкает обратную связь. Не перестраивайте весь монолит до появления первого правила.
  2. Снимите baseline. Соберите фактические межмодульные ссылки и отделите старый долг от разрешённых исключений. Иначе первая проверка смешает регрессии с уже известными нарушениями.
  3. Назначьте владельца. Запишите, какой модуль отвечает за данные, инвариант и изменение контракта. Потребитель не становится владельцем только потому, что первым вызвал функцию.
  4. Сузьте поверхность. Оставьте команду, запрос, порт или событие, выражающие потребность. Entity, repository и mapper не должны попасть в API случайно.
  5. Опишите граф. Зафиксируйте from → to.surface, разрешённые направления и запрет на internal-доступ. Для временного исключения добавьте владельца и дату пересмотра.
  6. Добавьте положительный и отрицательный тест. Разрешённый вызов должен проходить, internal-импорт и цикл — ломаться с понятной причиной. Положительный тест без отказа легко перестаёт защищать границу.
  7. Переведите один вызов. Замените один импорт на публичный порт, запустите тесты и убедитесь, что старый символ больше не нужен. Удаляйте внутренний доступ отдельным изменением, если так проще откатить результат.
  8. Запускайте правило вместе с кодом. Архитектурный тест должен входить в обычную проверку проекта. Диаграмма без автоматического отрицательного пути быстро устаревает.
\n

Ограничения применимости

\n

Проверка зависимостей отвечает на структурный вопрос: кто обращается к чьему коду и через какую поверхность. Она не отвечает за цену, транзакции, права доступа, задержку, размер ответа, совместимость событий и владение таблицами. Разрешённая стрелка может вести к медленному или небезопасному API, поэтому нужны отдельные функциональные, нагрузочные и security-тесты.

\n

Обычные пакеты Java не равны именованным модулям Java Platform Module System. JLS описывает exports и явные зависимости для модульной системы, но приложение на classpath может оставить больше доступных типов. Spring Modulith добавляет модель логических модулей Spring Boot и проверку API-пакетов, циклов и разрешённых зависимостей. Это framework-specific механизм, а не обязательная архитектура любого монолита.

\n

ArchUnit анализирует импортированные скомпилированные классы. Он проверяет только область, указанную в @AnalyzeClasses, а качество результата зависит от базового пакета и исключений. Reflection, SQL-зависимости, сгенерированный код и внешние сервисы требуют других проверок. Поэтому «цикл не найден» означает «цикл не найден в проверенном графе классов», а не «архитектура доказана».

\n

Цикл не нужно вырезать механически. Сначала определите, является ли обратная связь командой, событием, общим типом или ошибочно выбранным владельцем. Если связь предметно необходима, оставьте её как явно названное исключение, примите стоимость и проверьте отдельно. Пакет common без владельца не уменьшает связанность, а прячет её.

\n

Критерий готовности

\n

Участок оформлен, когда для каждого межмодульного вызова команда показывает сценарий, владельца, точную поверхность и направление. Автоматическая проверка проходит по фактическому набору исходников или байткода: разрешённый вызов проходит, internal-импорт ломается, неизвестная стрелка ломается, а цикл получает отдельное сообщение. Учебная карта может проверить только форму договора и должна так себя называть.

\n

Если один ответ отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца, перенесите операцию к нему или оставьте код локальным. Польза модульного монолита — не в красивом дереве папок, а в меньшей области изменения, которую можно подтвердить следующим запуском теста.

\n

Проверяемые источники

\n" }