{ "index": 136, "slug": "editorial-2024-03-field-package-boundaries", "title": "Границы пакетов: как остановить утечку домена в общую utility", "excerpt": "Общая utility начинает ломать архитектуру задолго до падения сборки: она узнаёт доменные типы, а consumers обходят public API через internal-файлы. Разбираем симптомы, проверку границы и безопасные варианты исправления.", "contentHtml": "
Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.
\nТезис простой: граница пакета — это не каталог и не слово shared. Это проверяемый договор о том, какие имена доступны, кто владеет смыслом данных и какие пути запрещены. Если договор не записан, рабочий import постепенно становится частью API. Если договор записан, нарушение можно увидеть до релиза.
Рассмотрим учебный пример. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.
Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения нельзя лечить одной настройкой lint: у них разные владельцы и разные исправления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | Доменный смысл оказался в общем слое | Найти владельца enum и проследить, кто выбирает label | Оставить в formatter только primitive inputs; mapping вернуть в billing |
Consumer импортирует /internal/* | Public surface не описывает нужную операцию | Сверить specifier с package root и списком exports | Добавить осмысленный root export или убрать зависимость от cache |
| Никто не может назвать public names | Контракт существует только в соглашениях команды | Попросить owner указать root, имена и запретные маршруты | Создать короткую запись API с владельцем и сроком пересмотра |
| Предлагают сразу отключить правило | Инструмент подменяет архитектурное решение | Отделить допустимый adapter от случайного deep import | Сначала принять решение о границе, затем настроить static guard |
Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он начинает зависеть не от формы входа, а от причины, по которой вход существует.
У deep import другой механизм. Consumer перестаёт зависеть от обещанного поведения и начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета больше не может свободно поменять cache, разнести код по файлам или изменить стратегию invalidation. Даже если Node или bundler сегодня разрешает путь, это ещё не делает путь публичным.
\n// Учебный пример: домен выбирает смысл, utility форматирует данные. type InvoiceView = { amountMinor: number; currencyCode: string; statusLabel: string }; export function renderInvoice(view: InvoiceView, locale: string) { const amount = new Intl.NumberFormat(locale, { style: 'currency', currency: view.currencyCode }).format(view.amountMinor / 100); return `${amount} — ${view.statusLabel}`; } const view = { amountMinor: invoice.amountMinor, currencyCode: invoice.currencyCode, statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате' }; renderInvoice(view, 'ru-RU');\nВ примере statusLabel — осознанная граница. Billing меняет текст и правила статуса. Formatter получает данные, достаточные для форматирования, но не получает право расширять модель счёта. Это учебная иллюстрация, а не утверждение о конкретном production-коде.
Начните не с glob-паттерна, а со списка обещаний. Для условного пакета запись может выглядеть так:
\n// Учебная запись контракта, не готовая конфигурация проекта. const boundary = { root: '@example/platform-formatting', publicNames: ['formatMoney', 'formatDate'], forbidden: ['@example/platform-formatting/internal/*'], owner: 'formatting-team', reviewBy: '2026-09-01' };\nПоле root отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно экспортированного файла. forbidden показывает, что internal-пути не входят в обещание. Owner принимает изменения surface. Дата пересмотра нужна для временного adapter-а: без неё временная лазейка становится постоянной.
После этого можно выбрать техническую проверку. В Node поле exports задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем moduleResolution учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл InvoiceStatus и не доказывает, что dynamic loader соблюдает тот же договор.
Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.
\nAdapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать formatMoneyAdapter, пока команда мигрирует на новый root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.
Поле exports не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.
Static rule не видит все способы загрузки кода. Dynamic import(), generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.
Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.
\nРаботу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.
\nКритерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.
\nexports, public entry points и subpaths.moduleResolution.