{ "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. Если договор записан, нарушение можно увидеть до релиза.

\n

Два симптома одной потери договора

\n

Рассмотрим учебный пример. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.

\n

Первый 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
\n

Механизм: смысл движется вверх, детали — вниз

\n

Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он начинает зависеть не от формы входа, а от причины, по которой вход существует.

\n

У 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-коде.

\n

Как назвать public API

\n

Начните не с 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-а: без неё временная лазейка становится постоянной.

\n

После этого можно выбрать техническую проверку. В Node поле exports задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем moduleResolution учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл InvoiceStatus и не доказывает, что dynamic loader соблюдает тот же договор.

\n
\"Учебный
Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Запишите точный module specifier и imported name из diff или заявки. Не заменяйте его формулой «пакеты сильно связаны».
  2. Назовите владельца смысла. Для каждого типа спросите, кто меняет его значения и правила отображения. Если ответ — billing, не переносите enum в formatting.
  3. Разделите направления. Отметьте, где utility зависит от domain, а где consumer зависит от internal. Это две записи и два решения, даже если они находятся в одном diff.
  4. Сверьте public record. Проверьте root specifier, разрешённое имя, запретный subpath, версии runtime и способ разрешения модулей.
  5. Выберите действие. Верните mapping владельцу домена, добавьте reviewed root export, создайте named adapter или отклоните deep import. Не оставляйте «разрешить пока» без даты.
  6. Проверьте отрицательный путь. Убедитесь, что неизвестное имя, internal subpath и новый доменный import действительно отклоняются выбранным guard-ом. Отдельно проверьте dynamic imports и generated code, если они есть.
  7. Повторите проверку после изменения. Сравните public surface до и после, запустите type check и lint в поддерживаемой конфигурации, затем проверьте потребителя. Synthetic пример не заменяет чтение реального графа.
\n

Что делать с cache и adapter-ом

\n

Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.

\n

Adapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать formatMoneyAdapter, пока команда мигрирует на новый root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.

\n

Ограничения и отрицательный путь

\n

Поле exports не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.

\n

Static rule не видит все способы загрузки кода. Dynamic import(), generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.

\n

Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.

\n

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

\n

Работу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.

\n

Критерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.

\n

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

" }