DarkRiDDeR12 мин

Модульный монолит: сначала разрешённые зависимости, потом новые папки

АрхитектураПрактики разработки

Проблема редко начинается с большой архитектуры. В монолите появляется папка `checkout`, рядом — `catalog`, затем кто-то импортирует внутренний helper из `catalog/internal`, потому что так быстрее. Через месяц другой модуль повторяет этот путь, а изменение каталога требует читать чужие вызовы. На схеме всё ещё четыре домена. В коде уже нет границы: папка даёт имя, но не определяет, что разрешено использовать. Цена ошибки — локальная правка получает скрытых потребителей, план работ становится неточным, а «разделение на модули» превращается в спор о расположении файлов.

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

Модуль отвечает на два вопроса

Первый вопрос: что другой модуль вправе вызвать или получить? Это публичный API: команда, событие, тип данных или ограниченный адаптер. Второй: от кого этот модуль вправе зависеть? Если второй ответ отсутствует, public API постепенно становится транзитной дверью во все соседние области. Если отсутствует первый, код либо копируют, либо тянут внутреннюю реализацию. Модуль — не папка и не количество файлов. Это контракт исходящих и входящих связей, который можно объяснить одной таблицей и затем проверить.

Для учебной карты выбраны четыре нейтральных имени: `catalog`, `checkout`, `payments`, `notifications`. У каждого есть ровно один видимый API и отдельная internal-область. `checkout` может обращаться к API `catalog` и `payments`; `payments` — к API `notifications`. Обратные и боковые направления не разрешены. Такое дерево не описывает реальный продукт и не предлагает универсальную декомпозицию. Оно намеренно маленькое, чтобы показать проверяемую форму правила: сначала откуда, затем куда, затем к какой поверхности.

Рабочая карта зависимости для учебной модели
ОткудаКуда и к какой поверхностиСтатусПричина правилаЧто не следует заключать
checkoutcatalog.apiразрешеноcheckout запрашивает опубликованную возможность каталогачто checkout знает внутреннее хранение каталога
checkoutpayments.apiразрешенооплата остаётся отдельной ответственностью с узким входомчто payment workflow уже существует в коде
paymentsnotifications.apiразрешеноуведомление вызывается через явную поверхностьчто доставка сообщения гарантирована
любой модульчужой *.internalзапрещенопотребитель не должен получать право на детали реализациичто все внутренние типы обязаны быть физически недоступны в любом языке
catalogpayments.apiзапрещено в этой картене добавлять связь без названного сценария и владельцачто такая связь невозможна в другой обоснованной карте
Матрица зависимостей учебного модульного монолита: строки — источник, столбцы — получатель. Зелёными стрелками отмечены checkout к catalog и payments, payments к notifications; остальные клетки заблокированы, internal-поверхности вынесены в отдельную легенду.
Матрица фиксирует направление, а не файловую структуру. Она не получена сканированием проекта и не утверждает, что такие зависимости существуют в production.

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

Симптом: изменение внутреннего parser-а каталога требует искать обращения в checkout. Причина: зависимость была записана как «checkout зависит от catalog», без указания доступной поверхности; consumer получил не контракт, а деталь. Проверка: для каждого межмодульного обращения выписать `from`, `to`, `surface` и спросить, относится ли surface к опубликованному API. Действие: оставить один вход, а internal тип либо скрыть, либо вынести требуемую операцию в API с названием и владельцем. Это не означает, что API должен повторить каждую внутреннюю функцию. Он обязан выражать нужный чужому модулю смысл, а не сохранить удобный импорт.

Следующий симптом: два модуля начинают ссылаться друг на друга. Причина бывает разной — общая операция, неверный владелец процесса, удобный shared-helper. Но проверка одна: нарисовать стрелки источника к получателю, а не читать названия папок. Цикл означает, что порядок изменения и запуска уже нельзя объяснить одной направленной картой. Действие не обязательно «вынести сервис». Сначала можно уточнить владельца операции, выделить один узкий API или сделать явное сообщение. Важно не маскировать цикл общим пакетом `common`: он легко превращается в новую неописанную центральную зависимость.

Договор должен быть уже инструмента

Инструмент способен вычислить, что одно имя ссылается на другое. Он не способен сам решить, почему это разрешено. Поэтому до архитектурного теста фиксируем правило в четырёх строках: список модулей, их public surfaces, разрешённые направления, исключение и его срок пересмотра. Spring Modulith 1.1 показывает похожее разделение на API, внутренности и allowed dependencies, но это не повод переносить его аннотации в любой проект. Берём форму вопроса, а не фреймворк как доказательство дизайна.

У правила также должен быть владелец. Не человек, которому можно передать все решения, а роль, которая поддерживает карту и собирает обсуждение при новом направлении. Без владельца исключение становится постоянным import-ом с комментарием «временно». С владельцем оно получает время пересмотра, сценарий и проверку: остался ли вызов единственным или он уже вырос в новый контракт. Так граница остаётся технической работой, а не презентационным слоем.

