8 lines
14 KiB
JSON
8 lines
14 KiB
JSON
{
|
||
"index": 137,
|
||
"slug": "editorial-2024-03-mechanism-package-boundaries",
|
||
"title": "Границы пакетов: как не превратить shared-утилиту в скрытую платформу",
|
||
"excerpt": "Рабочая схема для пакета, который начинает знать чужую доменную модель: минимальный public API, запрет внутренних импортов, проверка маршрута зависимости и честные ограничения инструментов.",
|
||
"contentHtml": "<p>Ошибка обычно начинается с проходящего импорта. Formatter получает <code>InvoiceStatus</code>, чтобы вывести подпись рядом с суммой. Другой consumer берёт cache по пути <code>platform-formatting/internal/cache</code>, потому что так короче. Сборка проходит, TypeScript не спорит, autocomplete подсказывает нужный путь. Цена появляется позже: изменение billing enum требует выпуска formatter-а, чистка cache ломает consumer-а, а владелец зависимости неизвестен. Небольшой пакет перестаёт меняться изолированно.</p>\n<p>Тезис простой: границу пакета нельзя поручить одному инструменту. Сначала команда описывает public API. Затем runtime и компилятор ограничивают видимые точки входа. После этого статическое правило ловит запрещённые направления. Каждый слой проверяет свою часть договора. <code>exports</code> не заменяет архитектурное решение, TypeScript не определяет смысл доменной зависимости, а lint не видит весь динамический граф.</p>\n<h2>Механизм границы</h2>\n<p>Пакет может содержать больше, чем обещает. Внутри formatter-а допустимы cache key, fallback locale и адаптер к библиотеке дат. Consumer должен видеть root specifier и небольшой набор имён. Если consumer импортирует внутренний файл, устройство каталогов превращается в публичный контракт. Если utility импортирует доменный enum, она получает чужое правило принятия решений.</p>\n<p>Type-only import не отменяет границу. Такой импорт может исчезнуть из JavaScript, но останется в исходном коде и в декларациях. Formatter всё равно знает язык billing. Поэтому проверка «в bundle нет billing» отвечает не на тот вопрос. Нужно спросить: может ли владелец billing изменить статус, не меняя контракт общей утилиты?</p>\n<table><caption>Три уровня защиты границы</caption><thead><tr><th>Уровень</th><th>Проверяет</th><th>Не доказывает</th><th>Действие</th></tr></thead><tbody><tr><td>Public API record</td><td>разрешённые specifier, имена, входы, выходы и owner</td><td>реальное разрешение модулей</td><td>зафиксировать смысл договора</td></tr><tr><td><code>package.json</code> <code>exports</code></td><td>доступные package entry points</td><td>отсутствие абсолютных обходов и доменную политику</td><td>сузить surface для поддерживаемого runtime</td></tr><tr><td>TypeScript resolution</td><td>согласованное разрешение <code>imports</code>/<code>exports</code> и формата модулей</td><td>право utility знать чужую модель</td><td>синхронизировать compiler и runtime</td></tr><tr><td>ESLint restriction</td><td>названные статические import routes</td><td>dynamic import и полный граф</td><td>закодировать узкий запрет с альтернативой</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2024/package-boundaries-2024-public-api-table.svg\" alt=\"Схема границы package: root API с formatMoney и formatIsoDate, запрещённые InvoiceStatus и internal subpath\"><figcaption>Учебная схема: root API принимает примитивные данные, а доменный тип и внутренний subpath находятся за границей. Иллюстрация не описывает настоящий registry или production-пакет.</figcaption></figure>\n<h2>Пример: вернуть смысл владельцу домена</h2>\n<p>Рассмотрим синтетический пакет <code>@synthetic/platform-formatting</code>. Он форматирует деньги и даты. Billing хочет показывать особый текст для просроченного счёта. Плохой путь передаёт в formatter весь invoice или импортирует <code>InvoiceStatus</code>. Тогда форматирование решает бизнес-вопрос. Новый статус становится изменением shared package.</p>\n<p>Безопаснее сначала получить display model на стороне billing. Formatter принимает только данные, которые ему нужны для отображения. Пример учебный: он не доказывает работу настоящего приложения и не является рекомендацией менять конкретный репозиторий.</p>\n<pre><code>// Синтетический пример. Billing владеет интерпретацией статуса.\nconst display = {\n statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n};\n\n// Общая утилита получает только форматируемые значения.\nconst amountLabel = formatMoney({\n amountMinor: display.amountMinor,\n currencyCode: display.currencyCode,\n locale: 'ru-RU',\n});</code></pre>\n<p>У consumer-а остаётся один публичный маршрут: <code>@synthetic/platform-formatting</code>. В record можно записать <code>formatMoney</code> и <code>formatIsoDate</code> как public names, а <code>internal/*</code> и доменные импорты — как запрещённые направления. Если функция действительно нужна нескольким пакетам, её добавляют в root API с owner, входами, выходами и планом совместимости. Deep import не становится API только потому, что он уже используется.</p>\n<h2>Как связать договор и инструменты</h2>\n<p>Поле <code>exports</code> в <code>package.json</code> помогает объявить entry points. Resolver видит перечисленные subpath, а не случайные файлы каталога. Это полезная граница package surface. Но абсолютный путь к файлу может обойти такую инкапсуляцию. Значит, <code>exports</code> не является security boundary и не доказывает отсутствие плохих зависимостей.</p>\n<p>TypeScript в режимах <code>node16</code> и <code>nodenext</code> учитывает модель Node и package maps. Это уменьшает расхождение между проверкой типов и запуском. Но компилятор не знает, что <code>InvoiceStatus</code> принадлежит billing и не должен попадать в utility. Смысловой запрет остаётся задачей контракта и политики.</p>\n<p>ESLint можно настроить на конкретные маршруты. Запретите consumer-ам <code>@synthetic/platform-formatting/internal/*</code>, а utility — импорты <code>@synthetic/billing-domain/*</code>. Сообщение должно предлагать root API или adapter. Правило должно быть узким: общий запрет «не импортировать домены» может заблокировать законный интеграционный слой.</p>\n<pre><code>/* Учебная политика ESLint, не готовая конфигурация проекта. */\n'no-restricted-imports': ['error', {\n patterns: [{\n group: ['@synthetic/platform-formatting/internal/*'],\n message: 'Используйте root API пакета.',\n }],\n}]</code></pre>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Utility импортирует доменный тип</td><td>смысл статуса не принадлежит utility, но public API не описан</td><td>кто меняет enum и кто меняет форматирование</td><td>перенести интерпретацию в domain owner, передать primitive/display data</td></tr><tr><td>Consumer импортирует <code>/internal</code></td><td>файловое устройство приняли за контракт</td><td>есть ли стабильная семантика и root export</td><td>убрать deep import или оформить отдельный public export</td></tr><tr><td>Lint rule просит исключение</td><td>правило появилось раньше архитектурного решения</td><td>названы ли адресат, route и легальная альтернатива</td><td>сначала записать boundary record, затем настроить guard</td></tr><tr><td>Сборка чистая, но coupling растёт</td><td>проверяется emitted code, а не исходный import graph</td><td>найти type-only, re-export и dynamic edges отдельно</td><td>добавить статические проверки и ручной review исключений</td></tr></tbody></table>\n<h2>Порядок действий</h2>\n<ol><li>Выберите один пакет и назовите его роль. Не начинайте с общей папки <code>shared</code>.</li><li>Запишите root specifier, public names, входы, выходы, owner и допустимых consumers.</li><li>Отметьте внутренние subpath и доменные факты, которые пакет не должен интерпретировать.</li><li>Проверьте реальные import routes: обычный import, re-export, type-only import и dynamic import.</li><li>Настройте <code>exports</code> и compiler resolution только в поддерживаемой toolchain.</li><li>Добавьте узкие ESLint restrictions с понятной альтернативой.</li><li>Разберите каждое исключение отдельно. Для adapter укажите владельца и срок удаления.</li><li>Проверьте public API тестом поведения и повторите поиск запрещённых маршрутов.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Схема не делает пакеты независимыми автоматически. Adapter-ы, generated clients, plugin systems и framework entry points могут законно пересекать слои. Для них нужен явный маршрут и owner. Статический lint не описывает runtime registry и не ловит все вызовы <code>import()</code>. <code>exports</code> зависит от версии Node, bundler-а и способа потребления пакета. TypeScript resolution должен совпадать с тем, что реально запускает приложение.</p>\n<p>Не выдавайте учебный пример за аудит. В этой статье нет утверждения о production-результатах, размере bundle, CI или состоянии конкретного репозитория. Проверять нужно область, toolchain и импортный граф, а затем отдельно проверять поведение root API.</p>\n<p>Граница готова, если любой новый import можно классифицировать без чтения всего пакета: он входит в public API, нарушает названное правило или проходит через документированный adapter. Для выбранного пакета должны быть записаны owner и root API; команда должна получить диагностическое сообщение на запрещённый static import; тест public API должен пройти; поиск по исходникам не должен находить неразрешённые deep imports. Это проверяемый критерий, а не обещание абсолютной изоляции.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://nodejs.org/api/packages.html\" target=\"_blank\" rel=\"noopener\">Node.js: Packages и поле <code>exports</code></a></li><li><a href=\"https://www.typescriptlang.org/tsconfig/moduleResolution.html\" target=\"_blank\" rel=\"noopener\">TypeScript: moduleResolution</a></li><li><a href=\"https://eslint.org/docs/latest/rules/no-restricted-imports\" target=\"_blank\" rel=\"noopener\">ESLint: no-restricted-imports</a></li></ul>"
|
||
}
|