Проблема проявляется, когда архитектурное правило звучит убедительно, но не даёт ответа на конкретный 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.internal | consumer использует деталь или API не выражает нужную операцию | сравнить surface с опубликованным списком | заменить вызов API или спроектировать узкую возможность | не обещает мгновенно скрыть тип во всех языках |
| catalog обращается к payments.api | направление добавлено без сценария или ownership ошибочен | проверить allowed matrix и альтернативный владелец процесса | отклонить либо оформить новую стрелку с review | карта не запрещает обоснованную будущую связь |
| checkout и payments ссылаются друг на друга | две ответственности смешаны или общий процесс не имеет владельца | построить направленный граф и найти цикл | выделить один контракт или переприсвоить orchestration | граф не выбирает бизнес-решение автоматически |
| все начинают импортировать common | общий пакет стал обходом boundary review | проверить входящие и исходящие связи common | сузить shared contract либо вернуть код владельцу | общий код не всегда ошибка, но нуждается в отдельной модели |
Почему 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 к решению о границе
- Зафиксируйте конкретный вызов. Назовите consumer, owner и нужный результат; не начинайте с массовой перестройки namespaces.
- Проверьте поверхность. Если consumer просит internal тип, выясните, это недостающий API или чужая ответственность, которую не надо переносить.
- Проверьте направление. Сравните `from → to.surface` с картой. Любая новая стрелка требует сценария, owner и оценки обратного направления.
- Проверьте цикл. Добавьте стрелку на схему до реализации. Если появилась петля, остановитесь на модели и выберите место orchestration.
- Сделайте один обратимый перенос. Введите API или адаптер, переведите одного consumer-а, сохраните план удаления старого доступа и критерий проверки.
- Только затем автоматизируйте. Выберите анализатор, язык и место запуска по возможностям реального проекта; не называйте 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.