Files

8 lines
19 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &lt;&lt;&#39;EOF&#39;\nconst invoice = { status: &#39;overdue&#39;, amountMinor: 12345, currencyCode: &#39;RUB&#39; };\n\nconst view = {\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n statusLabel: invoice.status === &#39;overdue&#39; ? &#39;Просрочен&#39; : &#39;К оплате&#39;,\n};\n\nconst formatMoney = ({ amountMinor, currencyCode, locale }) =&gt;\n new Intl.NumberFormat(locale, { style: &#39;currency&#39;, currency: currencyCode })\n .format(amountMinor / 100);\n\nconsole.log(formatMoney({ ...view, locale: &#39;ru-RU&#39; }) + &#39; — &#39; + view.statusLabel);\nEOF</code></pre>\n<p>Ожидаемый результат — строка вида <code>123,45&nbsp;₽ — Просрочен</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 &quot;name&quot;: &quot;@example/platform-formatting&quot;,\n &quot;exports&quot;: {\n &quot;.&quot;: &quot;./dist/index.js&quot;,\n &quot;./format-date&quot;: &quot;./dist/format-date.js&quot;\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 &quot;rules&quot;: {\n &quot;no-restricted-imports&quot;: [&quot;error&quot;, {\n &quot;patterns&quot;: [{\n &quot;group&quot;: [&quot;@example/platform-formatting/internal/*&quot;],\n &quot;message&quot;: &quot;Используйте root API пакета.&quot;\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>"
}