{ "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, но не способен определить, кому принадлежит правило «просроченный счёт».

\n

Симптомы и цена ошибки

\n

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

\n

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

Механизм: смысл остаётся у domain owner

\n

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

\n

Это правило действует и для type-only import. Такой импорт может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Formatter всё равно знает словарь billing. Полезная проверка здесь не «исчез ли тип из bundle», а «может ли владелец billing изменить набор статусов без изменения контракта общей utility».

\n

У deep import другой механизм. Consumer начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета уже не может свободно переименовать cache, изменить invalidation или разнести реализацию по файлам. То, что bundler сегодня разрешает путь, ещё не делает его публичным.

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

Воспроизводимый пример без доменной зависимости

\n

Ниже функция запускается на Node.js без библиотек. Вход содержит сумму в минимальных единицах и код валюты. Смысл статуса выбирается до вызова formatter-а, поэтому utility не импортирует InvoiceStatus. Скопируйте одну команду в терминал: она печатает локализованную сумму и подпись статуса.

\n
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-а не обязана узнавать об этом состоянии. Это демонстрация границы, а не утверждение о конкретном репозитории.

\n

Как описать public API

\n

Начните со списка обещаний, а не с glob-паттерна. Для условного пакета запись может выглядеть так:

\n
const boundary={root:'@example/platform-formatting',publicNames:['formatMoney','formatDate'],forbiddenConsumerRoutes:['@example/platform-formatting/internal/*'],forbiddenUtilityTargets:['@example/billing-domain/*'],owner:'formatting-team',reviewBy:'2026-09-30'};
\n

root отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно доступного файла. forbiddenConsumerRoutes и forbiddenUtilityTargets показывают два разных направления запрета. Owner принимает изменения surface, а reviewBy не даёт временному adapter-у стать постоянной лазейкой.

\n

В настоящем проекте эту запись нужно связать с конкретным package entry point и тестом. Не называйте public-ом весь каталог только потому, что один consumer уже нашёл в нём helper. Публикуйте операцию с понятными входами, выходом, lifetime и правилами совместимости.

\n

Что именно проверяют Node, TypeScript и ESLint

\n

Поле exports в package.json задаёт доступные entry points и subpaths при обычном разрешении package specifier. Если ./internal/cache не перечислен, импорт через имя пакета должен быть отклонён Node как неэкспортированный subpath. Это ограничивает package surface, но не отвечает за доменную архитектуру. Локальный абсолютный путь, особый loader или generated code требуют отдельной проверки.

\n
{"name":"@example/platform-formatting","exports":{".":"./dist/index.js","./package.json":"./package.json"}}
\n

TypeScript в режимах node16, nodenext и поддерживаемом проектом bundler сопоставляет разрешение модулей с package maps. Это уменьшает расхождение между type-check и запуском, если compiler и runtime настроены согласованно. Сам компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility.

\n

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

\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. Не экспортируйте cache только ради совместимости.
  6. Проверьте отрицательный путь. Убедитесь, что неизвестное имя, internal subpath и новый доменный import отклоняются выбранным guard-ом. Отдельно проверьте type-only imports, re-exports, dynamic imports и generated code, если они есть.
  7. Повторите проверку. Запустите type check и lint в поддерживаемой конфигурации, проверьте consumer через root API и сравните public surface до и после изменения.
\n

Cache, adapter и постепенная миграция

\n

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

\n

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

\n

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

\n

Ограничения применимости

\n

Эта схема не делает пакеты независимыми автоматически. Внутренние и внешние пакеты имеют разные semver-обязательства. Legacy consumers могут требовать переходный слой. Generated clients, plugin systems и framework entry points могут законно пересекать обычные слои. Для них нужны явный маршрут, owner и отдельная проверка.

\n

exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript может разрешить типы в одной конфигурации, а runtime — разрешить их иначе. Static rule не доказывает отсутствие dynamic загрузки. Поэтому результат нужно формулировать узко: «названный static import из заданного scope запрещён», а не «в репозитории больше нет domain leak».

\n

Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, доступности, юридических формулировок и финансовой точности конкретного продукта. Для реального billing-кода эти правила должны принадлежать доменному контракту и иметь собственные тесты.

\n

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

\n

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

\n

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

\n

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

" }