{ "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 показывает возвраты. Такая граница переносит изменение туда, где живёт правило, и оставляет общей утилите одну причину для изменения — представление значения.

Диагностическая карта для одного package edge
НаблюдениеЧто это означаетПроверкаИсправление
Утилита импортирует 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 пакетаСначала экспортировать совместимый путь, затем объявить миграцию

Контракт public API

Для небольшого shared-пакета достаточно одной записи, которую можно проверить в review. В ней должны быть root specifier, публичные имена, формы входа и выхода, владелец, разрешённые зависимости и запретные маршруты. Например: @example/platform-formatting экспортирует formatMoney; функция принимает amount, currencyCode и locale; пакет не импортирует @example/billing-domain/*; consumers не импортируют его src/*.

Не путайте публичное имя с файлом, в котором оно сейчас лежит. Внутри можно поменять format-money.js на несколько модулей, если root export и поведение функции остаются совместимыми. И наоборот: экспорт всего каталога делает каждое имя частью ожиданий consumers, даже если оно появилось как временный helper.

Учебный граф: orders и billing импортируют root API platform-formatting, пакет форматирования использует runtime, а запрещённая пунктирная стрелка ведёт от formatter к billing domain.
В учебной модели домен принимает решение и использует formatter, а formatter не тянет обратно доменный тип. Граф объясняет направление зависимости; он не является снимком конкретного production-репозитория.

Что даёт package.json.exports

В 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 и ESLint

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. Такой порядок позволяет отличить изменение ответственности от изменения формата и легче откатить неудачный шаг.

Порядок воспроизводимой проверки

  1. Запишите один проблемный edge: файл, specifier, imported name, направление и владельца.
  2. Найдите фактические consumers, включая тестовые и инструментальные конфигурации. Не считайте совпадение в документации импортом без проверки.
  3. Сформулируйте API record с root entry point, публичными именами, входами, выходами и forbidden routes.
  4. Перенесите решение о доменном состоянии к его владельцу и оставьте utility нейтральные значения.
  5. Добавьте минимальный exports и положительный тест root import.
  6. Добавьте отрицательный тест deep import и проверьте ожидаемый код ошибки в том же resolver-е, который использует приложение.
  7. Настройте TypeScript и ESLint на согласованные маршруты; отдельно перечислите dynamic loading, aliases и generated layers, которые эти проверки не покрывают.
  8. Проверьте сборку и тесты consumers, затем удалите временный adapter только после того, как список старых импортов стал пустым.

Полезно сохранить два отрицательных теста рядом с контрактом: 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.

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

" }