Files
progcode/editorial/agent-rewrites/136.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
17 KiB
JSON
Raw 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>. Это проверяемый договор о том, какие имена доступны, кто владеет смыслом данных и какие пути запрещены. Если договор не записан, рабочий import постепенно становится частью API. Если договор записан, нарушение можно увидеть до релиза.</p>\n<h2>Два симптома одной потери договора</h2>\n<p>Рассмотрим учебный пример. Пакет <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-у. Эти нарушения нельзя лечить одной настройкой lint: у них разные владельцы и разные исправления.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</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 surface не описывает нужную операцию</td><td>Сверить specifier с package root и списком exports</td><td>Добавить осмысленный root export или убрать зависимость от cache</td></tr><tr><td>Никто не может назвать public names</td><td>Контракт существует только в соглашениях команды</td><td>Попросить owner указать root, имена и запретные маршруты</td><td>Создать короткую запись API с владельцем и сроком пересмотра</td></tr><tr><td>Предлагают сразу отключить правило</td><td>Инструмент подменяет архитектурное решение</td><td>Отделить допустимый adapter от случайного deep import</td><td>Сначала принять решение о границе, затем настроить static guard</td></tr></tbody></table>\n<h2>Механизм: смысл движется вверх, детали — вниз</h2>\n<p>Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает <code>InvoiceStatus</code>, он начинает зависеть не от формы входа, а от причины, по которой вход существует.</p>\n<p>У deep import другой механизм. Consumer перестаёт зависеть от обещанного поведения и начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета больше не может свободно поменять cache, разнести код по файлам или изменить стратегию invalidation. Даже если Node или bundler сегодня разрешает путь, это ещё не делает путь публичным.</p>\n<pre><code>// Учебный пример: домен выбирает смысл, 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');</code></pre>\n<p>В примере <code>statusLabel</code> — осознанная граница. Billing меняет текст и правила статуса. Formatter получает данные, достаточные для форматирования, но не получает право расширять модель счёта. Это учебная иллюстрация, а не утверждение о конкретном production-коде.</p>\n<h2>Как назвать public API</h2>\n<p>Начните не с glob-паттерна, а со списка обещаний. Для условного пакета запись может выглядеть так:</p>\n<pre><code>// Учебная запись контракта, не готовая конфигурация проекта. const boundary = { root: '@example/platform-formatting', publicNames: ['formatMoney', 'formatDate'], forbidden: ['@example/platform-formatting/internal/*'], owner: 'formatting-team', reviewBy: '2026-09-01' };</code></pre>\n<p>Поле <code>root</code> отвечает на вопрос, откуда импортировать. <code>publicNames</code> отделяет API от случайно экспортированного файла. <code>forbidden</code> показывает, что internal-пути не входят в обещание. Owner принимает изменения surface. Дата пересмотра нужна для временного adapter-а: без неё временная лазейка становится постоянной.</p>\n<p>После этого можно выбрать техническую проверку. В Node поле <code>exports</code> задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем <code>moduleResolution</code> учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл <code>InvoiceStatus</code> и не доказывает, что dynamic loader соблюдает тот же договор.</p>\n<figure><img src=\"/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg\" alt=\"Учебный маршрут проверки границы пакета: доменный тип в utility и deep import в internal ведут к разным действиям\"><figcaption>Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.</figcaption></figure>\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. Не оставляйте «разрешить пока» без даты.</li><li><strong>Проверьте отрицательный путь.</strong> Убедитесь, что неизвестное имя, internal subpath и новый доменный import действительно отклоняются выбранным guard-ом. Отдельно проверьте dynamic imports и generated code, если они есть.</li><li><strong>Повторите проверку после изменения.</strong> Сравните public surface до и после, запустите type check и lint в поддерживаемой конфигурации, затем проверьте потребителя. Synthetic пример не заменяет чтение реального графа.</li></ol>\n<h2>Что делать с cache и adapter-ом</h2>\n<p>Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.</p>\n<p>Adapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать <code>formatMoneyAdapter</code>, пока команда мигрирует на новый root API. Adapter не должен открывать весь <code>internal</code>. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.</p>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Поле <code>exports</code> не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.</p>\n<p>Static rule не видит все способы загрузки кода. Dynamic <code>import()</code>, generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.</p>\n<p>Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.</p>\n<h2>Критерий готовности</h2>\n<p>Работу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.</p>\n<p>Критерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.</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> — связь разрешения модулей с <code>moduleResolution</code>.</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>"
}