diff --git a/editorial/agent-rewrites/137.json b/editorial/agent-rewrites/137.json index 92352af..fe12f7b 100644 --- a/editorial/agent-rewrites/137.json +++ b/editorial/agent-rewrites/137.json @@ -1,7 +1,7 @@ { "index": 137, "slug": "editorial-2024-03-mechanism-package-boundaries", - "title": "Границы пакетов: как не превратить shared-утилиту в скрытую платформу", - "excerpt": "Рабочая схема для пакета, который начинает знать чужую доменную модель: минимальный public API, запрет внутренних импортов, проверка маршрута зависимости и честные ограничения инструментов.", - "contentHtml": "
Ошибка обычно начинается с проходящего импорта. Formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Другой consumer берёт cache по пути platform-formatting/internal/cache, потому что так короче. Сборка проходит, TypeScript не спорит, autocomplete подсказывает нужный путь. Цена появляется позже: изменение billing enum требует выпуска formatter-а, чистка cache ломает consumer-а, а владелец зависимости неизвестен. Небольшой пакет перестаёт меняться изолированно.
Тезис простой: границу пакета нельзя поручить одному инструменту. Сначала команда описывает public API. Затем runtime и компилятор ограничивают видимые точки входа. После этого статическое правило ловит запрещённые направления. Каждый слой проверяет свою часть договора. exports не заменяет архитектурное решение, TypeScript не определяет смысл доменной зависимости, а lint не видит весь динамический граф.
Пакет может содержать больше, чем обещает. Внутри formatter-а допустимы cache key, fallback locale и адаптер к библиотеке дат. Consumer должен видеть root specifier и небольшой набор имён. Если consumer импортирует внутренний файл, устройство каталогов превращается в публичный контракт. Если utility импортирует доменный enum, она получает чужое правило принятия решений.
\nType-only import не отменяет границу. Такой импорт может исчезнуть из JavaScript, но останется в исходном коде и в декларациях. Formatter всё равно знает язык billing. Поэтому проверка «в bundle нет billing» отвечает не на тот вопрос. Нужно спросить: может ли владелец billing изменить статус, не меняя контракт общей утилиты?
\n| Уровень | Проверяет | Не доказывает | Действие |
|---|---|---|---|
| Public API record | разрешённые specifier, имена, входы, выходы и owner | реальное разрешение модулей | зафиксировать смысл договора |
package.json exports | доступные package entry points | отсутствие абсолютных обходов и доменную политику | сузить surface для поддерживаемого runtime |
| TypeScript resolution | согласованное разрешение imports/exports и формата модулей | право utility знать чужую модель | синхронизировать compiler и runtime |
| ESLint restriction | названные статические import routes | dynamic import и полный граф | закодировать узкий запрет с альтернативой |
Рассмотрим синтетический пакет @synthetic/platform-formatting. Он форматирует деньги и даты. Billing хочет показывать особый текст для просроченного счёта. Плохой путь передаёт в formatter весь invoice или импортирует InvoiceStatus. Тогда форматирование решает бизнес-вопрос. Новый статус становится изменением shared package.
Безопаснее сначала получить display model на стороне billing. Formatter принимает только данные, которые ему нужны для отображения. Пример учебный: он не доказывает работу настоящего приложения и не является рекомендацией менять конкретный репозиторий.
\n// Синтетический пример. Billing владеет интерпретацией статуса.\nconst display = {\n statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n};\n\n// Общая утилита получает только форматируемые значения.\nconst amountLabel = formatMoney({\n amountMinor: display.amountMinor,\n currencyCode: display.currencyCode,\n locale: 'ru-RU',\n});\nУ consumer-а остаётся один публичный маршрут: @synthetic/platform-formatting. В record можно записать formatMoney и formatIsoDate как public names, а internal/* и доменные импорты — как запрещённые направления. Если функция действительно нужна нескольким пакетам, её добавляют в root API с owner, входами, выходами и планом совместимости. Deep import не становится API только потому, что он уже используется.
Поле exports в package.json помогает объявить entry points. Resolver видит перечисленные subpath, а не случайные файлы каталога. Это полезная граница package surface. Но абсолютный путь к файлу может обойти такую инкапсуляцию. Значит, exports не является security boundary и не доказывает отсутствие плохих зависимостей.
TypeScript в режимах node16 и nodenext учитывает модель Node и package maps. Это уменьшает расхождение между проверкой типов и запуском. Но компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility. Смысловой запрет остаётся задачей контракта и политики.
ESLint можно настроить на конкретные маршруты. Запретите consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Сообщение должно предлагать root API или adapter. Правило должно быть узким: общий запрет «не импортировать домены» может заблокировать законный интеграционный слой.
/* Учебная политика ESLint, не готовая конфигурация проекта. */\n'no-restricted-imports': ['error', {\n patterns: [{\n group: ['@synthetic/platform-formatting/internal/*'],\n message: 'Используйте root API пакета.',\n }],\n}]\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Utility импортирует доменный тип | смысл статуса не принадлежит utility, но public API не описан | кто меняет enum и кто меняет форматирование | перенести интерпретацию в domain owner, передать primitive/display data |
Consumer импортирует /internal | файловое устройство приняли за контракт | есть ли стабильная семантика и root export | убрать deep import или оформить отдельный public export |
| Lint rule просит исключение | правило появилось раньше архитектурного решения | названы ли адресат, route и легальная альтернатива | сначала записать boundary record, затем настроить guard |
| Сборка чистая, но coupling растёт | проверяется emitted code, а не исходный import graph | найти type-only, re-export и dynamic edges отдельно | добавить статические проверки и ручной review исключений |
shared.exports и compiler resolution только в поддерживаемой toolchain.Схема не делает пакеты независимыми автоматически. Adapter-ы, generated clients, plugin systems и framework entry points могут законно пересекать слои. Для них нужен явный маршрут и owner. Статический lint не описывает runtime registry и не ловит все вызовы import(). exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript resolution должен совпадать с тем, что реально запускает приложение.
Не выдавайте учебный пример за аудит. В этой статье нет утверждения о production-результатах, размере bundle, CI или состоянии конкретного репозитория. Проверять нужно область, toolchain и импортный граф, а затем отдельно проверять поведение root API.
\nГраница готова, если любой новый import можно классифицировать без чтения всего пакета: он входит в public API, нарушает названное правило или проходит через документированный adapter. Для выбранного пакета должны быть записаны owner и root API; команда должна получить диагностическое сообщение на запрещённый static import; тест public API должен пройти; поиск по исходникам не должен находить неразрешённые deep imports. Это проверяемый критерий, а не обещание абсолютной изоляции.
\nСбой границы редко начинается с красной сборки. Сначала formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Затем другой consumer импортирует cache по пути platform-formatting/internal/cache, потому что так короче. TypeScript не возражает, autocomplete подсказывает путь, тесты проходят. Цена появляется при следующем изменении: новый статус требует выпуска formatter-а, а переименование cache заставляет искать неизвестных потребителей.
У этой ситуации две разные причины. Доменный тип переносит в общую утилиту смысл, которым владеет billing. Deep import превращает расположение файла в обещание для consumer-а. Лечить их одним запретом нельзя. Сначала нужно описать public API, затем поставить подходящие технические проверки и явно оставить места, где связь допустима.
\nПакет может содержать больше, чем он обещает. Внутри formatter-а могут жить cache key, fallback локали и адаптер к библиотеке дат. Consumer должен знать root specifier, публичные имена, формат входа и результата. Если он импортирует внутренний файл, любое переименование реализации становится изменением чужого контракта.
\nГраница отвечает не только на вопрос «откуда импортировать». Она фиксирует владельца смысла. Общая функция может превратить число в строку валюты, но не должна решать, что статус счёта означает «просрочен». Это решение принадлежит billing. Передавать нужно примитивы или готовую модель отображения, а не весь объект домена.
\n| Слой | Что проверяет | Чего не доказывает | Практический вопрос |
|---|---|---|---|
| Boundary record | root, public names, входы, выходы и owner | что runtime действительно разрешает только эти маршруты | Кто принимает изменение surface? |
package.json exports | доступные package entry points и subpaths | доменную корректность и абсолютные обходы | Какой bare specifier разрешён? |
| TypeScript resolution | сопоставление module resolution с runtime или bundler | право utility знать чужую бизнес-модель | Одинаково ли разрешаются типы и запуск? |
| ESLint restriction | названные статические import routes | dynamic import, generated code и полный граф | Какой запрет должен сработать на diff? |
Утечка домена видна по ответу на вопрос «кто меняет этот факт?». Если набор значений InvoiceStatus меняет billing-команда, formatter не должен импортировать enum даже через import type. Type-only import может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Значит, shared-пакет всё равно связан со словарём billing.
Deep import имеет другую цену. Consumer зависит от имени файла, структуры каталогов и поведения private helper-а. Исправление начинается с выяснения потребности: нужна публичная операция или случайно найденная деталь? Если нужна операция, её оформляют именованным export с owner, входами, результатом и правилами совместимости. Если нужна деталь, consumer должен исчезнуть, а cache остаться у владельца.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | технический слой интерпретирует домен | кто меняет enum и label | вернуть mapping в billing, передать display data |
Consumer импортирует /internal/* | каталог приняли за API | есть ли стабильная операция за root export | добавить reviewed export или убрать зависимость |
| Появился type-only import | проверяют bundle вместо исходной зависимости | найти import type, re-export и declaration | оценить смысловую связь, а не только emitted code |
| Lint просит исключение | правило появилось раньше решения о границе | названы ли route, owner и альтернатива | оформить adapter с ограниченным сроком |
Ниже синтетический пример запускается в Node.js без зависимостей. Billing выбирает подпись статуса, а formatter получает только минимальные данные. Команда с Node.js 12 и новее может скопировать команду целиком; --input-type=module явно задаёт режим для кода из standard input.
node --input-type=module <<'EOF'\nconst invoice = { status: 'overdue', amountMinor: 12345, currencyCode: 'RUB' };\n\nconst view = {\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате',\n};\n\nconst formatMoney = ({ amountMinor, currencyCode, locale }) =>\n new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode })\n .format(amountMinor / 100);\n\nconsole.log(formatMoney({ ...view, locale: 'ru-RU' }) + ' — ' + view.statusLabel);\nEOF\nОжидаемый результат — строка вида 123,45 ₽ — Просрочен. В разных версиях ICU и окружениях пробел перед символом валюты может отличаться, поэтому проверяйте смысл результата, а не сравнивайте байты вывода. Важнее другое: появление статуса disputed меняет mapping в billing, но не требует добавлять этот статус в formatter.
Для условного пакета @example/platform-formatting достаточно короткой записи: root — @example/platform-formatting, public names — formatMoney и formatDate, owner — команда форматирования, запрещённый consumer route — @example/platform-formatting/internal/*. Отдельно запишите запретное исходящее направление: utility не импортирует @example/billing-domain/*.
Эта запись нужна до настройки lint. Она позволяет отличить нарушение от законного adapter-а. Adapter должен иметь собственное имя и owner, принимать узкую модель и иметь условие удаления, например отсутствие потребителей старого specifier в поиске по исходникам. Сам факт, что два пакета используют одинаковый helper, не делает helper общей абстракцией.
\n{\n "name": "@example/platform-formatting",\n "exports": {\n ".": "./dist/index.js",\n "./format-date": "./dist/format-date.js"\n }\n}\nЯвный exports объявляет entry points пакета. Если ./internal/cache не перечислен, обычный импорт по имени пакета в поддерживаемом Node.js завершается ERR_PACKAGE_PATH_NOT_EXPORTED. Добавление exports в существующий пакет может стать breaking change, если consumers уже использовали неявные subpaths. Перед включением нужно перечислить прежние поддерживаемые точки входа и выбрать план миграции.
Node.js проверяет package surface при разрешении package specifier. Поле exports умеет ограничить main entry point и named subpaths, но не является сильной изоляцией: прямой абсолютный путь к файлу может обойти эту инкапсуляцию. Поэтому exports — контракт package resolver-а, а не защита от любого доступа к файловой системе.
TypeScript в режимах node16 и nodenext моделирует различия ESM и CommonJS и учитывает package maps при соответствующей конфигурации. Это помогает приблизить type-check к реальному разрешению модулей. Компилятор всё равно не знает, кому принадлежит бизнес-смысл InvoiceStatus. Смысловую границу задаёт архитектурный договор.
ESLint rule no-restricted-imports подходит для статических маршрутов. Для deep imports можно задать pattern и понятное сообщение:
{\n "rules": {\n "no-restricted-imports": ["error", {\n "patterns": [{\n "group": ["@example/platform-formatting/internal/*"],\n "message": "Используйте root API пакета."\n }]\n }]\n }\n}\nЭто правило действует на static import и не обещает проверить dynamic import(). Generated files, path aliases, re-export и loader registry нужно покрыть отдельными проверками. Не расширяйте pattern до всех shared-пакетов: такой запрет может блокировать законный интеграционный слой и заставить команду добавлять бессодержательные исключения.
import type, dynamic import() и generated code в своей области.exports и режим разрешения TypeScript в соответствии с реально запускаемым runtime или bundler-ом.Эта схема не делает пакеты независимыми автоматически. Generated clients, plugin systems, framework entry points и интеграционные adapters могут пересекать обычные слои. Для каждого исключения нужны назначенный owner, документированный маршрут и проверка, которая действительно охватывает этот способ загрузки.
\nПоведение exports зависит от версии Node.js, package manager и bundler-а. TypeScript должен использовать режим, совместимый с запуском; иначе type-check и runtime могут разрешить разные пути. ESLint не строит полный граф зависимостей и не ловит dynamic import. Абсолютный путь может обойти package encapsulation. Поэтому корректный вывод звучит узко: «названный static route запрещён в заданном scope», а не «весь монорепозиторий не содержит утечек».
Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, юридических формулировок, локализации и финансовой точности продукта. Реальный billing-контракт должен иметь собственные типы и тесты. При вводе exports в существующий пакет отдельно проверьте обратную совместимость прежних entry points.
Граница готова, когда у каждого спорного import-а есть четыре ответа: кто владеет смыслом, какой route разрешён, чем запрещён обход и как проверяется поведение. Consumer импортирует root API, type-check и static guard проходят в поддерживаемой конфигурации, а временный adapter имеет условие удаления. Это проверяемый уровень контроля, а не обещание абсолютной изоляции.
\nПроблема часто начинается с безобидного изменения. Formatter денег уже используется в billing и orders. В него добавляют условие для InvoiceStatus, чтобы рядом с суммой вывести «Просрочен». Другой consumer импортирует внутренний cache по пути platform-formatting/internal/cache. Сборка проходит. Симптом появляется позже: изменение enum требует правки общей утилиты, очистка cache ломает consumer, а reviewer не может отличить обещанный API от случайного файла.
Цена ошибки — не одна лишняя зависимость. Доменная модель проникает в пакет, который считали нейтральным. Владелец billing начинает влиять на форматтер, владелец форматтера — на orders. Любой рефакторинг проходит через большее число команд. Ошибку труднее локализовать. Откат затрагивает код, который изначально не должен был знать друг о друге.
Тезис: граница пакета начинается с короткого договора, а не с папки и не с конфигурации линтера. Договор называет root specifier, публичные имена, входы, выходы и запрещённые направления. Инструменты затем проверяют отдельные части договора. Они не принимают архитектурное решение вместо владельца пакета.
Пакет содержит больше кода, чем обещает. Внутри могут лежать cache, fallback для locale, адаптеры и тестовые helpers. Consumer должен видеть только root entry point и имена, которые команда готова поддерживать. Любой другой импорт превращает текущую структуру файлов в неявный контракт.
Доменная граница проходит по смыслу данных. amountMinor, currencyCode и locale описывают вход для форматирования. InvoiceStatus, лимит возврата и правило просрочки описывают billing. Если formatter принимает Invoice, он получает право интерпретировать чужую модель. Если formatter импортирует InvoiceStatus даже только как тип, зависимость остаётся: исходный код и декларации начинают отражать billing-словарь.
Правильный consumer сначала принимает решение у себя, затем передаёт утилите нейтральные данные. Billing может превратить статус в свою подпись, а formatter — отформатировать сумму. Так изменение статуса остаётся у владельца billing. Общий пакет меняет только правила представления чисел, валюты и даты.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | Доменная интерпретация вошла в общий пакет | Посмотреть imported name и владельца типа | Вернуть выбор статуса в billing и передать primitive values |
Consumer импортирует /internal/* | Public API не назван или оказался слишком мал | Сверить specifier с API record | Убрать deep import либо открыть отдельный reviewed export |
| Новый export добавляют «на всякий случай» | Поверхность пакета растёт без владельца | Проверить consumer, семантику и срок поддержки | Оставить только нужное имя и зафиксировать owner |
| Lint rule разрешает всё через исключение | Инструмент скрывает неясное архитектурное решение | Назвать точный allowed route и причину исключения | Сузить правило или создать именованный adapter |
| Надеются на один механизм | exports, TypeScript и lint смешали в одно обещание | Проверить область действия каждого слоя | Разделить package surface, module resolution и policy |
Ниже приведён учебный пример. Имена billing и platform-formatting вымышлены. Код показывает форму границы, а не состояние конкретного проекта.
// Учебный пример: billing владеет смыслом статуса.\nconst displayData = {\n statusLabel: invoice.status === \"overdue\" ? \"Просрочен\" : \"Открыт\",\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n};\n\n// Общая функция получает только данные для форматирования.\nconst amountLabel = formatMoney({\n amountMinor: displayData.amountMinor,\n currencyCode: displayData.currencyCode,\n locale: \"ru-RU\",\n});Плохой вариант смешивает оба решения: formatter сам импортирует InvoiceStatus, выбирает подпись и форматирует деньги. Такой код может быть короче, но граница становится неясной. Хороший вариант не запрещает переиспользование. Он оставляет каждому пакету один вид ответственности.
Node.js package.json с полем exports описывает доступные entry points при обычном импорте пакета. Это полезно для surface и совместимости. Неэкспортированный subpath перестаёт быть обычной частью package API. Но exports не является защитой от любого прямого обращения к файлу. Он также не знает, что InvoiceStatus относится к billing и потому не должен попадать в formatter.
TypeScript в режимах node16 и nodenext учитывает exports, imports, self-reference и различия ESM/CJS. Compiler помогает согласовать типы с module resolution. Он не решает вопрос владения бизнес-смыслом. Корректный type-check не делает доменную зависимость хорошей.
ESLint no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Это узкая проверка синтаксиса. Она не строит полный граф dynamic import, generated code и runtime plugin loading. В policy нужно обещать только то, что выбранное правило действительно видит.
Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.
Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.
Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый root import, utility с доменным импортом и consumer с deep import. Такой тест полезен, если он явно называет свои границы. Он проверяет классификацию записанных примеров. Он не читает репозиторий, не строит настоящий import graph и не доказывает состояние CI.
const boundary = {\n publicSpecifier: \"@synthetic/platform-formatting\",\n publicNames: [\"formatMoney\", \"formatIsoDate\"],\n forbiddenConsumerRoutes: [\"@synthetic/platform-formatting/internal/*\"],\n forbiddenUtilityTargets: [\"@synthetic/billing-domain/*\"],\n};\n\n// Проверяемый учебный результат:\n// clean root import - compliant\n// utility -> billing - violated\n// consumer -> internal - violatedЕсли такой пример называют проверкой проекта, он вводит в заблуждение. Для реального edge нужны согласованная область чтения, выбранный resolver, учёт aliases и generated layers, затем отдельная проверка toolchain. Учебная модель не заменяет эти шаги.
Эта схема не делает пакеты независимыми автоматически. Она не измеряет размер bundle, не доказывает отсутствие циклов, не проверяет семантическую совместимость всех версий и не описывает dynamic loading. В legacy-коде может потребоваться временный adapter. У adapter-а должны быть владелец, разрешённый маршрут и дата удаления исключения.
Глобальный запрет тоже опасен. Framework entry point, generated client и plugin adapter могут законно пересекать слои. Важно назвать роль такого пакета. Запрещайте не слово domain, а конкретное направление для конкретного owner-а. Иначе команда начнёт отключать правило вместо исправления зависимости.
Работа готова, когда для одного выбранного package можно ответить на пять вопросов без поиска по всему репозиторию: кто owner; какой root specifier обещан; какие имена и входы публичны; какие направления запрещены; чем проверяется каждый запрет. Положительный тест импортирует только root API. Отрицательные тесты показывают отказ для domain leak и deep import. Проверка явно указывает, какие пути она не покрывает.
После этого изменение InvoiceStatus не требует знания внутренностей formatter-а, а изменение cache не заставляет искать случайных consumers. Граница не запрещает развитие пакета. Она делает цену нового знания видимой до того, как оно станет общей платформой.
Проблема проявляется не в момент создания пакета. Сначала formatter денег используют в billing и orders через один импорт. Потом в formatter добавляют условие для InvoiceStatus, а другой consumer берёт cache напрямую из @example/platform-formatting/src/internal/cache.js. Сборка ещё проходит, но любой рефакторинг внутреннего файла превращается в поиск неизвестных владельцев.
Цена ошибки состоит из двух разных зависимостей. Доменная модель попадает в общий пакет и заставляет его знать правила billing. Внутренний файл становится неявным API, хотя команда не обещала его сохранять. В результате изменение enum, очистка cache или переезд src затрагивают больше consumers, чем видно по публичному описанию пакета.
Решение начинается с короткого договора: какой module specifier разрешён, какие имена экспортируются, какие значения принимает функция и какие направления запрещены. Затем каждый инструмент проверяет свою часть договора. exports закрывает package subpaths при обычном разрешении Node.js, TypeScript согласует типы с resolver-ом, а ESLint ловит известные статические маршруты. Ни один из этих механизмов сам по себе не определяет, кому принадлежит бизнес-смысл.
Начните с конкретного edge — направленной зависимости между двумя пакетами. Зафиксируйте импортирующий файл, module specifier, imported name и владельца типа. Если platform-formatting импортирует InvoiceStatus, это не «технический тип без последствий». Даже type-only зависимость связывает словарь общего пакета с billing и меняет его публичный контракт.
Решение о том, что счёт просрочен, принадлежит billing. Formatter должен получить уже выбранные данные: сумму, код валюты и locale. Он не должен импортировать Invoice, выбирать подпись статуса или знать, что один из consumers показывает возвраты. Такая граница переносит изменение туда, где живёт правило, и оставляет общей утилите одну причину для изменения — представление значения.
| Наблюдение | Что это означает | Проверка | Исправление |
|---|---|---|---|
Утилита импортирует InvoiceStatus | Общий слой интерпретирует доменную модель | Найти imported name и владельца типа | Выбрать статус в billing и передать нейтральные значения |
Consumer импортирует /src/ или /internal/ | Структура файлов стала неявным API | Сопоставить specifier с package contract | Перейти на root import или отдельно согласовать новый export |
В exports добавляют весь каталог | Проверка заменена широким исключением | Посчитать реальные consumers и публичные имена | Описать точные subpaths либо оставить только . |
| ESLint-правило молчит | Маршрут не попал в область его анализа или импорт динамический | Проверить resolver, pattern и форму импорта | Сузить утверждение либо добавить отдельную проверку графа |
После добавления exports ломается старый consumer | Ранее поддерживался неописанный entry point | Сверить историю импортов и release notes пакета | Сначала экспортировать совместимый путь, затем объявить миграцию |
Для небольшого shared-пакета достаточно одной записи, которую можно проверить в review. В ней должны быть root specifier, публичные имена, формы входа и выхода, владелец, разрешённые зависимости и запретные маршруты. Например: @example/platform-formatting экспортирует formatMoney; функция принимает amount, currencyCode и locale; пакет не импортирует @example/billing-domain/*; consumers не импортируют его src/*.
Не путайте публичное имя с файлом, в котором оно сейчас лежит. Внутри можно поменять format-money.js на несколько модулей, если root export и поведение функции остаются совместимыми. И наоборот: экспорт всего каталога делает каждое имя частью ожиданий consumers, даже если оно появилось как временный helper.
В Node.js поле exports перечисляет entry points, доступные при обычном импорте пакета. Если оставить только точку ., попытка импортировать @example/platform-formatting/src/internal/cache.js должна завершиться ошибкой ERR_PACKAGE_PATH_NOT_EXPORTED. Это полезная машинная граница: переезд cache внутри пакета не обязан сохранять старый путь.
{
"name": "@example/platform-formatting",
"type": "module",
"exports": {
".": "./src/index.js"
}
}Файл src/index.js публикует только согласованные функции:
export { formatMoney } from "./format-money.js";После установки локального пакета положительный и отрицательный smoke-check можно повторить командами:
node --input-type=module -e 'import("@example/platform-formatting").then(({ formatMoney }) => console.log(formatMoney({ amount: 1234.5, currencyCode: "RUB", locale: "ru-RU" })))'
node --input-type=module -e 'import("@example/platform-formatting/src/internal/cache.js").then(() => process.exit(1), error => { if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") process.exit(1); console.log(error.code); })'Первый вызов проверяет, что root API разрешается и возвращает функцию. Второй считает успехом именно ожидаемый отказ. Точный пробел или символ валюты в первой строке зависит от реализации Intl.NumberFormat и окружения; проверять следует контракт результата, а не копировать визуальную строку без оговорки.
У exports есть важные ограничения. Оно действует для package resolution, но не является защитой от прямого абсолютного доступа к файлу на диске. Оно также не исправляет consumer, который уже использует другой resolver или alias сборщика. Добавление поля в существующий пакет может быть breaking change: ранее неописанные entry points перестанут разрешаться. Перед включением нужно найти такие импорты и решить, какие из них действительно поддерживаются.
TypeScript 4.7 добавил режимы node16 и nodenext. В этих режимах compiler учитывает модульную модель Node.js и поля exports/imports при разрешении пакетов. Это помогает получить одинаковую границу для исходников и деклараций, но не заменяет проверку runtime. Другой режим, path alias или отдельная конфигурация bundler-а могут дать отличающийся результат.
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"strict": true
}
}ESLint подходит для статических запретов, которые можно сформулировать как маршруты. В legacy-конфигурации правило можно применить к двум сторонам границы:
{
"overrides": [
{
"files": ["packages/platform-formatting/src/**/*.js"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["@example/billing-domain/*"],
"message": "formatter не импортирует billing domain"
}]
}]
}
},
{
"files": ["packages/orders/**/*.js"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["@example/platform-formatting/src/*"],
"message": "используйте root API formatter"
}]
}]
}
}
]
}Здесь paths нужен для точного имени, а patterns — для группы путей с wildcard. Правило не строит полный runtime-граф: dynamic import(), загрузчик плагинов, generated code и обход через абсолютный путь требуют отдельной проверки. Type-only import тоже остаётся архитектурной связью. Разрешайте его исключением только тогда, когда это часть договора, а не способ спрятать доменную зависимость.
Сразу закрыть все старые пути в большом репозитории рискованно. Сначала составьте список imports по тексту и по инструменту, которым действительно собирается проект. Отдельно отметьте production-код, тесты, storybook, скрипты и generated files. У каждого найденного пути должен появиться статус: поддерживаемый root API, кандидат на отдельный export, временный adapter или ошибка.
Если в пакете уже есть consumers, не удаляйте их маршрут только потому, что он выглядит некрасиво. Для обратной совместимости можно временно экспортировать точно известный subpath, предупредить consumers и удалить его в следующем совместимом процессе. В новом пакете лучше начать с минимального списка. Публикация целого src редко является нейтральным компромиссом: она закрепляет внутреннюю структуру на будущее.
Доменную зависимость мигрируйте отдельным изменением. Сначала добавьте в consumer функцию, которая преобразует InvoiceStatus в собственную подпись. Затем передайте в formatter только нейтральные данные. После проверки consumers удалите импорт домена из utility. Такой порядок позволяет отличить изменение ответственности от изменения формата и легче откатить неудачный шаг.
exports и положительный тест root import.Полезно сохранить два отрицательных теста рядом с контрактом: utility не может импортировать billing domain, consumer не может импортировать internal subpath. Положительный тест тоже обязателен. Запрет без рабочего root API только перенаправляет команду к новому обходному пути.
Эта схема не доказывает отсутствие циклов, не проверяет семантическую совместимость версий и не измеряет размер bundle. Она не видит автоматически каждый alias, runtime plugin loader или абсолютный путь. Если сборщик не повторяет правила Node.js, результат smoke-check нужно получить именно через его resolver. Если пакет поддерживает CommonJS и ESM, проверяйте обе точки входа и не смешивайте их contract без явного решения.
Глобальный запрет на слово domain тоже не является архитектурой. Иногда отдельный adapter действительно должен пересекать слои. Зафиксируйте его владельца, разрешённый маршрут, причину и условие удаления. Временное исключение безопаснее, когда оно названо и наблюдаемо; молчаливый deep import просто переносит стоимость на следующий рефакторинг.
Граница готова, когда команда может ответить на пять вопросов без чтения внутреннего каталога: кто владелец пакета; какой root specifier обещан; какие имена и данные публичны; какие направления запрещены; каким инструментом проверяется каждый запрет. В репозитории есть рабочий root import, отрицательный тест для deep import и список известных исключений. Тогда изменение InvoiceStatus остаётся в billing, а изменение cache не требует обзванивать случайных consumers.
exports, entry points, subpath-ограничениях и пределе защиты от абсолютного пути.node16/nodenext и поддержки exports/imports.paths, групповых patterns и ограничений статической проверки.В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает приватный formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется. Тест на один сценарий проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду. Цена ошибки — не эстетика архитектуры. Она выражается в связанных релизах, длинном ревью и скрытом риске сломать соседний сценарий.
\nМодульный монолит не запрещает модулям общаться. Он делает эту связь проверяемой. Для каждой стрелки нужно назвать consumer, owner, публичную поверхность и направление. Если хотя бы одно поле неизвестно, импорт ещё не является понятным контрактом. Такой разбор не доказывает корректность всего приложения. Он отвечает на более узкий вопрос: кто имеет право вызвать кого и что произойдёт с границей после следующего изменения.
\nНиже приведён учебный пример с фиксированными именами catalog, checkout, payments и notifications. Он не описывает реальный репозиторий, метрики или результат CI. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.
Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.
Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.
Граница модуля состоит из трёх договорённостей. Владелец отвечает за данные и инварианты. Поверхность API перечисляет возможности, которые владелец готов поддерживать. Направление зависимости ограничивает сценарии, в которых другой модуль может этой возможностью пользоваться. Папка помогает увидеть структуру, но сама по себе границу не создаёт.
\nПроверка начинается не с названия класса, а с конкретной ссылки. Запишите её как source → target.surface. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.
record BoundaryLink(\n String source,\n String target,\n String surface,\n String scenario\n) {}\n\nBoundaryLink link = new BoundaryLink(\n \"checkout\", \"catalog\", \"api\", \"show product card\"\n);\n\n// Проверяем отдельно:\n// 1. surface опубликована владельцем;\n// 2. source -> target разрешено картой;\n// 3. новая ссылка не замыкает цикл;\n// 4. scenario не переносит чужой инвариант.\n\nКод выше — учебная запись, а не готовая библиотека. В реальном Java-проекте вместо строки surface понадобятся пакеты, named interface или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.
Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.
Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.
Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый вызов проходит компиляцию, но его цель неясна | Нет записи о сценарии и владельце | Выписать source, target.surface и инвариант | Оставить ссылку только после явного контракта |
Consumer импортирует internal | Публичная поверхность не покрывает потребность | Сравнить импорт с опубликованными пакетами или интерфейсами | Вернуть операцию владельцу или добавить узкий API |
| Два модуля вызывают друг друга | Новая обратная связь добавлена без владельца процесса | Построить граф и найти цикл | Выбрать orchestration или event contract |
| Ссылка ведёт в неизвестный модуль | Карта зависимостей устарела или неполна | Сверить имя с исходниками и конфигурацией модулей | Остановить изменение до обновления карты |
| После переноса тесты зелёные, но граница снова открыта | Проверка была только примером, без правила | Запустить структурную проверку и отрицательный тест | Закрепить запрет на уровне сборки или тестового набора |
Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.
Учебная фикстура полезна для проверки формы рассуждения: фиксированный набор модулей, точный список связей, ожидаемые решения и отрицательные входы. Она не читает файлы проекта, не строит граф по настоящим импортам, не обращается к сети и не сообщает о состоянии production. В реальной проверке эти источники должны быть названы отдельно. Отсутствие скана означает только отсутствие скана, а не отсутствие нарушения.
\nГраф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.
\nНе всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.
\nИнструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.
\nИзменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.
Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.
\nВ pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает внутренний formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется, а тест одного сценария проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду.
\nПроблема не в красоте дерева каталогов. Скрытая связь расширяет область изменения: внутренний тип становится контрактом, цикл усложняет порядок вызовов, а общий релиз приходится проверять целиком. Модульный монолит помогает только тогда, когда границы можно назвать и проверить: кто владеет смыслом операции, какая поверхность опубликована и в каком направлении разрешено обращение.
\nНиже — воспроизводимая модель с модулями catalog, checkout, payments и notifications. Имена и связи вымышлены, поэтому это не отчёт о конкретном production-проекте. Их можно заменить своими пакетами и прогнать те же проверки на настоящем репозитории.
В ревью легко перепутать наблюдение с объяснением. Факт — checkout действительно импортирует класс из catalog.internal. Гипотеза — этот импорт появился потому, что публичный API не выражает нужный сценарий. Решение — перенести операцию к каталогу или добавить узкий публичный контракт. Каждый слой требует своей проверки.
| Поле | Пример | Как подтвердить |
|---|---|---|
| Consumer | checkout | Файл вызывающего кода и его пакет |
| Owner | catalog | Владелец инварианта и данных операции |
| Surface | catalog.api.ProductView | Публичный пакет, интерфейс или команда |
| Scenario | Показать карточку перед оформлением | Тест или описание пользовательского пути |
| Direction | checkout → catalog | Карта разрешённых зависимостей и поиск обратного пути |
Запись «checkout зависит от catalog» слишком широкая. Она одинаково скрывает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api уже даёт объект для ревью. Если нельзя назвать сценарий или owner, новый импорт лучше остановить до выяснения ответственности.
Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.
Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.
Модуль владеет смыслом операции, данными и правилами их изменения. Публичная поверхность — это обещание другому модулю, а не все классы с модификатором public. Она может быть командой, запросом, событием, портом или небольшим интерфейсом. Внутренний formatter, ORM-репозиторий и таблица не становятся API только из-за удобства импорта.
В учебной карте разрешены три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Обращение checkout → catalog.internal запрещено. Обращение payments → checkout.api также требует решения: если checkout уже вызывает payments, оно замыкает цикл и оставляет неясным владельца orchestration — последовательности действий.
Если проект использует Java Platform Module System, часть границы можно закрепить физически. Например, каталог экспортирует только API-пакет:
\nmodule com.acme.catalog {\n exports com.acme.catalog.api;\n}\n\nmodule com.acme.checkout {\n requires com.acme.catalog;\n}\n\nТакой module-info.java ограничивает доступ к неэкспортированным пакетам на уровне JPMS. Но многие Java-приложения работают на classpath или используют собственное разбиение пакетов. В них понадобится архитектурный тест и правило сборки; один namespace не превращает папку в изолированный модуль.
Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.
Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.
Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый вызов проходит компиляцию, но его цель неясна | Нет записи о сценарии и владельце | Выписать source, target.surface и инвариант | Оставить ссылку только после явного контракта |
Consumer импортирует internal | Публичная поверхность не покрывает потребность | Сравнить импорт с опубликованными пакетами или интерфейсами | Вернуть операцию владельцу или добавить узкий API |
| Два модуля вызывают друг друга | Новая обратная связь добавлена без владельца процесса | Построить граф и найти цикл | Выбрать orchestration или event contract |
| Ссылка ведёт в неизвестный модуль | Карта зависимостей устарела или неполна | Сверить имя с исходниками и конфигурацией модулей | Остановить изменение до обновления карты |
| После переноса тесты зелёные, но граница снова открыта | Проверка была только примером, без правила | Запустить структурную проверку и отрицательный тест | Закрепить запрет на уровне сборки или тестового набора |
Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.
Учебная фикстура полезна для проверки формы рассуждения: фиксированный набор модулей, точный список связей, ожидаемые решения и отрицательные входы. Она не читает файлы проекта, не строит граф по настоящим импортам, не обращается к сети и не сообщает о состоянии production. В реальной проверке эти источники должны быть названы отдельно. Отсутствие скана означает только отсутствие скана, а не отсутствие нарушения.
\nСначала найдите реальные ссылки, не полагаясь на карту из памяти. Команда ниже подходит для Java-проекта, где исходники лежат в src/main/java; путь и имена пакетов нужно заменить на проектные:
rg -n --glob '*.java' \\\n '^\\s*import\\s+.*(catalog|checkout|payments)' \\\n src/main/java\n\nПоиск даёт список кандидатов, но не доказывает архитектурное нарушение: он не понимает, какой пакет экспортирован, и может найти комментарий или строку. Для автоматического запрета удобнее импортировать байткод в ArchUnit. Минимальный тест ниже запрещает checkout ссылаться на внутренность каталога и требует ацикличности срезов:
\n@AnalyzeClasses(packages = \"com.acme\")\nclass ModuleBoundaryTest {\n\n @ArchTest\n static final ArchRule checkout_uses_catalog_api =\n noClasses().that().resideInAnyPackage(\"..checkout..\")\n .should().dependOnClassesThat()\n .resideInAnyPackage(\"..catalog.internal..\");\n\n @ArchTest\n static final ArchRule modules_have_no_cycles =\n slices().matching(\"com.acme.(*)..\")\n .should().beFreeOfCycles();\n}\n\nЕсли тест находится в Gradle-проекте с именем класса ModuleBoundaryTest, его можно запустить так:
./gradlew test --tests \\\n 'com.acme.architecture.ModuleBoundaryTest'\n\nЭтот тест проверяет зависимости классов, попавших в импорт ArchUnit. Он не видит автоматически SQL-связи, вызовы через reflection, конфигурацию контейнера, внешние очереди и бизнес-правильность транзакции. Поэтому результат нужно читать точно: «две заданные архитектурные проверки не нашли нарушение в импортированном наборе классов», а не «модуль полностью изолирован».
\nГраф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.
\nНе всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.
\nИнструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.
\nИзменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.
Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.
\nrequires и exports, а также доступа к экспортированным пакетам.В монолите проблема часто начинается с маленького импорта. Код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Потом платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит. Папки по-прежнему выглядят как отдельные домены.
\nСимптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой только терпят ради срока. Цена ошибки — скрытый контракт. Он увеличивает область каждого изменения, усложняет откат и делает будущий перенос модуля дороже.
\nТезис простой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой связи нужно назвать потребителя, владельца и поверхность доступа: consumer → owner.publicApi. Отдельно нужно перечислить разрешённые направления. Тогда правило можно обсуждать по конкретному вызову, а не по впечатлению от дерева файлов.
Модуль владеет смыслом операции, своими данными и публичным входом. Публичный вход не равен каждому символу с модификатором public. Это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность, а не раскрывать внутреннее хранение.
В учебной модели есть четыре модуля: catalog, checkout, payments и notifications. У каждого есть поверхность *.api и внутренняя часть *.internal. Разрешены только три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это пример формы правила, а не описание реальной системы.
| Вопрос | Пример ответа | Зачем он нужен |
|---|---|---|
| Кто вызывает? | checkout | Фиксирует потребителя и его сценарий |
| Кто владеет смыслом? | catalog | Назначает ответственность за изменение контракта |
| Через что вызывают? | catalog.api | Не даёт подменить API внутренним типом |
| Разрешено ли направление? | checkout → catalog | Останавливает случайные обратные связи |
Одна стрелка без поверхности слишком широка. Запись «checkout зависит от catalog» допускает и запрос карточки, и чтение репозитория, и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавлять ли узкий метод, событие или оставить операцию внутри своего модуля.
Сначала команда описывает карту модулей. Для каждого модуля она записывает имя, публичную поверхность, внутренние пакеты и владельца. Затем добавляет разрешённые рёбра. Проверка каждой ссылки отвечает на четыре вопроса: существует ли источник, существует ли получатель, совпадает ли поверхность с опубликованной и есть ли такое направление в карте.
\nНаправление нужно хранить отдельно от физического пути. В одном языке internal-пакет можно закрыть средствами компилятора, в другом останется только соглашение и архитектурный тест. Оба слоя полезны. Видимость защищает от части ошибочных обращений, а карта объясняет, почему разрешён сам маршрут.
\nconst allowed = new Set(['checkout>catalog:catalog.api', 'checkout>payments:payments.api', 'payments>notifications:notifications.api']); function check(link) { if (link.surface !== link.to + '.api') return 'non-public surface'; return allowed.has(link.from + '>' + link.to + ':' + link.surface) ? 'allowed' : 'forbidden direction'; }\nКод выше — учебный пример проверки заранее описанной карты. Он не читает репозиторий, не строит граф импортов и не доказывает отсутствие нарушений в приложении. В настоящем проекте анализатор должен получить фактические ссылки из подходящего инструмента языка, сопоставить их с картой и сохранить результат проверки. Если такого анализа пока нет, честный результат — «карта описана, фактические импорты не проверены».
\nЦикл проверяют на том же графе. Если карта разрешает checkout → payments, а затем добавляет payments → checkout, две области начинают знать друг о друге. Цикл не означает, что нужно немедленно выделить микросервис. Он означает, что не назван владелец процесса. Сначала уточняют orchestration, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Потребитель импортирует catalog.internal | API не выражает нужную операцию или деталь показалась удобнее | Сверить surface вызова со списком API и назвать сценарий | Вернуть операцию владельцу либо добавить узкий контракт |
| Появилась обратная стрелка | Не определён владелец процесса или смешаны ответственности | Построить граф и найти цикл | Выбрать orchestration, событие или перенос операции |
Все импортируют common | Общий пакет стал обходом границы | Проверить, кто владеет каждым типом и кто меняет его | Разделить контракты или вернуть код владельцу |
| API повторяет таблицы владельца | Публичная поверхность раскрывает реализацию | Проверить, может ли владелец изменить хранение без consumer | Сузить данные до операции, результата или события |
| Тест зелёный, но импорт неизвестен | Проверена только модель, а не исходный код | Проверить источник фактических ссылок и дату evidence | Не выдавать модель за аудит; добавить реальный анализ |
from → to.surface и отдельно укажите запрещённую обратную связь. У каждого исключения должен быть владелец и дата пересмотра.Граф границ не отвечает за транзакции, задержку, права доступа, размер payload, версионирование событий и качество данных. Разрешённая стрелка может вести к медленной операции. Запрещённая стрелка может стать оправданной после смены владельца. Поэтому зелёный статус архитектурной проверки не заменяет нагрузочный, security или интеграционный тест.
\nОтрицательный путь нужно сохранять рядом с правилом. Вызов catalog.internal должен завершаться понятным отказом, а не молча проходить через исключение. Неизвестный модуль, дубликат связи и цикл тоже должны иметь отдельные сообщения. Если проверка пропускает пустую поверхность или принимает произвольный путь к файлу, она защищает только видимость, но не границу.
Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.
\nГраница готова, если для каждого межмодульного вызова команда может показать четыре записи: сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Реальная проверка должна пройти по фактическим ссылкам и отдельно показать отрицательные случаи: internal-протечку, неизвестный модуль и цикл. Учебная модель может проверить только формулировку правила и обязана так себя называть.
\nЕсли один из четырёх ответов отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца или оставьте код локальным. Модульный монолит приносит пользу именно в этот момент: команда получает ясную границу и может менять внутренность без скрытых потребителей.
\nНиже — учебный сценарий, а не отчёт о конкретной production-системе. В монолите код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Затем платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит, а папки по-прежнему выглядят как отдельные домены.
\nСимптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой временно терпят ради срока. Цена ошибки — скрытый контракт: он расширяет область изменения, усложняет откат и делает возможное выделение сервиса дороже.
\nПрактический тезис такой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой межмодульной связи нужно назвать потребителя, владельца смысла и поверхность доступа: consumer → owner.publicApi. Отдельно фиксируют разрешённые направления. Эта статья показывает модель и небольшой проверяемый fixture; он не заменяет анализатор импортов, тесты данных, нагрузку или security-проверку.
Модуль владеет смыслом операции, связанными с ним данными и публичным входом. Публичный вход не равен каждому символу с модификатором public: это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность потребителя, а не раскрывать таблицу, репозиторий или внутренний formatter.
Возьмём четыре условных модуля: catalog, checkout, payments и notifications. Разрешены только checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это форма архитектурной политики, а не утверждение о структуре какого-либо проекта.
| Вопрос | Пример ответа | Что защищаем |
|---|---|---|
| Кто вызывает? | checkout | Сценарий потребителя и его ответственность |
| Кто владеет смыслом? | catalog | Право менять контракт и правила данных |
| Через что вызывают? | catalog.api | Запрет на чтение внутренней реализации |
| Разрешено ли направление? | checkout → catalog | Контроль обратных связей и циклов |
Запись «checkout зависит от catalog» слишком широка: она допускает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавить ли узкую операцию, событие или оставить работу внутри своего модуля.
Сначала составляют карту модулей: имя, владелец, публичная поверхность, внутренние пакеты и разрешённые исходящие связи. Затем фактические импорты или вызовы сопоставляют с этой картой. Проверка каждой ссылки должна ответить на четыре вопроса: существуют ли оба модуля, совпадает ли поверхность с опубликованной, разрешено ли направление и не образует ли оно цикл.
\nНаправление нужно хранить отдельно от физического пути. В одном стеке внутренний пакет частично закрывает компилятор, в другом остаются соглашение и архитектурный тест. Видимость помогает, но не отвечает на вопрос владения. Карта нужна именно для этого: она делает исключение обсуждаемым, а не случайным импортом.
\nnode --input-type=module -e \"const allowed=new Set(['checkout>catalog:catalog.api','checkout>payments:payments.api','payments>notifications:notifications.api']); const links=[{from:'checkout',to:'catalog',surface:'catalog.api'},{from:'catalog',to:'checkout',surface:'checkout.api'},{from:'checkout',to:'catalog',surface:'catalog.internal'}]; const check=({from,to,surface}) => surface !== to + '.api' ? 'non-public-surface' : allowed.has(from + '>' + to + ':' + surface) ? 'allowed' : 'forbidden-direction'; console.table(links.map(link => ({...link,result:check(link)})));\"\nСохраните команду во временный терминал без изменений и выполните её через Node.js 18 или новее. Ожидаемый результат для трёх строк: allowed, затем forbidden-direction, затем non-public-surface. В fixture специально нет чтения репозитория: он проверяет только заранее заданные записи. Поэтому зелёный результат доказывает корректность policy-функции для этого входа, но не отсутствие незаконных импортов в приложении.
Если карта разрешает checkout → payments, а затем добавляет payments → checkout, граф становится циклическим. Это не означает, что завтра нужно выделить микросервис. Это сигнал уточнить владельца процесса, границу инварианта и способ обмена. Часто операция переносится к владельцу, а результат публикуется событием; иногда нужен узкий порт без обратного вызова.
Событие само по себе не уничтожает связь: остаются схема сообщения, политика повторов, порядок, идемпотентность и владелец данных. Если эти условия не названы, цикл просто переехал из импортов в сообщения. То же относится к общему пакету: тип, которым пользуются все, может стать не API, а обходом границы.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
Импортируется catalog.internal | Публичный вход не выражает нужную операцию либо внутренняя деталь удобнее | Сверить фактический импорт с картой API | Вернуть операцию владельцу или добавить узкий контракт |
| Появилась обратная стрелка | Не определён владелец процесса | Построить граф и найти цикл | Выбрать orchestration, событие или перенос операции |
Все модули импортируют common | Общий пакет скрывает владение типами | Назвать владельца каждого типа и его изменений | Разделить контракты или вернуть тип владельцу |
| API повторяет таблицу владельца | Публичная поверхность раскрывает хранение | Проверить, можно ли сменить storage без consumer | Сузить результат до операции, DTO или события |
| Архитектурный тест зелёный, а импорт неизвестен | Проверена только модель в памяти | Сопоставить источник фактических ссылок с policy | Не называть fixture аудитом; подключить реальный анализ |
В Java Platform Module System модуль объявляет requires для зависимостей и exports для пакетов, доступных извне. Это физический механизм языка, но он не заменяет решение о владельце операции. В Spring Modulith логические модули выводятся из структуры пакетов; для них можно проверять отсутствие циклов, доступ только через API-пакеты и явно разрешённые зависимости.
var modules = ApplicationModules.of(Application.class);\nmodules.verify();\nЭтот Java-фрагмент воспроизводим только в проекте, где подключён Spring Modulith и существует класс приложения Application; он не является самостоятельной командой для Node-проекта. В проекте на другом языке понадобится соответствующий анализатор импортов или архитектурный тест. Общий критерий один: проверка должна видеть фактические зависимости, а не только красивую схему.
from → to.surface дополните запрещённой обратной связью и владельцем исключения.Граф зависимостей отвечает за структуру, но не за транзакции, задержку, права доступа, размер payload, версионирование событий, миграции схемы и качество данных. Разрешённая стрелка может вести к медленной операции, а временно запрещённая — стать допустимой после смены владельца. Поэтому архитектурный PASS не заменяет нагрузочный, security, контрактный и интеграционный тест.
\nНе переносите правило catalog.internal в универсальную истину для любого фреймворка. В Java package-private, JPMS exports и Spring Modulith дают разные уровни защиты; в JavaScript или PHP часть границ может остаться договором, статическим анализом и review. Открытый модуль в Spring Modulith также меняет правила доступа и может быть переходным решением для legacy-кода, а не целевым состоянием.
Отрицательный путь должен быть наблюдаемым: неизвестный модуль, пустая поверхность, дубликат связи и цикл возвращают понятную ошибку. Если тест пропускает произвольный путь к файлу или проверяет только наличие слов api, он защищает соглашение о названии, но не архитектурную границу.
Граница готова, если для каждого межмодульного вызова можно показать сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Автоматическая проверка проходит по фактическим ссылкам и отдельно сообщает об internal-протечке, неизвестном модуле и цикле. Учебный fixture из статьи проверяет только форму policy и обязан так себя называть.
\nЕсли хотя бы один ответ отсутствует, не расширяйте API и не начинайте распил. Сначала уточните владельца или верните операцию локально. Ценность модульного монолита именно в этом: внутренность можно менять независимо от потребителей, а границу — проверять до дорогостоящего распределения системы.
\nrequires, exports и доступа к пакетам. Ограничение: применимо к JPMS и не описывает правила произвольного монолита.verify() проверяет модель, которую библиотека смогла построить.Симптом обычно выглядит безобидно: разработчик в модуле checkout добавляет импорт из catalog/internal, потому что нужный helper уже готов. Сборка проходит. Через несколько недель изменение внутреннего parser-а каталога требует искать потребителей в оплате и заказах. Команда больше не знает, какой код можно менять локально. Цена ошибки — скрытые регрессии, длинный review и рефакторинг, который нельзя выполнить по частям.
Папка с названием домена не создаёт границу. Она помогает найти файлы, но не определяет право на импорт. Граница появляется только тогда, когда команда явно задаёт публичную поверхность модуля, владельца этой поверхности и допустимые направления зависимостей. После этого правило можно проверить на коде и отдельно проверить отрицательный путь: внутренний импорт и цикл должны ломать проверку.
\nУ модуля есть две стороны. Первая — то, что он публикует: команда, запрос, тип или событие с понятным смыслом. Вторая — то, от чего он зависит. Если описана только первая сторона, API быстро превращается в транзит к чужим деталям. Если описана только вторая, команда видит список импортов, но не понимает, какие вызовы считаются устойчивыми.
\nДля каждой связи полезно хранить тройку source → target.surface. Например, checkout → catalog.api означает, что checkout использует именно опубликованную поверхность каталога. Запись checkout → catalog слишком широка: она не отличает API от repository, внутреннего mapper-а и класса, который случайно объявили public.
Учебный пример ниже не описывает реальный продукт и не сообщает о результатах в production. В нём четыре модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены только три стрелки: checkout к API каталога, checkout к API платежей и payments к API уведомлений.
| Откуда | Куда | Решение | Что это защищает |
|---|---|---|---|
| checkout | catalog.api | разрешено | заказ получает товар через контракт каталога |
| checkout | payments.api | разрешено | заказ не знает внутреннюю реализацию оплаты |
| payments | notifications.api | разрешено | уведомление вызывается через отдельную поверхность |
| любой модуль | чужой *.internal | запрещено | детали реализации остаются у владельца |
| catalog | payments.api | запрещено в этой модели | новая стрелка требует сценария и владельца |
Публичная поверхность должна выражать потребность потребителя, а не повторять внутреннюю структуру владельца. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и набор внутренних преобразователей. Ему нужен узкий порт, например CatalogReader. Владелец может заменить хранение и parser, пока сохраняет смысл этого порта.
/* Учебный пример. Это контракт модуля catalog, а не готовая production-модель. */\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// checkout импортирует только public surface:\nimport type { CatalogReader } from '../catalog/public';\n\n// Такой импорт нарушает границу:\nimport { ProductParser } from '../catalog/internal/ProductParser';\nСамо слово public не решает архитектурную задачу. В обычном монолите разработчик часто может технически импортировать любой доступный символ. Поэтому правило состоит из двух уровней. Язык и модульная система задают физическую видимость, а архитектурный тест задаёт смысловое разрешение. Нельзя подменять одно другим.
Направления должны образовывать ориентированный ацикличный граф. Цикл checkout → payments → checkout не всегда означает, что предметная модель неверна. Он означает, что текущий порядок владения не объяснён. Пока цикл существует, изменение одного модуля требует держать в голове другой, а изолированный тест и поэтапная миграция становятся дороже.
Разорвать цикл можно несколькими способами. Сначала назовите операцию и её владельца. Если payments сообщает checkout о результате, событие может идти в одну сторону. Если оба модуля используют одинаковое правило, возможно, нужен небольшой тип без поведения. Если один модуль просит внутреннюю деталь другого, сначала спроектируйте порт по потребности. Пакет common не является решением сам по себе: без владельца он превращается в новую общую свалку.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Изменение внутреннего класса требует искать чужие вызовы | Потребитель импортирует деталь вместо контракта | Выписать from, to и surface для импорта | Сформировать узкий API и перевести один вызов |
| Два модуля ссылаются друг на друга | Не назван владелец операции или сообщения | Построить граф прямых зависимостей и найти цикл | Выбрать владельца, событие или односторонний adapter |
Все новые вызовы идут через shared | Временный helper получил неограниченную роль | Проверить владельца, потребителей и срок исключения | Оставить тип локальным либо вернуть поведение владельцу |
| Тест границ зелёный, но API отдаёт слишком много | Структурное правило приняли за проверку бизнес-контракта | Сопоставить данные API с конкретным сценарием потребителя | Уточнить DTO, права, инварианты и отдельные тесты |
| Новая стрелка добавлена ради прохождения сборки | Правило не требует обоснования связи | Спросить сценарий, владельца, альтернативу и цену связи | Оформить исключение с датой пересмотра или не добавлять импорт |
Минимальная проверка отвечает на четыре вопроса: существует ли названный модуль, существует ли его поверхность, разрешено ли направление и нет ли цикла. Отдельно проверяется запрет на internal. Если тест проверяет только разрешённые примеры, его можно случайно сломать так, что он начнёт принимать любой импорт.
// Учебный псевдокод проверки политики.\nconst allowed = new Set([\n 'checkout->catalog:catalog.api',\n 'checkout->payments:payments.api',\n 'payments->notifications:notifications.api',\n]);\n\nfunction check(reference) {\n if (reference.surface.endsWith('.internal')) return 'reject: internal';\n const key = `${reference.from}->${reference.to}:${reference.surface}`;\n return allowed.has(key) ? 'accept' : 'reject: direction';\n}\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.api' });\n// accept\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.internal' });\n// reject: internal\nЭтот фрагмент проверяет только заранее переданную политику. Он не читает файлы, не строит AST, не сканирует package graph и не доказывает отсутствие нарушений в конкретном репозитории. Для реального проекта нужен инструмент, который видит фактические зависимости исходного кода, а затем тот же инструмент должен быть подключён к обычной проверке проекта. Учебный псевдокод помогает проверить форму правила, но не заменяет такой анализ.
\nCatalogReader, а не CatalogInternals. Перечислите, что остаётся закрытым.source → target.surface. Запрещённые направления запишите явно.Граф зависимостей не отвечает за качество API. Разрешённый вызов может быть медленным, возвращать лишние данные или нарушать бизнес-инвариант. Он также не решает транзакции, права доступа, владение таблицами, доставку событий и совместимость схем. Эти свойства требуют отдельных контрактов и тестов.
\nФизическая модульность зависит от стека. Java Platform Module System умеет ограничивать экспорт пакетов, но многие приложения живут в обычном classpath. Spring Modulith предлагает проверку application modules, API-пакетов, циклов и явно разрешённых зависимостей, но это решение для Spring-стека. В TypeScript или другом языке понадобится другой анализатор. Переносить аннотации без переноса семантики бесполезно.
\nНе всякая связь должна исчезнуть. Две области могут честно зависеть от общего справочного типа или от события. Важно назвать форму связи и её владельца. Если новая стрелка появляется только потому, что импорт проще, это сигнал остановиться. Если она нужна предметному сценарию, она должна попасть в карту и пройти тот же отрицательный путь.
\nУчасток готов, когда команда может показать карту с владельцами и поверхностями, а проверка даёт четыре наблюдаемых результата: разрешённая связь проходит; импорт чужого internal отвергается; неизвестная стрелка отвергается; цикл получает отдельную ошибку. После перевода одного реального вызова потребитель больше не импортирует детали владельца. Если хотя бы один результат нельзя воспроизвести на коде, граница пока остаётся договорённостью на словах.
Сбой начинается с безобидного импорта. Код оформления заказа берёт 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.В день переключения новый обработчик отвечает успешно, но команда не может быстро ответить на четыре вопроса: какой трафик он получил, с чем его сравнивать, кто остановит волну и что произойдёт с уже записанными данными. Обычно звучит: «включим на десять процентов, а если что — откатим». Процент не задаёт границу риска. Слово «откат» не объясняет, вернётся ли только маршрут или ещё и состояние системы.
\nЦена ошибки — не только временная деградация. Новый путь может отправить письмо, создать платёж, изменить баланс или записать событие до того, как команда заметит проблему. Переключатель вернёт следующие запросы в старую систему, но уже созданный эффект останется. Поэтому модернизация legacy начинается с узкого шва и проверяемого решения, а не с общего обещания переписать всё.
\nТезис. Безопасная замена legacy — это последовательность границ: один маршрут, явный владелец, сравнимый control, ограниченное окно и отдельно описанный путь возврата. Rollout отвечает на вопрос «кому разрешено увидеть новый код». Rollback-route отвечает на вопрос «куда направить следующий запрос». Восстановление данных отвечает на другой вопрос: «что делать с эффектами, которые уже произошли».
\nШов — участок поведения, который можно отделить от остальной системы. Это может быть чтение каталога, расчёт тарифа или выдача профиля. Для первого шага лучше выбрать операцию с понятным входом, ограниченным числом потребителей и наблюдаемым результатом. Если действие меняет деньги, права или внешнюю запись, его граница должна включать эти эффекты, а не только HTTP-ответ.
\nПрокси или адаптер принимает запрос и выбирает старый либо новый обработчик. Сначала он может передавать запрос в legacy без изменения. Затем команда добавляет новый обработчик за той же границей. Такой подход оставляет старый путь доступным, пока новый контракт не проверен. Маршрут должен быть перехватываемым, а состояние — достаточно понятным для сравнения.
\n| Поле | Пример | Зачем нужно |
|---|---|---|
| Шов | GET /catalog/item | Ограничивает область изменения |
| Владелец | Команда каталога | Назначает решение |
| Control | Тот же запрос через legacy | Даёт точку сравнения |
| Сигнал | Код ответа и время | Задаёт наблюдаемый признак |
| Возврат | Предыдущая версия правила | Показывает обратимое действие |
| Данные | Только чтение | Отделяет маршрут от восстановления |
Таблица не заменяет проверку поведения. Одинаковый статус 200 может скрывать другой набор полей, задержку или побочный эффект. Для каждого шва нужны valid, invalid и repeat-сценарии. Повтор особенно важен для операций с ключом идемпотентности: одинаковый запрос не должен создать второй эффект.
Decision gate должен проверять один класс риска. Gate шва проверяет область и владельца. Gate совместимости проверяет входы, ответы и эффекты. Gate доставки проверяет версию адаптера и правило маршрутизации. Gate наблюдения проверяет control, population, duration и источник сигнала. Gate возврата проверяет только обратимое действие. Статус одного gate не доказывает остальные.
\nОграниченная волна имеет смысл только рядом с control. Если новый путь обслуживает пользователей без скидок, а legacy — остальных, различие может объясняться составом аудитории. Если окно короче агрегации метрики, сигнал опоздает. Если оба пути используют общий кеш или базу, новый код способен изменить поведение старого. Такое наблюдение останавливает вывод «новая версия сломана», но не отменяет расследование.
\nУчебный пример ниже показывает форму решения для операции чтения. Имена и значения вымышлены; пример не сообщает о реальном сервисе, трафике или измерении.
\nconst gate = {\n seam: 'catalog.item.read',\n owner: 'catalog-team',\n population: 'tenant=demo',\n control: 'legacy-v3',\n candidate: 'adapter-v1',\n duration: '15m',\n signal: ['status_code', 'latency_ms', 'schema_diff'],\n decision: 'manual_review',\n rollbackRoute: 'route -> legacy-v3',\n dataEffect: 'read-only',\n};\n\nif (gate.dataEffect !== 'read-only' && !reconciliationOwner) {\n throw new Error('data boundary is unknown');\n}\nПоследняя проверка намеренно блокирует операцию. Если новый путь пишет данные, одного правила маршрута недостаточно. Нужны идентификатор эффекта, журнал, владелец сверки и решение для повторного запроса. Когда условия неизвестны, безопасный результат — не расширять волну и оставить шов на legacy.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Обсуждают только процент | Процент приняли за стратегию | Назвать population, control, duration и signal | Остановить включение |
Оба пути вернули 200 | Сравнили транспорт, не поведение | Проверить поля, ошибки, время и эффект | Добавить case и владельца |
| «Откат» означает выключение флага | Маршрут смешали с данными | Перечислить записи и внешние вызовы | Описать reconciliation или запретить запись |
| Сигнал нового пути хуже control | Различается population или shared state | Сопоставить запросы, окно и зависимости | Поставить волну на паузу |
| Неизвестно, кто вернёт маршрут | Нет владельца и prior state | Проверить возврат без пользовательского трафика | Не выдавать разрешение |
Для read-only шва возврат маршрута обычно проще: следующий запрос снова идёт в известную версию. Но общий кеш, sticky session или изменённая схема могут связать пути. Нужно проверить, что старый обработчик принимает текущее состояние и что новый код не изменил его косвенно.
\nДля write-операции новый обработчик мог создать заказ, отправить сообщение, вызвать платёжный шлюз или записать событие. Отключение адаптера остановит новые вызовы, но не отменит внешний эффект. Компенсация может быть невозможна, дублировать действие или потребовать бизнес-решения. Перед такой миграцией нужны record id, журнал состояния, владелец сверки и ответ для повторной доставки.
\nЕсли команда не может назвать эти элементы, не пишите аварийный delete-скрипт. Сузьте первый шов до чтения, добавьте preview или оставьте запись в legacy. Отложенное изменение сохраняет управляемость. Быстрое переключение без границы данных переносит проблему в момент, когда исправление дороже.
\nCanary не заменяет тесты. Небольшая доля трафика снижает область воздействия, но не доказывает полноту поведения. Synthetic нагрузка не показывает все состояния реальных пользователей. Общие базы, кеши, очереди и внешние провайдеры могут испортить независимость control и нового пути. Автоматический rollback полезен только там, где действие действительно обратимо.
\nУниверсального безопасного процента нет. Для системы, где каждый запрос меняет баланс, десять процентов могут быть слишком много. Для чтения сто процентов допустимы после проверки совместимости. Число выбирают после определения population, эффекта и времени обнаружения проблемы.
\nКритерий готовности проверяем. Другой инженер без устного контекста может показать владельца; legacy и candidate версии; control; population и duration; сигналы и их источники; valid, invalid и repeat cases; действие возврата; список необратимых эффектов и владельца сверки. Команда может выполнить безопасное обратное переключение на тестовом контуре и увидеть, куда пойдут следующие запросы. Если пункт неизвестен, готовность не доказана и волну не расширяют.
\nВ зрелом проекте слово «модернизация» часто появляется раньше, чем найдено конкретное место изменения. Проекту много лет, команда снова обсуждает полное переписывание, а перед переключением нового обработчика остаются вопросы: какой трафик он увидит, с чем его сравнивать, кто остановит волну и что произойдёт с уже записанными данными.
\nЦена ошибки — не только временный рост ошибок. Новый путь может создать заказ, изменить баланс, отправить письмо или записать событие до обнаружения проблемы. Переключатель вернёт следующие запросы в старую систему, но уже созданный внешний эффект сам не исчезнет. Поэтому безопасная модернизация начинается с узкого шва и проверяемого решения, а не с обещания заменить весь монолит за один раз.
\nГлавная граница. Rollout отвечает на вопрос «кому разрешён новый код». Rollback маршрута отвечает на вопрос «куда направить следующий запрос». Восстановление данных отвечает на вопрос «как обработать эффект, который уже произошёл». Это связанные, но разные действия.
\nШов — участок поведения, который можно отделить от остальной системы: чтение карточки товара, расчёт тарифа или получение профиля. Для первой волны подходит операция с понятными входами, ограниченным числом потребителей и наблюдаемым результатом. Если операция меняет деньги, права или вызывает внешний сервис, граница должна включать эти эффекты, а не только HTTP-ответ.
\nДругой инженер должен суметь назвать вход, старый контракт, новый контракт, владельца решения и способ остановить поток. Если измеряется только общий процент ошибок системы, шов слишком широк. Если неизвестно, кто пользуется старой схемой данных, сначала собирают зависимости и добавляют совместимый адаптер. Ниже приведён учебный сценарий: он объясняет способ проверки, но не выдаёт себя за отчёт о конкретной production-системе.
\n| Поле | Пример | Что должно быть проверено |
|---|---|---|
| Операция | GET /catalog/items/{id} | Понятно, какой запрос входит в волну |
| Владелец | Команда каталога | Есть ответственный за stop/continue |
| Control | legacy-v3 | Есть известная версия для сравнения |
| Candidate | adapter-v1 | Новый путь виден в логах и метриках |
| Состояние | Только чтение | Rollback маршрута не обещает откат записи |
| Сигналы | 5xx, p95, schema diff | У каждого сигнала есть источник и порог |
Шов обычно оформляют как proxy, adapter или anti-corruption layer. Сначала слой пропускает все вызовы в legacy без изменения. Затем для выбранной операции он отправляет запрос в candidate, преобразует его внутренний ответ в прежний внешний контракт и позволяет одним изменением правила вернуть следующие запросы в legacy. Потребители не обязаны мигрировать одновременно с серверной реализацией.
\nУ proxy есть собственный риск: он может стать единой точкой отказа или узким местом. Его latency и ошибки измеряют отдельно, а исходный pass-through маршрут проверяют до включения candidate. Наличие прокси само по себе не доказывает готовность: нужны его версия, конфигурация и поведение при недоступности нового обработчика.
\nСледующая команда показывает форму сравнения двух read-only маршрутов. Заголовок X-Route — условный интерфейс тестового адаптера; его нельзя посылать в произвольный production endpoint. Подставьте документированный способ выбрать control и candidate. Нормализуйте только поля, которые заранее признаны техническими.
test -n \"$BASE_URL\" || { echo \"set BASE_URL\" >&2; exit 2; }\ntest -n \"$ITEM_ID\" || { echo \"set ITEM_ID\" >&2; exit 2; }\n\nfor route in legacy candidate; do\n curl --fail-with-body --silent --show-error \\\n -H \"X-Route: $route\" \\\n \"$BASE_URL/catalog/items/$ITEM_ID\" > \"$route.json\"\ndone\n\njq -S 'del(.requestId, .generatedAt)' legacy.json > legacy.normalized.json\njq -S 'del(.requestId, .generatedAt)' candidate.json > candidate.normalized.json\ndiff -u legacy.normalized.json candidate.normalized.json\nНулевой diff проверяет только один вход и выбранную нормализацию. Добавьте valid, invalid, отсутствующий идентификатор, forbidden и повторный запрос. Если вызов имеет скрытый побочный эффект, сравнение JSON не заменяет журнал вызовов и сверку состояния.
\nCanary — не произвольные «десять процентов». В модели Google SRE candidate получает подмножество population на ограниченное время, а оставшаяся часть служит control. Нужны способ разделить трафик, процедура оценки и связь этой оценки с решением о выпуске. Разделителем может быть tenant, стабильная группа пользователей, отдельный маршрут или версия на балансировщике.
\nControl должен быть сопоставим с candidate. Если новый код обслуживает только новых пользователей, различие может объясняться population. Если метрика строится каждый час, а волна длится пятнадцать минут, сигнал запоздает. Общая база, кеш или очередь могут позволить candidate повлиять на control, поэтому сравнение дополняют абсолютным SLO.
\n| Измерение | Источник | Если результат неизвестен |
|---|---|---|
| HTTP 5xx по версии | Счётчик запросов proxy | Поставить волну на паузу |
| p95 latency | Histogram одного маршрута | Не расширять population |
| Схема ответа | Нормализованный contract diff | Разобрать поле и потребителей |
| Повторный эффект | record id или idempotency key | Остановить write-path |
| Состояние control | Абсолютный SLO | Отделить общий сбой от candidate |
Порог — проектное решение, не универсальная цифра. Его связывают с SLO, размером выборки, длительностью окна и ценой ошибки. Один успешный ответ не доказывает совместимость, а хороший общий error rate может скрыть редкий дорогой сценарий.
\n| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Обсуждают только процент | Размер подменил стратегию | Назвать population, control, window и owner | Не включать candidate |
| Оба пути вернули 200 | Сравнили транспорт, не смысл | Проверить поля, ошибки, latency и effect | Добавить contract cases |
| Candidate медленнее вечером | Различается нагрузка или cache state | Сопоставить окно, ключ и backend | Повторить в сопоставимой группе |
| После rollback появились дубли | Запись уже произошла | Сверить record id и журнал | Остановить запись, не делать blind delete |
| Никто не нажимает stop | Нет владельца и prior state | Проверить runbook на тестовом контуре | Вернуть решение на подготовку |
Рост 5xx в candidate — сигнал остановиться, но не автоматическое доказательство причины: общая зависимость и несовпадающие окна могут менять оба пути. И наоборот, хороший A/B-график не отменяет проверки денежных, правовых и permission-sensitive эффектов.
\nДля read-only шва rollback часто означает смену правила proxy: следующие запросы снова идут в legacy. Но общий кеш, sticky session, изменённая схема или миграция справочника могут связать старый и новый пути. Перед возвратом нужно проверить, что legacy понимает актуальное состояние и candidate не изменял его косвенно.
\nДля write-операции новый обработчик мог создать заказ, отправить сообщение, вызвать платёжный шлюз или записать событие. Остановка candidate предотвращает часть новых вызовов, но не удаляет запись из внешней системы. Компенсация может быть невозможной, породить второй эффект или потребовать бизнес-решения.
\nAWS различает cutover без новых данных и возврат после появления новых транзакций: во втором случае нужен отдельный план работы с данными, например fail-forward, dual-write или проверенное восстановление из backup. Это не универсальный рецепт. Способ выбирают по модели владения данными, RPO/RTO, идемпотентности и требованиям бизнеса.
\nМинимальный набор для write-path — record id, журнал переходов, idempotency key или доказанное отсутствие повторной доставки, владелец сверки и порядок действий при расхождении. Если элементов нет, первый шов лучше сузить до чтения, добавить preview или оставить запись в legacy. Аварийный delete-скрипт без карты зависимостей — не стратегия восстановления.
\nCanary снижает размер воздействия, но не заменяет тесты и не даёт статистической гарантии. Маленькая population может не содержать редкие роли. Synthetic traffic не воспроизводит все реальные состояния. Общие backend-компоненты нарушают независимость control и candidate. Система с балансами, правами или внешними эффектами требует более строгой границы, чем read-only endpoint.
\nУниверсального безопасного процента нет. Десять процентов запросов к операции, меняющей баланс, могут быть чрезмерным риском; для чтения сто процентов могут быть приемлемы после проверки совместимости. Размер и duration выбирают так, чтобы увидеть релевантную нагрузку, не потратить незаметно error budget и успеть остановить волну до необратимого ущерба.
\nГотовность доказана, когда без устного контекста можно показать точный шов, control и candidate, population, окно, источники сигналов, тестовые cases, stop/rollback runbook, список необратимых эффектов, владельца сверки, совместимость схемы и безопасное поведение при недоступности candidate. Пока хотя бы один пункт неизвестен, волну не расширяют.
\n