8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 138,
|
||
"slug": "editorial-2024-03-practice-package-boundaries",
|
||
"title": "Границы пакетов: как не превратить общую утилиту в скрытую платформу",
|
||
"excerpt": "Общая утилита становится дорогой в сопровождении, когда принимает доменные типы и открывает внутренние файлы. Разбираем короткий public API, запретные направления, проверку и безопасный путь исправления.",
|
||
"contentHtml": "<p>Проблема часто начинается с безобидного изменения. Formatter денег уже используется в billing и orders. В него добавляют условие для <code>InvoiceStatus</code>, чтобы рядом с суммой вывести «Просрочен». Другой consumer импортирует внутренний cache по пути <code>platform-formatting/internal/cache</code>. Сборка проходит. Симптом появляется позже: изменение enum требует правки общей утилиты, очистка cache ломает consumer, а reviewer не может отличить обещанный API от случайного файла.</p><p>Цена ошибки — не одна лишняя зависимость. Доменная модель проникает в пакет, который считали нейтральным. Владелец billing начинает влиять на форматтер, владелец форматтера — на orders. Любой рефакторинг проходит через большее число команд. Ошибку труднее локализовать. Откат затрагивает код, который изначально не должен был знать друг о друге.</p><p><strong>Тезис:</strong> граница пакета начинается с короткого договора, а не с папки и не с конфигурации линтера. Договор называет root specifier, публичные имена, входы, выходы и запрещённые направления. Инструменты затем проверяют отдельные части договора. Они не принимают архитектурное решение вместо владельца пакета.</p><h2>Что именно считать границей</h2><p>Пакет содержит больше кода, чем обещает. Внутри могут лежать cache, fallback для locale, адаптеры и тестовые helpers. Consumer должен видеть только root entry point и имена, которые команда готова поддерживать. Любой другой импорт превращает текущую структуру файлов в неявный контракт.</p><p>Доменная граница проходит по смыслу данных. <code>amountMinor</code>, <code>currencyCode</code> и <code>locale</code> описывают вход для форматирования. <code>InvoiceStatus</code>, лимит возврата и правило просрочки описывают billing. Если formatter принимает <code>Invoice</code>, он получает право интерпретировать чужую модель. Если formatter импортирует <code>InvoiceStatus</code> даже только как тип, зависимость остаётся: исходный код и декларации начинают отражать billing-словарь.</p><p>Правильный consumer сначала принимает решение у себя, затем передаёт утилите нейтральные данные. Billing может превратить статус в свою подпись, а formatter — отформатировать сумму. Так изменение статуса остаётся у владельца billing. Общий пакет меняет только правила представления чисел, валюты и даты.</p><h2>Симптом → причина → проверка → действие</h2><table><caption>Учебная карта диагностики package boundary</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>Посмотреть imported name и владельца типа</td><td>Вернуть выбор статуса в billing и передать primitive values</td></tr><tr><td>Consumer импортирует <code>/internal/*</code></td><td>Public API не назван или оказался слишком мал</td><td>Сверить specifier с API record</td><td>Убрать deep import либо открыть отдельный reviewed export</td></tr><tr><td>Новый export добавляют «на всякий случай»</td><td>Поверхность пакета растёт без владельца</td><td>Проверить consumer, семантику и срок поддержки</td><td>Оставить только нужное имя и зафиксировать owner</td></tr><tr><td>Lint rule разрешает всё через исключение</td><td>Инструмент скрывает неясное архитектурное решение</td><td>Назвать точный allowed route и причину исключения</td><td>Сузить правило или создать именованный adapter</td></tr><tr><td>Надеются на один механизм</td><td><code>exports</code>, TypeScript и lint смешали в одно обещание</td><td>Проверить область действия каждого слоя</td><td>Разделить package surface, module resolution и policy</td></tr></tbody></table><h2>Пример: от доменного типа к нейтральному API</h2><p>Ниже приведён учебный пример. Имена billing и platform-formatting вымышлены. Код показывает форму границы, а не состояние конкретного проекта.</p><pre><code>// Учебный пример: billing владеет смыслом статуса.\nconst displayData = {\n statusLabel: invoice.status === \"overdue\" ? \"Просрочен\" : \"Открыт\",\n amountMinor: invoice.amountMinor,\n currencyCode: invoice.currencyCode,\n};\n\n// Общая функция получает только данные для форматирования.\nconst amountLabel = formatMoney({\n amountMinor: displayData.amountMinor,\n currencyCode: displayData.currencyCode,\n locale: \"ru-RU\",\n});</code></pre><p>Плохой вариант смешивает оба решения: formatter сам импортирует InvoiceStatus, выбирает подпись и форматирует деньги. Такой код может быть короче, но граница становится неясной. Хороший вариант не запрещает переиспользование. Он оставляет каждому пакету один вид ответственности.</p><h2>Три разных механизма</h2><p>Node.js <code>package.json</code> с полем <code>exports</code> описывает доступные entry points при обычном импорте пакета. Это полезно для surface и совместимости. Неэкспортированный subpath перестаёт быть обычной частью package API. Но <code>exports</code> не является защитой от любого прямого обращения к файлу. Он также не знает, что InvoiceStatus относится к billing и потому не должен попадать в formatter.</p><p>TypeScript в режимах <code>node16</code> и <code>nodenext</code> учитывает <code>exports</code>, <code>imports</code>, self-reference и различия ESM/CJS. Compiler помогает согласовать типы с module resolution. Он не решает вопрос владения бизнес-смыслом. Корректный type-check не делает доменную зависимость хорошей.</p><p>ESLint <code>no-restricted-imports</code> подходит для названных статических маршрутов. Можно запретить consumer-ам <code>@synthetic/platform-formatting/internal/*</code>, а utility — импорты <code>@synthetic/billing-domain/*</code>. Это узкая проверка синтаксиса. Она не строит полный граф dynamic import, generated code и runtime plugin loading. В policy нужно обещать только то, что выбранное правило действительно видит.</p><figure><img src=\"/assets/editorial/2024/package-boundaries-2024-package-graph.svg\" alt=\"Учебный граф границ пакетов: orders и billing используют root API formatting, а formatting не импортирует billing domain; запрещённое направление отмечено красной пунктирной стрелкой.\" loading=\"lazy\" /><figcaption>Иллюстрация показывает направление зависимостей в учебной модели. Она не является скриншотом и не доказывает граф какого-либо production-приложения.</figcaption></figure><h2>Как поставить границу в существующем коде</h2><p>Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.</p><p>Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.</p><h2>Порядок действий</h2><ol><li>Зафиксируйте точный module specifier, imported name и слой, где находится зависимость.</li><li>Назначьте владельца каждого типа и функции. Если владелец не найден, не расширяйте API.</li><li>Составьте короткий API record: root specifier, public names, входы, выходы и forbidden routes.</li><li>Разделите domain decision и formatting. Перенесите интерпретацию модели обратно к её owner-у.</li><li>Удалите deep import. Если consumer не может работать через root API, проведите отдельный review нового export-а или adapter-а.</li><li>Настройте exports, TypeScript resolution или ESLint только для тех правил, которые уже согласованы.</li><li>Проверьте положительный и отрицательный пути: допустимый root import проходит, доменный import и internal subpath получают понятный отказ.</li></ol><h2>Проверка на учебной модели</h2><p>Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый root import, utility с доменным импортом и consumer с deep import. Такой тест полезен, если он явно называет свои границы. Он проверяет классификацию записанных примеров. Он не читает репозиторий, не строит настоящий import graph и не доказывает состояние CI.</p><pre><code>const boundary = {\n publicSpecifier: \"@synthetic/platform-formatting\",\n publicNames: [\"formatMoney\", \"formatIsoDate\"],\n forbiddenConsumerRoutes: [\"@synthetic/platform-formatting/internal/*\"],\n forbiddenUtilityTargets: [\"@synthetic/billing-domain/*\"],\n};\n\n// Проверяемый учебный результат:\n// clean root import - compliant\n// utility -> billing - violated\n// consumer -> internal - violated</code></pre><p>Если такой пример называют проверкой проекта, он вводит в заблуждение. Для реального edge нужны согласованная область чтения, выбранный resolver, учёт aliases и generated layers, затем отдельная проверка toolchain. Учебная модель не заменяет эти шаги.</p><h2>Ограничения</h2><p>Эта схема не делает пакеты независимыми автоматически. Она не измеряет размер bundle, не доказывает отсутствие циклов, не проверяет семантическую совместимость всех версий и не описывает dynamic loading. В legacy-коде может потребоваться временный adapter. У adapter-а должны быть владелец, разрешённый маршрут и дата удаления исключения.</p><p>Глобальный запрет тоже опасен. Framework entry point, generated client и plugin adapter могут законно пересекать слои. Важно назвать роль такого пакета. Запрещайте не слово domain, а конкретное направление для конкретного owner-а. Иначе команда начнёт отключать правило вместо исправления зависимости.</p><h2>Критерий готовности</h2><p>Работа готова, когда для одного выбранного package можно ответить на пять вопросов без поиска по всему репозиторию: кто owner; какой root specifier обещан; какие имена и входы публичны; какие направления запрещены; чем проверяется каждый запрет. Положительный тест импортирует только root API. Отрицательные тесты показывают отказ для domain leak и deep import. Проверка явно указывает, какие пути она не покрывает.</p><p>После этого изменение InvoiceStatus не требует знания внутренностей formatter-а, а изменение 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: Packages</a> — официальная документация о поле exports, package entry points и пределах encapsulation.</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> — официальные release notes о режимах node16, nodenext, exports и imports.</li><li><a href=\"https://eslint.org/docs/latest/rules/no-restricted-imports\" target=\"_blank\" rel=\"noopener noreferrer\">ESLint: no-restricted-imports</a> — официальная документация правила для ограничения статических импортов.</li></ul>"
|
||
}
|