Симптом обычно появляется в маленьком pull request. В общей утилите форматирования просят учесть `InvoiceStatus`, потому что так удобнее вывести подпись рядом с суммой. Через неделю второй потребитель берёт внутренний cache этой утилиты, а третий ждёт от неё ещё один доменный флаг. Цена не в самом импорте. Пакет, который считали нейтральной функцией, начинает владеть чужим смыслом. Изменение статуса счёта теперь способно затронуть экран заказа, а ремонт formatter-а требует знать правила billing. Стоимость растёт в review, обновлениях и откатах: у команды больше нет малого места, которое можно менять изолированно.
Не надо отвечать на это большим переносом директорий. Сначала нужно назвать границу. Общая утилита полезна, пока принимает данные, не интерпретируя доменную историю: minor units, currency code, locale, ISO date. `InvoiceStatus`, лимиты возврата и правило «показывать счёт как просроченный» принадлежат billing-домену. Если utility импортирует тип или enum, чтобы решить, что показать, она становится транзитной точкой доменной политики. Тогда любой новый consumer получает не только функцию, но и скрытое право зависеть от чужого языка.
Симптом → причина → проверка → действие
- Симптом. В описании задачи звучит «добавим одно условие в shared helper», а имя импортируемого объекта относится к заказу, счету, клиенту или другому домену.
- Причина. В utility нет записанного public API. Потребитель видит файлы и считает любой внутренний symbol доступным; домен видит свободную функцию и переносит в неё собственное решение.
- Проверка. Для одного пакета перечислите root specifier, экспортируемые имена, входы, выход и два запрещённых направления: consumer не ходит в `/internal`, utility не импортирует domain package.
- Действие. Оставьте в utility преобразование примитивных входов, верните доменную интерпретацию в owner-package и запишите запрет рядом с public API. Автоматическую проверку добавляют только после того, как команда согласовала эти слова.
Минимальный public API, а не каталог файлов
Public API — это не всё, что случайно экспортируется из исходной папки. Это короткий договор: какой module specifier разрешён, какие имена можно импортировать, что получает функция и что она возвращает. У форматтера API может быть меньше пяти строк. Важна не красота декларации, а отсутствие доменной дырки. `formatMoney({ amountMinor, currencyCode, locale })` получает числа и строковые коды и возвращает строку. Он не принимает `Invoice`, не знает `InvoiceStatus` и не решает, разрешён ли возврат.
Такой API заставляет consumer сделать полезную работу у себя. Billing выбирает состояние счёта и вызывает formatter только для денег. Orders-feature тоже может вызвать formatter, но не вынужден таскать billing-модель. Это не запрет на переиспользование. Это разделение ответственности: общая функция владеет представлением примитивов, домен — значением собственных объектов. Если два домена действительно договорились об одном бизнес-правиле, оно должно жить в явно названном общедоменно́м модуле с owner и версией, а не прятаться внутри «utils».
| Элемент | Разрешено | Запрещено | Почему |
|---|---|---|---|
| Specifier потребителя | `@synthetic/platform-formatting` | `@synthetic/platform-formatting/internal/*` | root entry point остаётся единственной точкой обещания |
| Публичные имена | `formatMoney`, `formatIsoDate` | случайные cache и private helper | внутренность можно менять без миграции всех consumers |
| Вход `formatMoney` | `amountMinor`, `currencyCode`, `locale` | `Invoice`, `InvoiceStatus`, правило скидки | утилита не получает доменную модель |
| Исходящие зависимости utility | согласованный runtime formatting | billing и account domains | домен не протекает в общую платформенную точку |
| Решение при нарушении | короткий review и новый контракт | тихий deep import или перенос enum | изменение становится видимым до распространения |
Где поставить реальную границу
Начните не с названия папки, а с вопроса: «какой факт должен остаться верным, если billing меняет свою модель?» Для formatter-а ответ прост: он всё ещё умеет превратить amount и currency в отображаемую строку. Если невозможно сформулировать вход без объекта другого домена, значит функция не общая. Её место либо в billing, либо в отдельном пакете, который явно владеет общим словарём и имеет собственный контракт.
Полезно записать и запрещённый маршрут, а не только allowed API. Два правила из примера достаточно жёсткие, но понятные: consumer не импортирует `internal` и `src`; platform formatting не импортирует `@synthetic/billing-domain` и `@synthetic/account-domain`. Запрет не утверждает, что любые пакеты обязаны быть изолированы. Он охраняет конкретную роль пакета. Отдельному adapter-у, который намеренно соединяет billing с UI, нужны другое имя, другой owner и собственные допустимые зависимости.
Что можно сделать механизмами Node.js, TypeScript и ESLint
У этих инструментов разные полномочия. Node.js `package.json` `exports` умеет объявить доступные entry points package import-а. В документации Node v20.11.1 сказано, что неэкспортируемые subpath становятся недоступны обычному import, но это не сильная изоляция против прямого абсолютного пути. Значит `exports` полезен как runtime/package contract, но не заменяет архитектурный review и не защищает любой способ доступа к файлу.
TypeScript 4.7 добавил режимы `node16` и `nodenext`, которые понимают `exports`, `imports` и self-reference. Это важно для совпадения type-checking с форматом package entry point, особенно когда ESM и CJS имеют разные точки входа. Но compiler не знает бизнес-смысл `InvoiceStatus`. Он может подтвердить, что specifier разрешается, но не решает, должен ли formatter зависеть от billing. Такое решение остаётся в контракте пакета.
ESLint `no-restricted-imports` подходит для точного статического запрета после согласования пути. К марту 2024 правило уже умело ограничивать import paths, а релиз ESLint 8.55.0 добавил `importNamePattern`. Однако это слой проверки статического синтаксиса, не средство построить достоверный граф всего проекта. Dynamic import, generated code, aliases и runtime resolution требуют отдельно проверять область применимости. Не записывайте в policy обещание, которое выбранный linter не умеет выполнять.
Исполнимый fixed synthetic пример
Ниже fixture не открывает package.json и не ходит по каталогам. Внутри модуля уже лежат три фиксированные записи: чистый root import, импорт доменного `InvoiceStatus` самой utility и deep import consumer-а. Функция принимает только case id с явным `synthetic` marker и проверяет правила на этих объектах. Это удобная проверка формы контракта: она показывает, что «domain leak» и «public API bypass» не смешаны в одном общем сообщении.
import {
createFixedSyntheticBoundaryInput,
inspectSyntheticPackageBoundary,
planSyntheticBoundaryRemediation,
runPackageBoundaryFixture,
} from './upgrade-2024-03.mjs';
const report = inspectSyntheticPackageBoundary(
createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'),
);
const draft = planSyntheticBoundaryRemediation(report);
if (!Object.values(runPackageBoundaryFixture().assertions).every(Boolean)) {
throw new Error('synthetic fixture failed');
}
console.log(report.status); // violated
console.log(draft.realCi); // not-executed
// Здесь нет чтения репозитория, файлов, сети, CI или реального import graph.
node web/scripts/upgrade-2024-03.mjs --verify-fixture
# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.
PASS fixture означает только согласованность заранее записанных synthetic records и их отрицательных веток. Он не читает рабочее дерево, не строит import graph, не знает фактического `package.json`, не запускает lint или CI и не меняет production. Это намеренное ограничение. Тест, который называет себя boundary check, но тихо опирается на состояние неизвестного репозитория, плохо объясняет, какие именно правила он подтвердил.
Переход без большой миграции
Сначала остановите расширение поверхности. Опубликуйте короткий root API и для новой задачи требуйте один из двух исходов: использовать существующее имя или открыть отдельное API review. Затем выберите один доменный импорт из utility, перенесите интерпретацию обратно в owner-package и добавьте consumer-side adapter, если ему нужны данные в другом виде. После этого объявите `internal` приватным в документации и настройте выбранный механизм контроля только для уже согласованных путей.
Ограничения и следующий шаг
Эта схема не измеряет размер bundle, не оценивает циклы, не проверяет семантическую совместимость API и не устанавливает универсальную слоистость. Node `exports` работает в своих runtime и compatibility условиях; TypeScript modes требуют соответствующей конфигурации; ESLint rule охватывает статические import statements в выбранной настройке. Для legacy-кода может понадобиться временный adapter и срок удаления. Для runtime plugin system статический запрет вообще не описывает все связи.
Следующий шаг — выбрать один настоящий shared package и оформить one-page boundary record: owner, root specifier, public names, input/output, allowed incoming consumers, forbidden outgoing domains, способ проверки и дата следующего review. Пока такой record не существует, не называйте функцию платформой. Когда он появится, каждый новый импорт станет коротким проверяемым вопросом, а не очередным исключением в общей утилите.
Историческая граница марта 2024
К марту 2024 уже были доступны Node `exports` (в Node с 12.7.0; здесь взята официальная документация v20.11.1), TypeScript 4.7 с поддержкой Node-oriented package resolution и ESLint 8.55.0. Материал использует их как строительные блоки, но не приписывает им более поздние возможности. Голос M7 здесь практичный: сначала цена зависимости, затем контракт, ограничение инструмента и воспроизводимая проверка без выдуманного production-опыта.
Проверяемые источники
- Node.js v20.11.1: Modules: Packages, февраль 2024 — Первичная документация Node.js, доступная до марта 2024. Поле package.json "exports" задаёт доступные entry points; неэкспортируемые subpath для обычного package import недоступны. Node отдельно оговаривает, что это не сильная изоляция против прямого абсолютного пути. Документ не рисует архитектуру конкретного монорепозитория и не заменяет правило команды.
- TypeScript 4.7: ECMAScript Module Support in Node.js, май 2022 — Первичные release notes TypeScript. В режимах node16 и nodenext TypeScript поддерживает package.json "exports", "imports" и self-reference, а также различает ESM/CJS entry points и декларации. Это описание поведения компилятора, а не политика допустимых зависимостей между доменами.
- ESLint v8.55.0 release notes, 01.12.2023 — Первичный релиз ESLint, опубликованный до марта 2024: rule no-restricted-imports получила option importNamePattern. Она помогает фиксировать статические запреты импорта, но сама по себе не доказывает архитектуру, не строит полный dependency graph и не заменяет review динамических загрузок.