8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 137,
|
||
"slug": "editorial-2024-03-mechanism-package-boundaries",
|
||
"title": "Границы пакетов: как отделить public API от внутренностей",
|
||
"excerpt": "Публичный API пакета — это договор о маршрутах импорта, данных и владельце смысла. Разбираем, как обнаружить утечку домена, ограничить deep import и не перепутать возможности Node.js, TypeScript и ESLint.",
|
||
"contentHtml": "<p>Сбой границы редко начинается с красной сборки. Сначала formatter получает <code>InvoiceStatus</code>, чтобы вывести подпись рядом с суммой. Затем другой consumer импортирует cache по пути <code>platform-formatting/internal/cache</code>, потому что так короче. TypeScript не возражает, autocomplete подсказывает путь, тесты проходят. Цена появляется при следующем изменении: новый статус требует выпуска formatter-а, а переименование cache заставляет искать неизвестных потребителей.</p>\n<p>У этой ситуации две разные причины. Доменный тип переносит в общую утилиту смысл, которым владеет billing. Deep import превращает расположение файла в обещание для consumer-а. Лечить их одним запретом нельзя. Сначала нужно описать public API, затем поставить подходящие технические проверки и явно оставить места, где связь допустима.</p>\n<h2>Сначала договор, потом инструменты</h2>\n<p>Пакет может содержать больше, чем он обещает. Внутри formatter-а могут жить cache key, fallback локали и адаптер к библиотеке дат. Consumer должен знать root specifier, публичные имена, формат входа и результата. Если он импортирует внутренний файл, любое переименование реализации становится изменением чужого контракта.</p>\n<p>Граница отвечает не только на вопрос «откуда импортировать». Она фиксирует владельца смысла. Общая функция может превратить число в строку валюты, но не должна решать, что статус счёта означает «просрочен». Это решение принадлежит billing. Передавать нужно примитивы или готовую модель отображения, а не весь объект домена.</p>\n<table><caption>Четыре слоя, которые нельзя подменять друг другом</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Что проверяет</th><th scope=\"col\">Чего не доказывает</th><th scope=\"col\">Практический вопрос</th></tr></thead><tbody><tr><td>Boundary record</td><td>root, public names, входы, выходы и owner</td><td>что runtime действительно разрешает только эти маршруты</td><td>Кто принимает изменение surface?</td></tr><tr><td><code>package.json</code> <code>exports</code></td><td>доступные package entry points и subpaths</td><td>доменную корректность и абсолютные обходы</td><td>Какой bare specifier разрешён?</td></tr><tr><td>TypeScript resolution</td><td>сопоставление module resolution с runtime или bundler</td><td>право utility знать чужую бизнес-модель</td><td>Одинаково ли разрешаются типы и запуск?</td></tr><tr><td>ESLint restriction</td><td>названные статические import routes</td><td>dynamic import, generated code и полный граф</td><td>Какой запрет должен сработать на diff?</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2024/package-boundaries-2024-public-api-table.svg\" alt=\"Схема границы пакета: consumer обращается к root API с formatMoney и formatIsoDate, а InvoiceStatus и internal cache остаются за пределами public surface\" loading=\"lazy\"><figcaption>Схема показывает учебное правило направления: consumer использует root API, а доменный тип и внутренний cache не становятся частью обещанного surface. Названия synthetic-пакета не описывают конкретный registry.</figcaption></figure>\n<h2>Две утечки, два решения</h2>\n<p>Утечка домена видна по ответу на вопрос «кто меняет этот факт?». Если набор значений <code>InvoiceStatus</code> меняет billing-команда, formatter не должен импортировать enum даже через <code>import type</code>. Type-only import может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Значит, shared-пакет всё равно связан со словарём billing.</p>\n<p>Deep import имеет другую цену. Consumer зависит от имени файла, структуры каталогов и поведения private helper-а. Исправление начинается с выяснения потребности: нужна публичная операция или случайно найденная деталь? Если нужна операция, её оформляют именованным export с owner, входами, результатом и правилами совместимости. Если нужна деталь, consumer должен исчезнуть, а cache остаться у владельца.</p>\n<table><caption>Диагностика по наблюдаемому симптому</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Utility импортирует <code>InvoiceStatus</code></td><td>технический слой интерпретирует домен</td><td>кто меняет enum и label</td><td>вернуть mapping в billing, передать display data</td></tr><tr><td>Consumer импортирует <code>/internal/*</code></td><td>каталог приняли за API</td><td>есть ли стабильная операция за root export</td><td>добавить reviewed export или убрать зависимость</td></tr><tr><td>Появился type-only import</td><td>проверяют bundle вместо исходной зависимости</td><td>найти import type, re-export и declaration</td><td>оценить смысловую связь, а не только emitted code</td></tr><tr><td>Lint просит исключение</td><td>правило появилось раньше решения о границе</td><td>названы ли route, owner и альтернатива</td><td>оформить adapter с ограниченным сроком</td></tr></tbody></table>\n<h2>Воспроизводимый пример: mapping до formatter</h2>\n<p>Ниже синтетический пример запускается в Node.js без зависимостей. Billing выбирает подпись статуса, а formatter получает только минимальные данные. Команда с Node.js 12 и новее может скопировать команду целиком; <code>--input-type=module</code> явно задаёт режим для кода из standard input.</p>\n<pre><code>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</code></pre>\n<p>Ожидаемый результат — строка вида <code>123,45 ₽ — Просрочен</code>. В разных версиях ICU и окружениях пробел перед символом валюты может отличаться, поэтому проверяйте смысл результата, а не сравнивайте байты вывода. Важнее другое: появление статуса <code>disputed</code> меняет mapping в billing, но не требует добавлять этот статус в formatter.</p>\n<h2>Как задать public surface</h2>\n<p>Для условного пакета <code>@example/platform-formatting</code> достаточно короткой записи: root — <code>@example/platform-formatting</code>, public names — <code>formatMoney</code> и <code>formatDate</code>, owner — команда форматирования, запрещённый consumer route — <code>@example/platform-formatting/internal/*</code>. Отдельно запишите запретное исходящее направление: utility не импортирует <code>@example/billing-domain/*</code>.</p>\n<p>Эта запись нужна до настройки lint. Она позволяет отличить нарушение от законного adapter-а. Adapter должен иметь собственное имя и owner, принимать узкую модель и иметь условие удаления, например отсутствие потребителей старого specifier в поиске по исходникам. Сам факт, что два пакета используют одинаковый helper, не делает helper общей абстракцией.</p>\n<pre><code>{\n "name": "@example/platform-formatting",\n "exports": {\n ".": "./dist/index.js",\n "./format-date": "./dist/format-date.js"\n }\n}</code></pre>\n<p>Явный <code>exports</code> объявляет entry points пакета. Если <code>./internal/cache</code> не перечислен, обычный импорт по имени пакета в поддерживаемом Node.js завершается <code>ERR_PACKAGE_PATH_NOT_EXPORTED</code>. Добавление <code>exports</code> в существующий пакет может стать breaking change, если consumers уже использовали неявные subpaths. Перед включением нужно перечислить прежние поддерживаемые точки входа и выбрать план миграции.</p>\n<h2>Что проверяют Node.js, TypeScript и ESLint</h2>\n<p>Node.js проверяет package surface при разрешении package specifier. Поле <code>exports</code> умеет ограничить main entry point и named subpaths, но не является сильной изоляцией: прямой абсолютный путь к файлу может обойти эту инкапсуляцию. Поэтому <code>exports</code> — контракт package resolver-а, а не защита от любого доступа к файловой системе.</p>\n<p>TypeScript в режимах <code>node16</code> и <code>nodenext</code> моделирует различия ESM и CommonJS и учитывает package maps при соответствующей конфигурации. Это помогает приблизить type-check к реальному разрешению модулей. Компилятор всё равно не знает, кому принадлежит бизнес-смысл <code>InvoiceStatus</code>. Смысловую границу задаёт архитектурный договор.</p>\n<p>ESLint rule <code>no-restricted-imports</code> подходит для статических маршрутов. Для deep imports можно задать pattern и понятное сообщение:</p>\n<pre><code>{\n "rules": {\n "no-restricted-imports": ["error", {\n "patterns": [{\n "group": ["@example/platform-formatting/internal/*"],\n "message": "Используйте root API пакета."\n }]\n }]\n }\n}</code></pre>\n<p>Это правило действует на static <code>import</code> и не обещает проверить dynamic <code>import()</code>. Generated files, path aliases, re-export и loader registry нужно покрыть отдельными проверками. Не расширяйте pattern до всех shared-пакетов: такой запрет может блокировать законный интеграционный слой и заставить команду добавлять бессодержательные исключения.</p>\n<h2>Порядок миграции</h2>\n<ol><li>Запишите точный симптом: файл, module specifier и импортируемое имя. Формула «связность выросла» недостаточна для проверки.</li><li>Назовите владельца смысла каждого доменного типа. Тот, кто меняет значения и правила, должен интерпретировать их.</li><li>Разделите два направления: utility → domain и consumer → internal. Для них нужны разные исправления.</li><li>Зафиксируйте root, public names, входы, выходы, owner и допустимых consumers.</li><li>Проверьте обычные imports, re-exports, <code>import type</code>, dynamic <code>import()</code> и generated code в своей области.</li><li>Настройте <code>exports</code> и режим разрешения TypeScript в соответствии с реально запускаемым runtime или bundler-ом.</li><li>Добавьте узкий ESLint pattern и отрицательный тест, который ломается на запрещённом static import.</li><li>Перенесите domain mapping обратно владельцу, а для legacy consumer-а создайте минимальный adapter с условием удаления.</li><li>Повторите поиск запрещённых маршрутов и проверьте поведение public API после сборки.</li></ol>\n<h2>Ограничения применимости и критерий готовности</h2>\n<p>Эта схема не делает пакеты независимыми автоматически. Generated clients, plugin systems, framework entry points и интеграционные adapters могут пересекать обычные слои. Для каждого исключения нужны назначенный owner, документированный маршрут и проверка, которая действительно охватывает этот способ загрузки.</p>\n<p>Поведение <code>exports</code> зависит от версии Node.js, package manager и bundler-а. TypeScript должен использовать режим, совместимый с запуском; иначе type-check и runtime могут разрешить разные пути. ESLint не строит полный граф зависимостей и не ловит dynamic import. Абсолютный путь может обойти package encapsulation. Поэтому корректный вывод звучит узко: «названный static route запрещён в заданном scope», а не «весь монорепозиторий не содержит утечек».</p>\n<p>Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, юридических формулировок, локализации и финансовой точности продукта. Реальный billing-контракт должен иметь собственные типы и тесты. При вводе <code>exports</code> в существующий пакет отдельно проверьте обратную совместимость прежних entry points.</p>\n<p>Граница готова, когда у каждого спорного import-а есть четыре ответа: кто владеет смыслом, какой route разрешён, чем запрещён обход и как проверяется поведение. Consumer импортирует root API, type-check и static guard проходят в поддерживаемой конфигурации, а временный adapter имеет условие удаления. Это проверяемый уровень контроля, а не обещание абсолютной изоляции.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://nodejs.org/download/release/v20.11.1/docs/api/packages.html\" target=\"_blank\" rel=\"noopener noreferrer\">Node.js v20.11.1: Packages, exports и инкапсуляция subpaths</a></li><li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript 4.7: Node.js ESM, node16/nodenext и package.json exports</a></li><li><a href=\"https://www.typescriptlang.org/docs/handbook/modules/reference.html\" target=\"_blank\" rel=\"noopener noreferrer\">TypeScript Modules Reference: moduleResolution и соответствие runtime или bundler</a></li><li><a href=\"https://eslint.org/docs/latest/rules/no-restricted-imports\" target=\"_blank\" rel=\"noopener noreferrer\">ESLint: no-restricted-imports, patterns и ограничение static imports</a></li><li><a href=\"https://eslint.org/blog/2023/12/eslint-v8.55.0-released/\" target=\"_blank\" rel=\"noopener noreferrer\">ESLint v8.55.0: importNamePattern в no-restricted-imports</a></li></ul>"
|
||
}
|