{ "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-а, а владелец зависимости неизвестен. Небольшой пакет перестаёт меняться изолированно.

\n

Тезис простой: границу пакета нельзя поручить одному инструменту. Сначала команда описывает public API. Затем runtime и компилятор ограничивают видимые точки входа. После этого статическое правило ловит запрещённые направления. Каждый слой проверяет свою часть договора. exports не заменяет архитектурное решение, TypeScript не определяет смысл доменной зависимости, а lint не видит весь динамический граф.

\n

Механизм границы

\n

Пакет может содержать больше, чем обещает. Внутри formatter-а допустимы cache key, fallback locale и адаптер к библиотеке дат. Consumer должен видеть root specifier и небольшой набор имён. Если consumer импортирует внутренний файл, устройство каталогов превращается в публичный контракт. Если utility импортирует доменный enum, она получает чужое правило принятия решений.

\n

Type-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 routesdynamic import и полный графзакодировать узкий запрет с альтернативой
\n
\"Схема
Учебная схема: root API принимает примитивные данные, а доменный тип и внутренний subpath находятся за границей. Иллюстрация не описывает настоящий registry или production-пакет.
\n

Пример: вернуть смысл владельцу домена

\n

Рассмотрим синтетический пакет @synthetic/platform-formatting. Он форматирует деньги и даты. Billing хочет показывать особый текст для просроченного счёта. Плохой путь передаёт в formatter весь invoice или импортирует InvoiceStatus. Тогда форматирование решает бизнес-вопрос. Новый статус становится изменением shared package.

\n

Безопаснее сначала получить 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 только потому, что он уже используется.

\n

Как связать договор и инструменты

\n

Поле exports в package.json помогает объявить entry points. Resolver видит перечисленные subpath, а не случайные файлы каталога. Это полезная граница package surface. Но абсолютный путь к файлу может обойти такую инкапсуляцию. Значит, exports не является security boundary и не доказывает отсутствие плохих зависимостей.

\n

TypeScript в режимах node16 и nodenext учитывает модель Node и package maps. Это уменьшает расхождение между проверкой типов и запуском. Но компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility. Смысловой запрет остаётся задачей контракта и политики.

\n

ESLint можно настроить на конкретные маршруты. Запретите consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Сообщение должно предлагать root API или adapter. Правило должно быть узким: общий запрет «не импортировать домены» может заблокировать законный интеграционный слой.

\n
/* Учебная политика ESLint, не готовая конфигурация проекта. */\n'no-restricted-imports': ['error', {\n  patterns: [{\n    group: ['@synthetic/platform-formatting/internal/*'],\n    message: 'Используйте root API пакета.',\n  }],\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 исключений
\n

Порядок действий

\n
  1. Выберите один пакет и назовите его роль. Не начинайте с общей папки shared.
  2. Запишите root specifier, public names, входы, выходы, owner и допустимых consumers.
  3. Отметьте внутренние subpath и доменные факты, которые пакет не должен интерпретировать.
  4. Проверьте реальные import routes: обычный import, re-export, type-only import и dynamic import.
  5. Настройте exports и compiler resolution только в поддерживаемой toolchain.
  6. Добавьте узкие ESLint restrictions с понятной альтернативой.
  7. Разберите каждое исключение отдельно. Для adapter укажите владельца и срок удаления.
  8. Проверьте public API тестом поведения и повторите поиск запрещённых маршрутов.
\n

Ограничения и критерий готовности

\n

Схема не делает пакеты независимыми автоматически. Adapter-ы, generated clients, plugin systems и framework entry points могут законно пересекать слои. Для них нужен явный маршрут и owner. Статический lint не описывает runtime registry и не ловит все вызовы import(). exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript resolution должен совпадать с тем, что реально запускает приложение.

\n

Не выдавайте учебный пример за аудит. В этой статье нет утверждения о production-результатах, размере bundle, CI или состоянии конкретного репозитория. Проверять нужно область, toolchain и импортный граф, а затем отдельно проверять поведение root API.

\n

Граница готова, если любой новый import можно классифицировать без чтения всего пакета: он входит в public API, нарушает названное правило или проходит через документированный adapter. Для выбранного пакета должны быть записаны owner и root API; команда должна получить диагностическое сообщение на запрещённый static import; тест public API должен пройти; поиск по исходникам не должен находить неразрешённые deep imports. Это проверяемый критерий, а не обещание абсолютной изоляции.

\n

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

\n" }