{ "index": 138, "slug": "editorial-2024-03-practice-package-boundaries", "title": "Границы пакетов: как не превратить общую утилиту в скрытую платформу", "excerpt": "Общая утилита становится дорогой в сопровождении, когда принимает доменные типы и открывает внутренние файлы. Разбираем короткий public API, запретные направления, проверку и безопасный путь исправления.", "contentHtml": "

Проблема часто начинается с безобидного изменения. Formatter денег уже используется в billing и orders. В него добавляют условие для InvoiceStatus, чтобы рядом с суммой вывести «Просрочен». Другой consumer импортирует внутренний cache по пути platform-formatting/internal/cache. Сборка проходит. Симптом появляется позже: изменение enum требует правки общей утилиты, очистка cache ломает consumer, а reviewer не может отличить обещанный API от случайного файла.

Цена ошибки — не одна лишняя зависимость. Доменная модель проникает в пакет, который считали нейтральным. Владелец billing начинает влиять на форматтер, владелец форматтера — на orders. Любой рефакторинг проходит через большее число команд. Ошибку труднее локализовать. Откат затрагивает код, который изначально не должен был знать друг о друге.

Тезис: граница пакета начинается с короткого договора, а не с папки и не с конфигурации линтера. Договор называет root specifier, публичные имена, входы, выходы и запрещённые направления. Инструменты затем проверяют отдельные части договора. Они не принимают архитектурное решение вместо владельца пакета.

Что именно считать границей

Пакет содержит больше кода, чем обещает. Внутри могут лежать cache, fallback для locale, адаптеры и тестовые helpers. Consumer должен видеть только root entry point и имена, которые команда готова поддерживать. Любой другой импорт превращает текущую структуру файлов в неявный контракт.

Доменная граница проходит по смыслу данных. amountMinor, currencyCode и locale описывают вход для форматирования. InvoiceStatus, лимит возврата и правило просрочки описывают billing. Если formatter принимает Invoice, он получает право интерпретировать чужую модель. Если formatter импортирует InvoiceStatus даже только как тип, зависимость остаётся: исходный код и декларации начинают отражать billing-словарь.

Правильный consumer сначала принимает решение у себя, затем передаёт утилите нейтральные данные. Billing может превратить статус в свою подпись, а formatter — отформатировать сумму. Так изменение статуса остаётся у владельца billing. Общий пакет меняет только правила представления чисел, валюты и даты.

Симптом → причина → проверка → действие

Учебная карта диагностики package boundary
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusДоменная интерпретация вошла в общий пакетПосмотреть imported name и владельца типаВернуть выбор статуса в billing и передать primitive values
Consumer импортирует /internal/*Public API не назван или оказался слишком малСверить specifier с API recordУбрать deep import либо открыть отдельный reviewed export
Новый export добавляют «на всякий случай»Поверхность пакета растёт без владельцаПроверить consumer, семантику и срок поддержкиОставить только нужное имя и зафиксировать owner
Lint rule разрешает всё через исключениеИнструмент скрывает неясное архитектурное решениеНазвать точный allowed route и причину исключенияСузить правило или создать именованный adapter
Надеются на один механизмexports, TypeScript и lint смешали в одно обещаниеПроверить область действия каждого слояРазделить package surface, module resolution и policy

Пример: от доменного типа к нейтральному API

Ниже приведён учебный пример. Имена billing и platform-formatting вымышлены. Код показывает форму границы, а не состояние конкретного проекта.

// Учебный пример: 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});

Плохой вариант смешивает оба решения: formatter сам импортирует InvoiceStatus, выбирает подпись и форматирует деньги. Такой код может быть короче, но граница становится неясной. Хороший вариант не запрещает переиспользование. Он оставляет каждому пакету один вид ответственности.

Три разных механизма

Node.js package.json с полем exports описывает доступные entry points при обычном импорте пакета. Это полезно для surface и совместимости. Неэкспортированный subpath перестаёт быть обычной частью package API. Но exports не является защитой от любого прямого обращения к файлу. Он также не знает, что InvoiceStatus относится к billing и потому не должен попадать в formatter.

TypeScript в режимах node16 и nodenext учитывает exports, imports, self-reference и различия ESM/CJS. Compiler помогает согласовать типы с module resolution. Он не решает вопрос владения бизнес-смыслом. Корректный type-check не делает доменную зависимость хорошей.

ESLint no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Это узкая проверка синтаксиса. Она не строит полный граф dynamic import, generated code и runtime plugin loading. В policy нужно обещать только то, что выбранное правило действительно видит.

\"Учебный
Иллюстрация показывает направление зависимостей в учебной модели. Она не является скриншотом и не доказывает граф какого-либо production-приложения.

Как поставить границу в существующем коде

Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.

Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.

Порядок действий

  1. Зафиксируйте точный module specifier, imported name и слой, где находится зависимость.
  2. Назначьте владельца каждого типа и функции. Если владелец не найден, не расширяйте API.
  3. Составьте короткий API record: root specifier, public names, входы, выходы и forbidden routes.
  4. Разделите domain decision и formatting. Перенесите интерпретацию модели обратно к её owner-у.
  5. Удалите deep import. Если consumer не может работать через root API, проведите отдельный review нового export-а или adapter-а.
  6. Настройте exports, TypeScript resolution или ESLint только для тех правил, которые уже согласованы.
  7. Проверьте положительный и отрицательный пути: допустимый root import проходит, доменный import и internal subpath получают понятный отказ.

Проверка на учебной модели

Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый root import, utility с доменным импортом и consumer с deep import. Такой тест полезен, если он явно называет свои границы. Он проверяет классификацию записанных примеров. Он не читает репозиторий, не строит настоящий import graph и не доказывает состояние CI.

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

Если такой пример называют проверкой проекта, он вводит в заблуждение. Для реального edge нужны согласованная область чтения, выбранный resolver, учёт aliases и generated layers, затем отдельная проверка toolchain. Учебная модель не заменяет эти шаги.

Ограничения

Эта схема не делает пакеты независимыми автоматически. Она не измеряет размер bundle, не доказывает отсутствие циклов, не проверяет семантическую совместимость всех версий и не описывает dynamic loading. В legacy-коде может потребоваться временный adapter. У adapter-а должны быть владелец, разрешённый маршрут и дата удаления исключения.

Глобальный запрет тоже опасен. Framework entry point, generated client и plugin adapter могут законно пересекать слои. Важно назвать роль такого пакета. Запрещайте не слово domain, а конкретное направление для конкретного owner-а. Иначе команда начнёт отключать правило вместо исправления зависимости.

Критерий готовности

Работа готова, когда для одного выбранного package можно ответить на пять вопросов без поиска по всему репозиторию: кто owner; какой root specifier обещан; какие имена и входы публичны; какие направления запрещены; чем проверяется каждый запрет. Положительный тест импортирует только root API. Отрицательные тесты показывают отказ для domain leak и deep import. Проверка явно указывает, какие пути она не покрывает.

После этого изменение InvoiceStatus не требует знания внутренностей formatter-а, а изменение cache не заставляет искать случайных consumers. Граница не запрещает развитие пакета. Она делает цену нового знания видимой до того, как оно станет общей платформой.

Проверяемые источники

" }