Synthetic fixture проверяет правило, а не репозиторий

Ниже лежит fixed memory-only fixture. Он получает заранее подготовленные JavaScript-объекты: четыре synthetic модуля и небольшой список synthetic ссылок. Для каждой ссылки он проверяет форму, существование модулей, совпадение поверхности с публичным API, разрешённое направление, дубли и цикл. Ветка с `catalog.internal` отклоняется. Ветка `catalog → payments.api` отклоняется как неразрешённое направление. Это полезно, потому что проверяет, что правило не свелось к красивой диаграмме.

import { runModularMonolithFixture } from './upgrade-2024-02.mjs';

const report = runModularMonolithFixture();
if (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture failed');

console.log(report.samples.valid.model.allowedDirections);
console.log(report.samples.internalReference.model.violations[0]);
// non-public-or-unknown-surface:checkout>catalog:catalog.internal

// Внутри модуля только fixed JS-данные. Нет чтения файлов, package graph,
// репозитория, CI, сети, HTTP, trace или production-конфигурации.

node web/scripts/upgrade-2024-02.mjs --verify-fixture

# PASS подтверждает лишь согласованность fixed synthetic политики и отрицательных веток.

PASS у этого fixture не говорит, что в данном репозитории нет forbidden import. Он не читает дерево файлов, AST, package graph, переменные окружения, CI, сеть, HTTP, trace или production-конфигурацию. Он не знает настоящий набор модулей, историю коммитов, права на релиз или влияние на пользователя. Его единственный результат — fixed политика различает разрешённую публичную связь и заранее заданные отрицательные случаи. Подменять этот результат аудитом кода было бы ложным заявлением.

Короткий маршрут внедрения

  1. Выберите один болезненный стык. Не «весь монолит», а изменение, которое регулярно цепляет чужую внутренность или создаёт цикл.
  2. Назовите consumer и owner. Запишите, кто нуждается в возможности и какой модуль отвечает за её смысл, данные и эволюцию.
  3. Опишите surface. Дайте API короткое имя и перечислите, что остаётся internal. Не делайте исходный файл контрактом по умолчанию.
  4. Зафиксируйте направление. Запишите `from → to.surface`; отдельно запишите запрещённую обратную связь, если она ожидаема и опасна.
  5. Проверьте существующий переход. В разрешённой среде проведите реальный анализ кодовой базы выбранным инструментом, но не приписывайте его результат этой учебной модели.
  6. Добавьте обратимое изменение. Сначала перенесите один вызов на API, оставьте план возврата и критерий, что старую внутренность действительно больше не потребляют.

Ограничения и следующий шаг

Карта не решает вопросы транзакций, данных, авторизации, времени доставки, очередей и распределённых границ. Она не доказывает, что один public метод хорошего размера или что зависимость безопасна по latency. Java modules могут дать физическое ограничение на уровне языка, Spring Modulith — проверку для своего стека, ArchUnit — способ выразить правило тестом; ни один источник не выбирает доменные границы вместо команды. Когда нужна новая стрелка, её нельзя добавлять только для зелёного статуса. Надо снова назвать сценарий, API, владельца, альтернативу и цену связи.

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

Историческая граница февраля 2024

К февралю 2024 уже существовали Java SE 17, Spring Modulith 1.1.0 от 24 ноября 2023 года и ArchUnit 1.1.0 от 9 августа 2023 года. Поэтому материал использует доступные к этому моменту понятия публичной поверхности, явно разрешённой связи и структурного теста. Голос M7 не обещает «распилить монолит» и не придумывает production-историю: он оставляет карту, проверку и ограничение полномочий.

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

  • Java Language Specification, Java SE 17, chapter 7: Packages and Modules — Первичный нормативный текст Oracle для Java SE 17: иерархия имён пакетов сама по себе не создаёт привилегированный доступ; именованный модуль явно задаёт exported packages и зависимости. Это правило языка Java, а не готовая модель предметных модулей для любого стека.
  • Spring Modulith 1.1: Fundamentals — Версионная официальная документация Spring Modulith 1.1, выпущенного 24.11.2023: показывает module API, internal packages и allowed dependencies. Это framework-specific пример для Spring Boot, а не доказательство устройства неизвестного репозитория и не требование применять Spring.
  • Spring Modulith 1.1.0 release, 24.11.2023 — Официальный release проекта фиксирует, что версия 1.1.0 существовала до февраля 2024. Он подтверждает историческую доступность версии, но не подтверждает состав или поведение чужого приложения.
  • ArchUnit 1.1.0 release, 09.08.2023 — Официальный release инструмента архитектурных тестов существовал до февраля 2024. Он показывает, что проверка структурных правил могла быть выделена в тест, но не делает конкретный DSL универсальным и не доказывает запуск CI.