{ "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. Это договор о доступных именах, смысле данных и запрещённых путях. Его можно проверить в исходниках, настройках разрешения модулей и статическом анализаторе. Но сначала нужно решить, кто владеет смыслом. Инструмент способен поймать deep import, но не способен определить, кому принадлежит правило «просроченный счёт».
Рассмотрим синтетический кейс, чтобы не выдавать учебную схему за отчёт о production-системе. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.
Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения связаны общей потерей договора, но исправляются по-разному: mapping статуса возвращается владельцу billing, а cache либо остаётся внутренним, либо получает отдельный осмысленный API.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | Доменный смысл оказался в общем слое | Найти владельца enum и того, кто выбирает label | Оставить в formatter primitive inputs; mapping вернуть в billing |
Consumer импортирует /internal/* | Файловое устройство приняли за public API | Сверить specifier с root export и списком exports | Добавить reviewed root export или убрать зависимость от cache |
| Никто не может назвать public names | Контракт существует только в соглашениях команды | Попросить owner указать root, имена и запретные маршруты | Создать короткую API-запись с владельцем и сроком пересмотра |
| Предлагают сразу отключить lint | Инструмент подменяет архитектурное решение | Отделить допустимый adapter от случайного deep import | Сначала принять решение о границе, затем настроить guard |
Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он зависит уже не от формы входа, а от причины, по которой вход существует.
Это правило действует и для type-only import. Такой импорт может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Formatter всё равно знает словарь billing. Полезная проверка здесь не «исчез ли тип из bundle», а «может ли владелец billing изменить набор статусов без изменения контракта общей utility».
\nУ deep import другой механизм. Consumer начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета уже не может свободно переименовать cache, изменить invalidation или разнести реализацию по файлам. То, что bundler сегодня разрешает путь, ещё не делает его публичным.
\nНиже функция запускается на Node.js без библиотек. Вход содержит сумму в минимальных единицах и код валюты. Смысл статуса выбирается до вызова formatter-а, поэтому utility не импортирует InvoiceStatus. Скопируйте одну команду в терминал: она печатает локализованную сумму и подпись статуса.
node -e "const invoice={status:'overdue',amountMinor:12345,currencyCode:'RUB'}; const formatInvoice=(view,locale='ru-RU')=>{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'?'Просрочен':'К оплате'}; console.log(formatInvoice(view))"\nВызов Intl.NumberFormat отвечает только за представление числа и валюты. Поле statusLabel подготовил код billing. Если появится состояние disputed, меняется mapping домена; сигнатура formatter-а не обязана узнавать об этом состоянии. Это демонстрация границы, а не утверждение о конкретном репозитории.
Начните со списка обещаний, а не с glob-паттерна. Для условного пакета запись может выглядеть так:
\nconst boundary={root:'@example/platform-formatting',publicNames:['formatMoney','formatDate'],forbiddenConsumerRoutes:['@example/platform-formatting/internal/*'],forbiddenUtilityTargets:['@example/billing-domain/*'],owner:'formatting-team',reviewBy:'2026-09-30'};\nroot отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно доступного файла. forbiddenConsumerRoutes и forbiddenUtilityTargets показывают два разных направления запрета. Owner принимает изменения surface, а reviewBy не даёт временному adapter-у стать постоянной лазейкой.
В настоящем проекте эту запись нужно связать с конкретным package entry point и тестом. Не называйте public-ом весь каталог только потому, что один consumer уже нашёл в нём helper. Публикуйте операцию с понятными входами, выходом, lifetime и правилами совместимости.
\nПоле exports в package.json задаёт доступные entry points и subpaths при обычном разрешении package specifier. Если ./internal/cache не перечислен, импорт через имя пакета должен быть отклонён Node как неэкспортированный subpath. Это ограничивает package surface, но не отвечает за доменную архитектуру. Локальный абсолютный путь, особый loader или generated code требуют отдельной проверки.
{"name":"@example/platform-formatting","exports":{".":"./dist/index.js","./package.json":"./package.json"}}\nTypeScript в режимах node16, nodenext и поддерживаемом проектом bundler сопоставляет разрешение модулей с package maps. Это уменьшает расхождение между type-check и запуском, если compiler и runtime настроены согласованно. Сам компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility.
ESLint с правилом no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @example/platform-formatting/internal/*, а utility — импорты @example/billing-domain/*. Правило должно предлагать легальную альтернативу. Оно не строит полный граф dynamic import(), generated files и runtime plugin loading, поэтому область его обещания нужно написать рядом с конфигурацией.
Если cache нужен только formatter-у, consumer должен вызывать публичную функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите стабильную операцию в reviewed export и опишите входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.
\nAdapter допустим, когда у него есть владелец и срок жизни. Старый consumer может временно вызывать formatMoneyAdapter, пока команда переходит на root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а условие удаления — измеримым: например, поиск запрещённого specifier больше не находит потребителей.
Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у и передайте formatter-у primitive или display data. Если consumer использует internal path, найдите требуемую операцию. При отсутствии стабильной семантики удалите зависимость и оставьте cache деталью владельца.
\nЭта схема не делает пакеты независимыми автоматически. Внутренние и внешние пакеты имеют разные semver-обязательства. Legacy consumers могут требовать переходный слой. Generated clients, plugin systems и framework entry points могут законно пересекать обычные слои. Для них нужны явный маршрут, owner и отдельная проверка.
\nexports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript может разрешить типы в одной конфигурации, а runtime — разрешить их иначе. Static rule не доказывает отсутствие dynamic загрузки. Поэтому результат нужно формулировать узко: «названный static import из заданного scope запрещён», а не «в репозитории больше нет domain leak».
Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, доступности, юридических формулировок и финансовой точности конкретного продукта. Для реального billing-кода эти правила должны принадлежать доменному контракту и иметь собственные тесты.
\nГраницу можно принять, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Consumer использует root API. Type check и static guard проходят в поддерживаемой toolchain. Для временного adapter-а записаны owner, срок удаления и сигнал, по которому его можно удалить.
\nКритерий не требует доказать, что весь монорепозиторий свободен от доменных утечек. Он требует доказать одну согласованную границу на конкретном import-е: показать diff, проверку разрешения модуля, отрицательный тест и owner решения. Это ограничение делает вывод честным и оставляет команде воспроизводимый следующий шаг.
\nexports, public entry points и subpaths.