Files

8 lines
21 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": 138,
"slug": "editorial-2024-03-practice-package-boundaries",
"title": "Границы пакетов: как закрыть deep import и не смешать домен с утилитой",
"excerpt": "Общий пакет начинает дорожать в сопровождении, когда его внутренние файлы становятся API, а доменные типы проникают в нейтральную утилиту. Разбираем контракт root entry point, проверку через exports, TypeScript и ESLint и безопасную миграцию существующих импортов.",
"contentHtml": "<p>Проблема проявляется не в момент создания пакета. Сначала formatter денег используют в billing и orders через один импорт. Потом в formatter добавляют условие для <code>InvoiceStatus</code>, а другой consumer берёт cache напрямую из <code>@example/platform-formatting/src/internal/cache.js</code>. Сборка ещё проходит, но любой рефакторинг внутреннего файла превращается в поиск неизвестных владельцев.</p><p>Цена ошибки состоит из двух разных зависимостей. Доменная модель попадает в общий пакет и заставляет его знать правила billing. Внутренний файл становится неявным API, хотя команда не обещала его сохранять. В результате изменение enum, очистка cache или переезд <code>src</code> затрагивают больше consumers, чем видно по публичному описанию пакета.</p><p>Решение начинается с короткого договора: какой module specifier разрешён, какие имена экспортируются, какие значения принимает функция и какие направления запрещены. Затем каждый инструмент проверяет свою часть договора. <code>exports</code> закрывает package subpaths при обычном разрешении Node.js, TypeScript согласует типы с resolver-ом, а ESLint ловит известные статические маршруты. Ни один из этих механизмов сам по себе не определяет, кому принадлежит бизнес-смысл.</p><h2>Симптом и граница ответственности</h2><p>Начните с конкретного edge — направленной зависимости между двумя пакетами. Зафиксируйте импортирующий файл, module specifier, imported name и владельца типа. Если <code>platform-formatting</code> импортирует <code>InvoiceStatus</code>, это не «технический тип без последствий». Даже type-only зависимость связывает словарь общего пакета с billing и меняет его публичный контракт.</p><p>Решение о том, что счёт просрочен, принадлежит billing. Formatter должен получить уже выбранные данные: сумму, код валюты и locale. Он не должен импортировать <code>Invoice</code>, выбирать подпись статуса или знать, что один из consumers показывает возвраты. Такая граница переносит изменение туда, где живёт правило, и оставляет общей утилите одну причину для изменения — представление значения.</p><table><caption>Диагностическая карта для одного package edge</caption><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Что это означает</th><th scope='col'>Проверка</th><th scope='col'>Исправление</th></tr></thead><tbody><tr><td>Утилита импортирует <code>InvoiceStatus</code></td><td>Общий слой интерпретирует доменную модель</td><td>Найти imported name и владельца типа</td><td>Выбрать статус в billing и передать нейтральные значения</td></tr><tr><td>Consumer импортирует <code>/src/</code> или <code>/internal/</code></td><td>Структура файлов стала неявным API</td><td>Сопоставить specifier с package contract</td><td>Перейти на root import или отдельно согласовать новый export</td></tr><tr><td>В <code>exports</code> добавляют весь каталог</td><td>Проверка заменена широким исключением</td><td>Посчитать реальные consumers и публичные имена</td><td>Описать точные subpaths либо оставить только <code>.</code></td></tr><tr><td>ESLint-правило молчит</td><td>Маршрут не попал в область его анализа или импорт динамический</td><td>Проверить resolver, pattern и форму импорта</td><td>Сузить утверждение либо добавить отдельную проверку графа</td></tr><tr><td>После добавления <code>exports</code> ломается старый consumer</td><td>Ранее поддерживался неописанный entry point</td><td>Сверить историю импортов и release notes пакета</td><td>Сначала экспортировать совместимый путь, затем объявить миграцию</td></tr></tbody></table><h2>Контракт public API</h2><p>Для небольшого shared-пакета достаточно одной записи, которую можно проверить в review. В ней должны быть root specifier, публичные имена, формы входа и выхода, владелец, разрешённые зависимости и запретные маршруты. Например: <code>@example/platform-formatting</code> экспортирует <code>formatMoney</code>; функция принимает <code>amount</code>, <code>currencyCode</code> и <code>locale</code>; пакет не импортирует <code>@example/billing-domain/*</code>; consumers не импортируют его <code>src/*</code>.</p><p>Не путайте публичное имя с файлом, в котором оно сейчас лежит. Внутри можно поменять <code>format-money.js</code> на несколько модулей, если root export и поведение функции остаются совместимыми. И наоборот: экспорт всего каталога делает каждое имя частью ожиданий consumers, даже если оно появилось как временный helper.</p><figure><img src='/assets/editorial/2024/package-boundaries-2024-package-graph.svg' alt='Учебный граф: orders и billing импортируют root API platform-formatting, пакет форматирования использует runtime, а запрещённая пунктирная стрелка ведёт от formatter к billing domain.' loading='lazy' /><figcaption>В учебной модели домен принимает решение и использует formatter, а formatter не тянет обратно доменный тип. Граф объясняет направление зависимости; он не является снимком конкретного production-репозитория.</figcaption></figure><h2>Что даёт package.json.exports</h2><p>В Node.js поле <code>exports</code> перечисляет entry points, доступные при обычном импорте пакета. Если оставить только точку <code>.</code>, попытка импортировать <code>@example/platform-formatting/src/internal/cache.js</code> должна завершиться ошибкой <code>ERR_PACKAGE_PATH_NOT_EXPORTED</code>. Это полезная машинная граница: переезд cache внутри пакета не обязан сохранять старый путь.</p><pre><code>{&#10; &quot;name&quot;: &quot;@example/platform-formatting&quot;,&#10; &quot;type&quot;: &quot;module&quot;,&#10; &quot;exports&quot;: {&#10; &quot;.&quot;: &quot;./src/index.js&quot;&#10; }&#10;}</code></pre><p>Файл <code>src/index.js</code> публикует только согласованные функции:</p><pre><code>export { formatMoney } from &quot;./format-money.js&quot;;</code></pre><p>После установки локального пакета положительный и отрицательный smoke-check можно повторить командами:</p><pre><code>node --input-type=module -e 'import(&quot;@example/platform-formatting&quot;).then(({ formatMoney }) =&gt; console.log(formatMoney({ amount: 1234.5, currencyCode: &quot;RUB&quot;, locale: &quot;ru-RU&quot; })))'&#10;node --input-type=module -e 'import(&quot;@example/platform-formatting/src/internal/cache.js&quot;).then(() =&gt; process.exit(1), error =&gt; { if (error.code !== &quot;ERR_PACKAGE_PATH_NOT_EXPORTED&quot;) process.exit(1); console.log(error.code); })'</code></pre><p>Первый вызов проверяет, что root API разрешается и возвращает функцию. Второй считает успехом именно ожидаемый отказ. Точный пробел или символ валюты в первой строке зависит от реализации <code>Intl.NumberFormat</code> и окружения; проверять следует контракт результата, а не копировать визуальную строку без оговорки.</p><p>У <code>exports</code> есть важные ограничения. Оно действует для package resolution, но не является защитой от прямого абсолютного доступа к файлу на диске. Оно также не исправляет consumer, который уже использует другой resolver или alias сборщика. Добавление поля в существующий пакет может быть breaking change: ранее неописанные entry points перестанут разрешаться. Перед включением нужно найти такие импорты и решить, какие из них действительно поддерживаются.</p><h2>Роль TypeScript и ESLint</h2><p>TypeScript 4.7 добавил режимы <code>node16</code> и <code>nodenext</code>. В этих режимах compiler учитывает модульную модель Node.js и поля <code>exports</code>/<code>imports</code> при разрешении пакетов. Это помогает получить одинаковую границу для исходников и деклараций, но не заменяет проверку runtime. Другой режим, path alias или отдельная конфигурация bundler-а могут дать отличающийся результат.</p><pre><code>{&#10; &quot;compilerOptions&quot;: {&#10; &quot;module&quot;: &quot;node16&quot;,&#10; &quot;moduleResolution&quot;: &quot;node16&quot;,&#10; &quot;strict&quot;: true&#10; }&#10;}</code></pre><p>ESLint подходит для статических запретов, которые можно сформулировать как маршруты. В legacy-конфигурации правило можно применить к двум сторонам границы:</p><pre><code>{&#10; &quot;overrides&quot;: [&#10; {&#10; &quot;files&quot;: [&quot;packages/platform-formatting/src/**/*.js&quot;],&#10; &quot;rules&quot;: {&#10; &quot;no-restricted-imports&quot;: [&quot;error&quot;, {&#10; &quot;patterns&quot;: [{&#10; &quot;group&quot;: [&quot;@example/billing-domain/*&quot;],&#10; &quot;message&quot;: &quot;formatter не импортирует billing domain&quot;&#10; }]&#10; }]&#10; }&#10; },&#10; {&#10; &quot;files&quot;: [&quot;packages/orders/**/*.js&quot;],&#10; &quot;rules&quot;: {&#10; &quot;no-restricted-imports&quot;: [&quot;error&quot;, {&#10; &quot;patterns&quot;: [{&#10; &quot;group&quot;: [&quot;@example/platform-formatting/src/*&quot;],&#10; &quot;message&quot;: &quot;используйте root API formatter&quot;&#10; }]&#10; }]&#10; }&#10; }&#10; ]&#10;}</code></pre><p>Здесь <code>paths</code> нужен для точного имени, а <code>patterns</code> — для группы путей с wildcard. Правило не строит полный runtime-граф: dynamic <code>import()</code>, загрузчик плагинов, generated code и обход через абсолютный путь требуют отдельной проверки. Type-only import тоже остаётся архитектурной связью. Разрешайте его исключением только тогда, когда это часть договора, а не способ спрятать доменную зависимость.</p><h2>Как мигрировать существующий пакет</h2><p>Сразу закрыть все старые пути в большом репозитории рискованно. Сначала составьте список imports по тексту и по инструменту, которым действительно собирается проект. Отдельно отметьте production-код, тесты, storybook, скрипты и generated files. У каждого найденного пути должен появиться статус: поддерживаемый root API, кандидат на отдельный export, временный adapter или ошибка.</p><p>Если в пакете уже есть consumers, не удаляйте их маршрут только потому, что он выглядит некрасиво. Для обратной совместимости можно временно экспортировать точно известный subpath, предупредить consumers и удалить его в следующем совместимом процессе. В новом пакете лучше начать с минимального списка. Публикация целого <code>src</code> редко является нейтральным компромиссом: она закрепляет внутреннюю структуру на будущее.</p><p>Доменную зависимость мигрируйте отдельным изменением. Сначала добавьте в consumer функцию, которая преобразует <code>InvoiceStatus</code> в собственную подпись. Затем передайте в formatter только нейтральные данные. После проверки consumers удалите импорт домена из utility. Такой порядок позволяет отличить изменение ответственности от изменения формата и легче откатить неудачный шаг.</p><h2>Порядок воспроизводимой проверки</h2><ol><li>Запишите один проблемный edge: файл, specifier, imported name, направление и владельца.</li><li>Найдите фактические consumers, включая тестовые и инструментальные конфигурации. Не считайте совпадение в документации импортом без проверки.</li><li>Сформулируйте API record с root entry point, публичными именами, входами, выходами и forbidden routes.</li><li>Перенесите решение о доменном состоянии к его владельцу и оставьте utility нейтральные значения.</li><li>Добавьте минимальный <code>exports</code> и положительный тест root import.</li><li>Добавьте отрицательный тест deep import и проверьте ожидаемый код ошибки в том же resolver-е, который использует приложение.</li><li>Настройте TypeScript и ESLint на согласованные маршруты; отдельно перечислите dynamic loading, aliases и generated layers, которые эти проверки не покрывают.</li><li>Проверьте сборку и тесты consumers, затем удалите временный adapter только после того, как список старых импортов стал пустым.</li></ol><p>Полезно сохранить два отрицательных теста рядом с контрактом: utility не может импортировать billing domain, consumer не может импортировать internal subpath. Положительный тест тоже обязателен. Запрет без рабочего root API только перенаправляет команду к новому обходному пути.</p><h2>Ограничения и критерий готовности</h2><p>Эта схема не доказывает отсутствие циклов, не проверяет семантическую совместимость версий и не измеряет размер bundle. Она не видит автоматически каждый alias, runtime plugin loader или абсолютный путь. Если сборщик не повторяет правила Node.js, результат smoke-check нужно получить именно через его resolver. Если пакет поддерживает CommonJS и ESM, проверяйте обе точки входа и не смешивайте их contract без явного решения.</p><p>Глобальный запрет на слово <code>domain</code> тоже не является архитектурой. Иногда отдельный adapter действительно должен пересекать слои. Зафиксируйте его владельца, разрешённый маршрут, причину и условие удаления. Временное исключение безопаснее, когда оно названо и наблюдаемо; молчаливый deep import просто переносит стоимость на следующий рефакторинг.</p><p>Граница готова, когда команда может ответить на пять вопросов без чтения внутреннего каталога: кто владелец пакета; какой root specifier обещан; какие имена и данные публичны; какие направления запрещены; каким инструментом проверяется каждый запрет. В репозитории есть рабочий root import, отрицательный тест для deep import и список известных исключений. Тогда изменение <code>InvoiceStatus</code> остаётся в billing, а изменение cache не требует обзванивать случайных consumers.</p><h2>Проверяемые источники</h2><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: Modules — Packages</a> — официальная документация о <code>exports</code>, entry points, subpath-ограничениях и пределе защиты от абсолютного пути.</li><li><a href='https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html' target='_blank' rel='noopener noreferrer'>TypeScript 4.7: ECMAScript Module Support in Node.js</a> — официальное описание режимов <code>node16</code>/<code>nodenext</code> и поддержки <code>exports</code>/<code>imports</code>.</li><li><a href='https://eslint.org/docs/latest/rules/no-restricted-imports' target='_blank' rel='noopener noreferrer'>ESLint: no-restricted-imports</a> — официальная документация для точных <code>paths</code>, групповых <code>patterns</code> и ограничений статической проверки.</li></ul>"
}