{ "index": 137, "slug": "editorial-2024-03-mechanism-package-boundaries", "title": "Границы пакетов: как отделить public API от внутренностей", "excerpt": "Публичный API пакета — это договор о маршрутах импорта, данных и владельце смысла. Разбираем, как обнаружить утечку домена, ограничить deep import и не перепутать возможности Node.js, TypeScript и ESLint.", "contentHtml": "

Сбой границы редко начинается с красной сборки. Сначала formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Затем другой consumer импортирует cache по пути platform-formatting/internal/cache, потому что так короче. TypeScript не возражает, autocomplete подсказывает путь, тесты проходят. Цена появляется при следующем изменении: новый статус требует выпуска formatter-а, а переименование cache заставляет искать неизвестных потребителей.

\n

У этой ситуации две разные причины. Доменный тип переносит в общую утилиту смысл, которым владеет billing. Deep import превращает расположение файла в обещание для consumer-а. Лечить их одним запретом нельзя. Сначала нужно описать public API, затем поставить подходящие технические проверки и явно оставить места, где связь допустима.

\n

Сначала договор, потом инструменты

\n

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

\n

Граница отвечает не только на вопрос «откуда импортировать». Она фиксирует владельца смысла. Общая функция может превратить число в строку валюты, но не должна решать, что статус счёта означает «просрочен». Это решение принадлежит billing. Передавать нужно примитивы или готовую модель отображения, а не весь объект домена.

\n
Четыре слоя, которые нельзя подменять друг другом
СлойЧто проверяетЧего не доказываетПрактический вопрос
Boundary recordroot, public names, входы, выходы и ownerчто runtime действительно разрешает только эти маршрутыКто принимает изменение surface?
package.json exportsдоступные package entry points и subpathsдоменную корректность и абсолютные обходыКакой bare specifier разрешён?
TypeScript resolutionсопоставление module resolution с runtime или bundlerправо utility знать чужую бизнес-модельОдинаково ли разрешаются типы и запуск?
ESLint restrictionназванные статические import routesdynamic import, generated code и полный графКакой запрет должен сработать на diff?
\n
\"Схема
Схема показывает учебное правило направления: consumer использует root API, а доменный тип и внутренний cache не становятся частью обещанного surface. Названия synthetic-пакета не описывают конкретный registry.
\n

Две утечки, два решения

\n

Утечка домена видна по ответу на вопрос «кто меняет этот факт?». Если набор значений InvoiceStatus меняет billing-команда, formatter не должен импортировать enum даже через import type. Type-only import может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Значит, shared-пакет всё равно связан со словарём billing.

\n

Deep import имеет другую цену. Consumer зависит от имени файла, структуры каталогов и поведения private helper-а. Исправление начинается с выяснения потребности: нужна публичная операция или случайно найденная деталь? Если нужна операция, её оформляют именованным export с owner, входами, результатом и правилами совместимости. Если нужна деталь, consumer должен исчезнуть, а cache остаться у владельца.

\n
Диагностика по наблюдаемому симптому
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusтехнический слой интерпретирует доменкто меняет enum и labelвернуть mapping в billing, передать display data
Consumer импортирует /internal/*каталог приняли за APIесть ли стабильная операция за root exportдобавить reviewed export или убрать зависимость
Появился type-only importпроверяют bundle вместо исходной зависимостинайти import type, re-export и declarationоценить смысловую связь, а не только emitted code
Lint просит исключениеправило появилось раньше решения о границеназваны ли route, owner и альтернативаоформить adapter с ограниченным сроком
\n

Воспроизводимый пример: mapping до formatter

\n

Ниже синтетический пример запускается в Node.js без зависимостей. Billing выбирает подпись статуса, а formatter получает только минимальные данные. Команда с Node.js 12 и новее может скопировать команду целиком; --input-type=module явно задаёт режим для кода из standard input.

\n
node --input-type=module <<'EOF'\nconst invoice = { status: 'overdue', amountMinor: 12345, currencyCode: 'RUB' };\n\nconst view = {\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n  statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате',\n};\n\nconst formatMoney = ({ amountMinor, currencyCode, locale }) =>\n  new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode })\n    .format(amountMinor / 100);\n\nconsole.log(formatMoney({ ...view, locale: 'ru-RU' }) + ' — ' + view.statusLabel);\nEOF
\n

Ожидаемый результат — строка вида 123,45 ₽ — Просрочен. В разных версиях ICU и окружениях пробел перед символом валюты может отличаться, поэтому проверяйте смысл результата, а не сравнивайте байты вывода. Важнее другое: появление статуса disputed меняет mapping в billing, но не требует добавлять этот статус в formatter.

\n

Как задать public surface

\n

Для условного пакета @example/platform-formatting достаточно короткой записи: root — @example/platform-formatting, public names — formatMoney и formatDate, owner — команда форматирования, запрещённый consumer route — @example/platform-formatting/internal/*. Отдельно запишите запретное исходящее направление: utility не импортирует @example/billing-domain/*.

\n

Эта запись нужна до настройки lint. Она позволяет отличить нарушение от законного adapter-а. Adapter должен иметь собственное имя и owner, принимать узкую модель и иметь условие удаления, например отсутствие потребителей старого specifier в поиске по исходникам. Сам факт, что два пакета используют одинаковый helper, не делает helper общей абстракцией.

\n
{\n  "name": "@example/platform-formatting",\n  "exports": {\n    ".": "./dist/index.js",\n    "./format-date": "./dist/format-date.js"\n  }\n}
\n

Явный exports объявляет entry points пакета. Если ./internal/cache не перечислен, обычный импорт по имени пакета в поддерживаемом Node.js завершается ERR_PACKAGE_PATH_NOT_EXPORTED. Добавление exports в существующий пакет может стать breaking change, если consumers уже использовали неявные subpaths. Перед включением нужно перечислить прежние поддерживаемые точки входа и выбрать план миграции.

\n

Что проверяют Node.js, TypeScript и ESLint

\n

Node.js проверяет package surface при разрешении package specifier. Поле exports умеет ограничить main entry point и named subpaths, но не является сильной изоляцией: прямой абсолютный путь к файлу может обойти эту инкапсуляцию. Поэтому exports — контракт package resolver-а, а не защита от любого доступа к файловой системе.

\n

TypeScript в режимах node16 и nodenext моделирует различия ESM и CommonJS и учитывает package maps при соответствующей конфигурации. Это помогает приблизить type-check к реальному разрешению модулей. Компилятор всё равно не знает, кому принадлежит бизнес-смысл InvoiceStatus. Смысловую границу задаёт архитектурный договор.

\n

ESLint rule no-restricted-imports подходит для статических маршрутов. Для deep imports можно задать pattern и понятное сообщение:

\n
{\n  "rules": {\n    "no-restricted-imports": ["error", {\n      "patterns": [{\n        "group": ["@example/platform-formatting/internal/*"],\n        "message": "Используйте root API пакета."\n      }]\n    }]\n  }\n}
\n

Это правило действует на static import и не обещает проверить dynamic import(). Generated files, path aliases, re-export и loader registry нужно покрыть отдельными проверками. Не расширяйте pattern до всех shared-пакетов: такой запрет может блокировать законный интеграционный слой и заставить команду добавлять бессодержательные исключения.

\n

Порядок миграции

\n
  1. Запишите точный симптом: файл, module specifier и импортируемое имя. Формула «связность выросла» недостаточна для проверки.
  2. Назовите владельца смысла каждого доменного типа. Тот, кто меняет значения и правила, должен интерпретировать их.
  3. Разделите два направления: utility → domain и consumer → internal. Для них нужны разные исправления.
  4. Зафиксируйте root, public names, входы, выходы, owner и допустимых consumers.
  5. Проверьте обычные imports, re-exports, import type, dynamic import() и generated code в своей области.
  6. Настройте exports и режим разрешения TypeScript в соответствии с реально запускаемым runtime или bundler-ом.
  7. Добавьте узкий ESLint pattern и отрицательный тест, который ломается на запрещённом static import.
  8. Перенесите domain mapping обратно владельцу, а для legacy consumer-а создайте минимальный adapter с условием удаления.
  9. Повторите поиск запрещённых маршрутов и проверьте поведение public API после сборки.
\n

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

\n

Эта схема не делает пакеты независимыми автоматически. Generated clients, plugin systems, framework entry points и интеграционные adapters могут пересекать обычные слои. Для каждого исключения нужны назначенный owner, документированный маршрут и проверка, которая действительно охватывает этот способ загрузки.

\n

Поведение exports зависит от версии Node.js, package manager и bundler-а. TypeScript должен использовать режим, совместимый с запуском; иначе type-check и runtime могут разрешить разные пути. ESLint не строит полный граф зависимостей и не ловит dynamic import. Абсолютный путь может обойти package encapsulation. Поэтому корректный вывод звучит узко: «названный static route запрещён в заданном scope», а не «весь монорепозиторий не содержит утечек».

\n

Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, юридических формулировок, локализации и финансовой точности продукта. Реальный billing-контракт должен иметь собственные типы и тесты. При вводе exports в существующий пакет отдельно проверьте обратную совместимость прежних entry points.

\n

Граница готова, когда у каждого спорного import-а есть четыре ответа: кто владеет смыслом, какой route разрешён, чем запрещён обход и как проверяется поведение. Consumer импортирует root API, type-check и static guard проходят в поддерживаемой конфигурации, а временный adapter имеет условие удаления. Это проверяемый уровень контроля, а не обещание абсолютной изоляции.

\n

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

\n" }