8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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>{ "name": "@example/platform-formatting", "type": "module", "exports": { ".": "./src/index.js" } }</code></pre><p>Файл <code>src/index.js</code> публикует только согласованные функции:</p><pre><code>export { formatMoney } from "./format-money.js";</code></pre><p>После установки локального пакета положительный и отрицательный smoke-check можно повторить командами:</p><pre><code>node --input-type=module -e 'import("@example/platform-formatting").then(({ formatMoney }) => console.log(formatMoney({ amount: 1234.5, currencyCode: "RUB", locale: "ru-RU" })))' node --input-type=module -e 'import("@example/platform-formatting/src/internal/cache.js").then(() => process.exit(1), error => { if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") 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>{ "compilerOptions": { "module": "node16", "moduleResolution": "node16", "strict": true } }</code></pre><p>ESLint подходит для статических запретов, которые можно сформулировать как маршруты. В legacy-конфигурации правило можно применить к двум сторонам границы:</p><pre><code>{ "overrides": [ { "files": ["packages/platform-formatting/src/**/*.js"], "rules": { "no-restricted-imports": ["error", { "patterns": [{ "group": ["@example/billing-domain/*"], "message": "formatter не импортирует billing domain" }] }] } }, { "files": ["packages/orders/**/*.js"], "rules": { "no-restricted-imports": ["error", { "patterns": [{ "group": ["@example/platform-formatting/src/*"], "message": "используйте root API formatter" }] }] } } ] }</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>"
|
||
}
|