Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 136,
"slug": "editorial-2024-03-field-package-boundaries",
"title": "Границы пакетов: как остановить утечку домена в общую utility",
"excerpt": "Общая utility начинает ломать архитектуру задолго до падения сборки: она узнаёт доменные типы, а consumers обходят public API через internal-файлы. Разбираем симптомы, проверку границы и безопасные варианты исправления.",
"contentHtml": "<p>Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.</p>\n<p>Граница пакета — не каталог и не слово <code>shared</code>. Это договор о доступных именах, смысле данных и запрещённых путях. Его можно проверить в исходниках, настройках разрешения модулей и статическом анализаторе. Но сначала нужно решить, кто владеет смыслом. Инструмент способен поймать deep import, но не способен определить, кому принадлежит правило «просроченный счёт».</p>\n<h2>Симптомы и цена ошибки</h2>\n<p>Рассмотрим синтетический кейс, чтобы не выдавать учебную схему за отчёт о production-системе. Пакет <code>@example/platform-formatting</code> форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт <code>InvoiceStatus</code>, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует <code>createFormatterCache</code> из <code>@example/platform-formatting/internal/cache</code>, потому что корневой экспорт не дал нужную функцию.</p>\n<p>Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения связаны общей потерей договора, но исправляются по-разному: mapping статуса возвращается владельцу billing, а cache либо остаётся внутренним, либо получает отдельный осмысленный API.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Utility импортирует <code>InvoiceStatus</code></td><td>Доменный смысл оказался в общем слое</td><td>Найти владельца enum и того, кто выбирает label</td><td>Оставить в formatter primitive inputs; mapping вернуть в billing</td></tr><tr><td>Consumer импортирует <code>/internal/*</code></td><td>Файловое устройство приняли за public API</td><td>Сверить specifier с root export и списком <code>exports</code></td><td>Добавить reviewed root export или убрать зависимость от cache</td></tr><tr><td>Никто не может назвать public names</td><td>Контракт существует только в соглашениях команды</td><td>Попросить owner указать root, имена и запретные маршруты</td><td>Создать короткую API-запись с владельцем и сроком пересмотра</td></tr><tr><td>Предлагают сразу отключить lint</td><td>Инструмент подменяет архитектурное решение</td><td>Отделить допустимый adapter от случайного deep import</td><td>Сначала принять решение о границе, затем настроить guard</td></tr></tbody></table>\n<h2>Механизм: смысл остаётся у domain owner</h2>\n<p>Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает <code>InvoiceStatus</code>, он зависит уже не от формы входа, а от причины, по которой вход существует.</p>\n<p>Это правило действует и для type-only import. Такой импорт может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Formatter всё равно знает словарь billing. Полезная проверка здесь не «исчез ли тип из bundle», а «может ли владелец billing изменить набор статусов без изменения контракта общей utility».</p>\n<p>У deep import другой механизм. Consumer начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета уже не может свободно переименовать cache, изменить invalidation или разнести реализацию по файлам. То, что bundler сегодня разрешает путь, ещё не делает его публичным.</p>\n<figure><img src=\"/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg\" alt=\"Учебный маршрут проверки границы пакета: доменный тип в utility и deep import в internal ведут к разным действиям\" loading=\"lazy\"><figcaption>Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.</figcaption></figure>\n<h2>Воспроизводимый пример без доменной зависимости</h2>\n<p>Ниже функция запускается на Node.js без библиотек. Вход содержит сумму в минимальных единицах и код валюты. Смысл статуса выбирается до вызова formatter-а, поэтому utility не импортирует <code>InvoiceStatus</code>. Скопируйте одну команду в терминал: она печатает локализованную сумму и подпись статуса.</p>\n<pre><code>node -e &quot;const invoice={status:'overdue',amountMinor:12345,currencyCode:'RUB'}; const formatInvoice=(view,locale='ru-RU')=&gt;{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))&quot;</code></pre>\n<p>Вызов <code>Intl.NumberFormat</code> отвечает только за представление числа и валюты. Поле <code>statusLabel</code> подготовил код billing. Если появится состояние <code>disputed</code>, меняется mapping домена; сигнатура formatter-а не обязана узнавать об этом состоянии. Это демонстрация границы, а не утверждение о конкретном репозитории.</p>\n<h2>Как описать public API</h2>\n<p>Начните со списка обещаний, а не с glob-паттерна. Для условного пакета запись может выглядеть так:</p>\n<pre><code>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'};</code></pre>\n<p><code>root</code> отвечает на вопрос, откуда импортировать. <code>publicNames</code> отделяет API от случайно доступного файла. <code>forbiddenConsumerRoutes</code> и <code>forbiddenUtilityTargets</code> показывают два разных направления запрета. Owner принимает изменения surface, а <code>reviewBy</code> не даёт временному adapter-у стать постоянной лазейкой.</p>\n<p>В настоящем проекте эту запись нужно связать с конкретным package entry point и тестом. Не называйте public-ом весь каталог только потому, что один consumer уже нашёл в нём helper. Публикуйте операцию с понятными входами, выходом, lifetime и правилами совместимости.</p>\n<h2>Что именно проверяют Node, TypeScript и ESLint</h2>\n<p>Поле <code>exports</code> в <code>package.json</code> задаёт доступные entry points и subpaths при обычном разрешении package specifier. Если <code>./internal/cache</code> не перечислен, импорт через имя пакета должен быть отклонён Node как неэкспортированный subpath. Это ограничивает package surface, но не отвечает за доменную архитектуру. Локальный абсолютный путь, особый loader или generated code требуют отдельной проверки.</p>\n<pre><code>{&quot;name&quot;:&quot;@example/platform-formatting&quot;,&quot;exports&quot;:{&quot;.&quot;:&quot;./dist/index.js&quot;,&quot;./package.json&quot;:&quot;./package.json&quot;}}</code></pre>\n<p>TypeScript в режимах <code>node16</code>, <code>nodenext</code> и поддерживаемом проектом <code>bundler</code> сопоставляет разрешение модулей с package maps. Это уменьшает расхождение между type-check и запуском, если compiler и runtime настроены согласованно. Сам компилятор не знает, что <code>InvoiceStatus</code> принадлежит billing и не должен попадать в utility.</p>\n<p>ESLint с правилом <code>no-restricted-imports</code> подходит для названных статических маршрутов. Можно запретить consumer-ам <code>@example/platform-formatting/internal/*</code>, а utility — импорты <code>@example/billing-domain/*</code>. Правило должно предлагать легальную альтернативу. Оно не строит полный граф dynamic <code>import()</code>, generated files и runtime plugin loading, поэтому область его обещания нужно написать рядом с конфигурацией.</p>\n<h2>Порядок диагностики и исправления</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите точный module specifier, imported name и файл-источник из diff или заявки. Не заменяйте их формулой «пакеты сильно связаны».</li><li><strong>Назовите владельца смысла.</strong> Для каждого типа спросите, кто меняет его значения и правила отображения. Если ответ — billing, не переносите enum в formatting.</li><li><strong>Разделите направления.</strong> Отметьте, где utility зависит от domain, а где consumer зависит от internal. Это две записи и два решения, даже если они находятся в одном diff.</li><li><strong>Сверьте public record.</strong> Проверьте root specifier, разрешённое имя, запретный subpath, версии runtime и способ разрешения модулей.</li><li><strong>Выберите узкое действие.</strong> Верните mapping владельцу домена, добавьте reviewed root export, создайте named adapter или отклоните deep import. Не экспортируйте cache только ради совместимости.</li><li><strong>Проверьте отрицательный путь.</strong> Убедитесь, что неизвестное имя, internal subpath и новый доменный import отклоняются выбранным guard-ом. Отдельно проверьте type-only imports, re-exports, dynamic imports и generated code, если они есть.</li><li><strong>Повторите проверку.</strong> Запустите type check и lint в поддерживаемой конфигурации, проверьте consumer через root API и сравните public surface до и после изменения.</li></ol>\n<h2>Cache, adapter и постепенная миграция</h2>\n<p>Если cache нужен только formatter-у, consumer должен вызывать публичную функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите стабильную операцию в reviewed export и опишите входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.</p>\n<p>Adapter допустим, когда у него есть владелец и срок жизни. Старый consumer может временно вызывать <code>formatMoneyAdapter</code>, пока команда переходит на root API. Adapter не должен открывать весь <code>internal</code>. Его surface должен быть меньше исходной детали, а условие удаления — измеримым: например, поиск запрещённого specifier больше не находит потребителей.</p>\n<p>Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у и передайте formatter-у primitive или display data. Если consumer использует internal path, найдите требуемую операцию. При отсутствии стабильной семантики удалите зависимость и оставьте cache деталью владельца.</p>\n<h2>Ограничения применимости</h2>\n<p>Эта схема не делает пакеты независимыми автоматически. Внутренние и внешние пакеты имеют разные semver-обязательства. Legacy consumers могут требовать переходный слой. Generated clients, plugin systems и framework entry points могут законно пересекать обычные слои. Для них нужны явный маршрут, owner и отдельная проверка.</p>\n<p><code>exports</code> зависит от версии Node, bundler-а и способа потребления пакета. TypeScript может разрешить типы в одной конфигурации, а runtime — разрешить их иначе. Static rule не доказывает отсутствие dynamic загрузки. Поэтому результат нужно формулировать узко: «названный static import из заданного scope запрещён», а не «в репозитории больше нет domain leak».</p>\n<p>Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, доступности, юридических формулировок и финансовой точности конкретного продукта. Для реального billing-кода эти правила должны принадлежать доменному контракту и иметь собственные тесты.</p>\n<h2>Критерий готовности</h2>\n<p>Границу можно принять, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Consumer использует root API. Type check и static guard проходят в поддерживаемой toolchain. Для временного adapter-а записаны owner, срок удаления и сигнал, по которому его можно удалить.</p>\n<p>Критерий не требует доказать, что весь монорепозиторий свободен от доменных утечек. Он требует доказать одну согласованную границу на конкретном import-е: показать diff, проверку разрешения модуля, отрицательный тест и owner решения. Это ограничение делает вывод честным и оставляет команде воспроизводимый следующий шаг.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://nodejs.org/api/packages.html#package-entry-points\" target=\"_blank\" rel=\"noopener\">Node.js: Package entry points</a> — официальное описание <code>exports</code>, public entry points и subpaths.</li><li><a href=\"https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports\" target=\"_blank\" rel=\"noopener\">TypeScript: Modules Reference, package.json exports</a> — связь package maps и режимов разрешения модулей.</li><li><a href=\"https://eslint.org/docs/latest/rules/no-restricted-imports\" target=\"_blank\" rel=\"noopener\">ESLint: no-restricted-imports</a> — официальное описание ограничений для static imports.</li></ul>"
}