{ "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