{ "index": 138, "slug": "editorial-2024-03-practice-package-boundaries", "title": "Границы пакетов: как закрыть deep import и не смешать домен с утилитой", "excerpt": "Общий пакет начинает дорожать в сопровождении, когда его внутренние файлы становятся API, а доменные типы проникают в нейтральную утилиту. Разбираем контракт root entry point, проверку через exports, TypeScript и ESLint и безопасную миграцию существующих импортов.", "contentHtml": "
Проблема проявляется не в момент создания пакета. Сначала 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 и ограничений статической проверки.