Files
progcode/editorial/agent-rewrites/138.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
18 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": 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>"
}