{ "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 заставляет искать неизвестных потребителей.
У этой ситуации две разные причины. Доменный тип переносит в общую утилиту смысл, которым владеет billing. Deep import превращает расположение файла в обещание для consumer-а. Лечить их одним запретом нельзя. Сначала нужно описать public API, затем поставить подходящие технические проверки и явно оставить места, где связь допустима.
\nПакет может содержать больше, чем он обещает. Внутри formatter-а могут жить cache key, fallback локали и адаптер к библиотеке дат. Consumer должен знать root specifier, публичные имена, формат входа и результата. Если он импортирует внутренний файл, любое переименование реализации становится изменением чужого контракта.
\nГраница отвечает не только на вопрос «откуда импортировать». Она фиксирует владельца смысла. Общая функция может превратить число в строку валюты, но не должна решать, что статус счёта означает «просрочен». Это решение принадлежит billing. Передавать нужно примитивы или готовую модель отображения, а не весь объект домена.
\n| Слой | Что проверяет | Чего не доказывает | Практический вопрос |
|---|---|---|---|
| Boundary record | root, public names, входы, выходы и owner | что runtime действительно разрешает только эти маршруты | Кто принимает изменение surface? |
package.json exports | доступные package entry points и subpaths | доменную корректность и абсолютные обходы | Какой bare specifier разрешён? |
| TypeScript resolution | сопоставление module resolution с runtime или bundler | право utility знать чужую бизнес-модель | Одинаково ли разрешаются типы и запуск? |
| ESLint restriction | названные статические import routes | dynamic import, generated code и полный граф | Какой запрет должен сработать на diff? |
Утечка домена видна по ответу на вопрос «кто меняет этот факт?». Если набор значений InvoiceStatus меняет billing-команда, formatter не должен импортировать enum даже через import type. Type-only import может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Значит, shared-пакет всё равно связан со словарём billing.
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 с ограниченным сроком |
Ниже синтетический пример запускается в Node.js без зависимостей. Billing выбирает подпись статуса, а formatter получает только минимальные данные. Команда с Node.js 12 и новее может скопировать команду целиком; --input-type=module явно задаёт режим для кода из standard input.
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.
Для условного пакета @example/platform-formatting достаточно короткой записи: root — @example/platform-formatting, public names — formatMoney и formatDate, owner — команда форматирования, запрещённый consumer route — @example/platform-formatting/internal/*. Отдельно запишите запретное исходящее направление: utility не импортирует @example/billing-domain/*.
Эта запись нужна до настройки 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. Перед включением нужно перечислить прежние поддерживаемые точки входа и выбрать план миграции.
Node.js проверяет package surface при разрешении package specifier. Поле exports умеет ограничить main entry point и named subpaths, но не является сильной изоляцией: прямой абсолютный путь к файлу может обойти эту инкапсуляцию. Поэтому exports — контракт package resolver-а, а не защита от любого доступа к файловой системе.
TypeScript в режимах node16 и nodenext моделирует различия ESM и CommonJS и учитывает package maps при соответствующей конфигурации. Это помогает приблизить type-check к реальному разрешению модулей. Компилятор всё равно не знает, кому принадлежит бизнес-смысл InvoiceStatus. Смысловую границу задаёт архитектурный договор.
ESLint rule no-restricted-imports подходит для статических маршрутов. Для deep imports можно задать pattern и понятное сообщение:
{\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-пакетов: такой запрет может блокировать законный интеграционный слой и заставить команду добавлять бессодержательные исключения.
import type, dynamic import() и generated code в своей области.exports и режим разрешения TypeScript в соответствии с реально запускаемым runtime или bundler-ом.Эта схема не делает пакеты независимыми автоматически. 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», а не «весь монорепозиторий не содержит утечек».
Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, юридических формулировок, локализации и финансовой точности продукта. Реальный billing-контракт должен иметь собственные типы и тесты. При вводе exports в существующий пакет отдельно проверьте обратную совместимость прежних entry points.
Граница готова, когда у каждого спорного import-а есть четыре ответа: кто владеет смыслом, какой route разрешён, чем запрещён обход и как проверяется поведение. Consumer импортирует root API, type-check и static guard проходят в поддерживаемой конфигурации, а временный adapter имеет условие удаления. Это проверяемый уровень контроля, а не обещание абсолютной изоляции.
\n