DarkRiDDeR12 мин

Граница модуля: граф разрешённых направлений и публичная поверхность

АрхитектураКачество кода

Проблема проявляется, когда архитектурное правило звучит убедительно, но не даёт ответа на конкретный import. «Заказы могут зависеть от каталога» не говорит, может ли `checkout` вызвать любой public класс, тронуть `catalog.internal`, забрать репозиторий или только получить карточку товара. Разные разработчики разумно читают одну фразу по-разному. Цена — граница перестаёт быть предсказуемой: каждый новый вызов выглядит маленьким, но сумма вызовов открывает соседний модуль целиком.

Причина в смешении трёх разных вещей: имя директории, видимость символа и архитектурное разрешение. Символ может быть технически public, потому что он нужен внутри одного модуля или фреймворку; это ещё не приглашение другим модулям. Java SE 17 формализует похожую разницу для named modules: внешний код получает доступ к public типу только из exported package, а читающая сторона должна явно зависеть от модуля. В обычном монолите такого физического заслона может не быть, поэтому архитектурное правило должно назвать поверхность явно и быть проверяемым отдельно.

Зависимость — это тройка, не стрелка между папками

Запишем связь как `consumer → owner.surface`. `consumer` отвечает за то, зачем ему нужна возможность; `owner` отвечает за её смысл и эволюцию; `surface` — единственный разрешённый вход. Для карты ниже `checkout → catalog.api` допустима, а `checkout → catalog.internal` нет, даже если оба пути находятся под одним верхним namespace. Такая запись добавляет важный вопрос: является ли нужная операция действительно частью API или consumer тянет detail, потому что API пока отсутствует? Ответ нельзя вычислить из имени файла, но его можно потребовать в review.

Публичная поверхность не обязана быть одним типом. Это может быть команда, query, событие, порт или простая функция в разрешённом пакете. Но у неё должна быть граница смысла. Хорошее название говорит, что можно получить или попросить: `catalog.api` предлагает каталожную возможность, а не «все helpers». Если API начинает повторять структуру хранилища или передавать внутренние entity, consumer получает ту же связанность через другой вход. Тогда проверка направления пройдёт, но модульность останется декоративной.

Как классифицировать межмодульную ссылку
Наблюдаемый случайПричинаПроверкаДействиеОграничение
checkout обращается к catalog.apiесть названный потребительский сценарийсверить тройку с картой и смысл APIоставить связь и назначить owner контрактадопустимость не доказывает качество payload
checkout обращается к catalog.internalconsumer использует деталь или API не выражает нужную операциюсравнить surface с опубликованным спискомзаменить вызов API или спроектировать узкую возможностьне обещает мгновенно скрыть тип во всех языках
catalog обращается к payments.apiнаправление добавлено без сценария или ownership ошибоченпроверить allowed matrix и альтернативный владелец процессаотклонить либо оформить новую стрелку с reviewкарта не запрещает обоснованную будущую связь
checkout и payments ссылаются друг на другадве ответственности смешаны или общий процесс не имеет владельцапостроить направленный граф и найти циклвыделить один контракт или переприсвоить orchestrationграф не выбирает бизнес-решение автоматически
все начинают импортировать commonобщий пакет стал обходом boundary reviewпроверить входящие и исходящие связи commonсузить shared contract либо вернуть код владельцуобщий код не всегда ошибка, но нуждается в отдельной модели
Схема разрешённых направлений: checkout обращается только к catalog.api и payments.api, payments — к notifications.api. У каждого модуля публичная верхняя зона отделена от internal-зоны; красная пунктирная стрелка к catalog.internal помечена как запрещённая.
На рисунке есть модель разрешений, а не graph, полученный из source code. Пунктир не утверждает, что такой import найден в реальном проекте.

Почему API и internal нужно разделить даже без language enforcement

