{ "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. Общий пакет меняет только правила представления чисел, валюты и даты.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
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 |
Ниже приведён учебный пример. Имена 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 нужно обещать только то, что выбранное правило действительно видит.
Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.
Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.
Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый 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. Граница не запрещает развитие пакета. Она делает цену нового знания видимой до того, как оно станет общей платформой.