Files
progcode/editorial/agent-rewrites/137.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
14 KiB
JSON
Raw 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": "Границы пакетов: как не превратить 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>"
}