Когда компилятор не может запретить обращение к internal, появляется соблазн не различать их вовсе. Это как снять замок с двери, потому что у охраны есть список посетителей. Нужны оба слоя: техническая видимость там, где стек её даёт, и явная архитектурная карта там, где она нужна. Spring Modulith 1.1 описывает module root как API и отличает внутренние подпакеты; его документация полезна именно этим разграничением. Но механизм привязан к Spring Boot и Java. В TypeScript, PHP, Go или другом Java-проекте форма проверки будет другой, а вопрос остаётся тем же: какой consumer получил право использовать какой смысл.

Важная оговорка: не следует называть любой public тип API. Public может требоваться сериализатору, тесту или коду внутри модуля. Архитектурный API определяется не модификатором, а обещанием потребителям и правилом поддержки. Это обещание может быть очень узким: один результат, одно событие, один интерфейс. Чем шире surface, тем больше обязательство при изменении. Поэтому для новой связи сначала формулируют сценарий и минимальные данные, а лишь потом выбирают форму вызова.

Граф обязан быть направленным и объяснимым

Список разрешённых направлений образует ориентированный граф. Он нужен не ради математического слова, а ради двух проверок. Первая: consumer не обходит owner через internal. Вторая: граф не получает цикл, в котором две области вынуждены знать детали друг друга. В fixed модели разрешены только три стрелки: `checkout → catalog.api`, `checkout → payments.api`, `payments → notifications.api`. Такая схема намеренно исключает обратное направление. Если добавить `payments → checkout.api`, модель увидит и неразрешённую стрелку, и цикл в переданном memory graph.

Найденный цикл — не приговор и не автоматическое указание создать микросервис. Он обозначает вопрос, который карта не ответила: кто владеет процессом, где заканчивается инвариант, есть ли у обмена направление во времени или нужен общий контракт. Иногда правильным ответом будет orchestration в одном модуле. Иногда — публичное событие. Иногда — перенос небольшой операции. Неправильным ответом будет добавить исключение без срока, потому что через него consumer получает постоянное право на чужую внутренность.

Проверяем только то, что действительно смоделировали

Fixture в этом пакете не парсит imports. Он принимает fixed JS objects с полями `from`, `to`, `surface`, а затем сверяет их с fixed списком модулей и разрешённых маршрутов. Он специально отвергает лишнее поле `path`: путь к исходнику выглядел бы как начало скана, но fixture не умеет и не пытается читать исходники. Так отрицательная ветка проверяет более полезное свойство: модель не выдаёт synthetic запись за найденный файл.

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 политики и отрицательных веток.

В корректной ветке три учебные связи разрешены; в ветках ошибок модель возвращает точную классификацию: неизвестный модуль, non-public surface, forbidden direction, duplicate reference, self-dependency или cycle. Она также возвращает `releaseAuthority=not-granted`, `projectScan=not-performed` и `realCiResult=not-claimed`. Это не декоративные флаги. Они закрывают распространённую ошибку документации: считать PASS на маленьком примере результатом архитектурной проверки реального процесса.

Маршрут: от import к решению о границе

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

Где модель заканчивается

Граф не отражает данные, транзакции, задержку, права, версионирование события или стоимость преобразования. Зелёная стрелка может быть архитектурно разрешена и всё равно создать тяжёлый запрос. Красная стрелка может стать обоснованной после изменения ownership. У модели нет доступа к вашим файлам, dependency manager, build, тестам, CI, trace, observability или production. Нет и «идеального» числа модулей. Это не недостаток короткой карты; это её честная граница.

Следующий шаг — написать рядом с каждой разрешённой стрелкой один вопрос, который она обслуживает, и один способ удалить её при смене решения. Если такого вопроса нет, связь преждевременна. Если невозможно назвать removal path, API слишком рано объявлен стабильным. С этого момента папки снова становятся полезными: они отражают уже принятое правило, а не пытаются заменить его.

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

Февраль 2024 позволяет использовать Java SE 17 как нормативную границу module/package и Spring Modulith 1.1.0 как доступный на тот момент пример explicit dependencies; ArchUnit 1.1.0 уже существовал как инструмент структурных правил. Материал не переносит поздние runtime-опции и не делает вид, что любой монолит собран на Java. Уровень M7 здесь — спокойная проверяемая формулировка зависимости и отказ от ложного охвата.

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

  • 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.