Ситуация учебная, но узнаваемая. Есть `platform-formatting`: его позвали форматировать money и date в нескольких feature. В следующей задаче billing просит показать особую подпись для просроченного счёта. Самый короткий код — импортировать `InvoiceStatus` в formatter. Одновременно orders-feature уже берёт `createFormatterCache` по пути `platform-formatting/internal/...`, потому что root export-а не хватило. Оба решения экономят несколько строк сейчас. Цена — новый enum и новый cache key становятся чужими рисками: billing нельзя менять без formatter-а, formatter нельзя чистить без orders-feature, а owner каждого решения не назван.
Это не отчёт о настоящем сервисе, репозитории или production-инциденте. Все package names, edges и records ниже — заранее записанный synthetic кейс. Его задача — показать порядок разговора, когда общая утилита незаметно начинает быть платформой. Мы не будем утверждать, что нашли зависимости сканером, что измерили bundle или что запустили CI. В такой ситуации полезнее сначала отделить наблюдаемый симптом от вероятной причины, чем выдать красивую диаграмму за доказательство.
Что именно сломалось в договоре
Первый симптом — utility импортирует доменный `InvoiceStatus`. Это не обязательно создаёт runtime cycle и не обязательно ломает сборку. Но formatting-package получает право решать, какое состояние имеет счёт и как оно называется. Второй симптом — consumer использует internal cache. Это делает файловое устройство package частью contract-а, хотя owner не обещал его поддерживать. Симптомы разные: один переносит доменный смысл вверх, другой расширяет surface вниз. Лечить их одним исключением «разрешить импорт» нельзя.
Причина общая: public API существовал только в головах. Слово «shared» прочитали как «сюда можно всё общее», а слова `internal` и package root не были policy. Поэтому reviewer видит рабочий import и не может ответить на два коротких вопроса: владеет ли источник этим типом и может ли target менять этот путь без миграции. Пока ответ не записан, каждое следующее удобное использование выглядит равноправным с настоящим API.
| Наблюдение | Что оно означает | Кто должен решить | Первое безопасное действие |
|---|---|---|---|
| formatter импортирует `InvoiceStatus` | доменная интерпретация вошла в platform utility | owner billing и owner formatting | вернуть label/status mapping в billing, оставить formatter primitive inputs |
| orders-feature импортирует `/internal/formatter-cache` | consumer зависим от внутреннего устройства | owner formatting и consumer owner | проверить, нужен ли root export или consumer хранит cache сам |
| нет списка public names | невозможно отличить контракт от файла | owner formatting | создать one-page record с root specifier и exports |
| lint исключение предлагается до решения | инструмент маскирует неясную архитектуру | reviewer правила | сначала согласовать route, потом настроить статический guard |
| неизвестна совместимость runtime | export map может расходиться с consumer tooling | package owner | проверить поддерживаемые Node/TypeScript/bundler режимы отдельно |
Разделить два решения, а не один файл
В billing остаётся выбор статуса и текста. Если он нужен UI, billing может отдать `statusLabel` или более строгую display model, но именно owner billing меняет её при появлении нового enum. Formatting получает `amountMinor`, `currencyCode`, `locale` и возвращает строку. Это не «примитивы ради примитивов». Это минимальный набор, который формирует деньги без знания, почему именно эта сумма показана и какое юридическое состояние у документа.
Для internal cache есть два возможных исхода. Первый: cache — деталь formatter-а; consumer перестаёт его импортировать и вызывает public `formatMoney`. Второй: cache действительно нужен нескольким consumer-ам и имеет стабильную семантику. Тогда он не становится public случайно. Owner описывает отдельный export, входы, lifetime, invalidation и compatibility. В synthetic кейсе мы не выбираем между этими вариантами за реальную команду. Мы фиксируем, что deep import — сигнал к review, а не доказательство, что любой internal helper надо экспортировать.
Короткий API record для разговора
На одной странице достаточно пяти полей. `Owner`: команда или роль, принимающая изменения surface. `Root specifier`: один путь, который можно импортировать. `Public names`: `formatMoney`, `formatIsoDate`. `Forbidden routes`: `internal/*` для consumers и domain packages для utility. `Evidence`: какой tool и какой review подтверждают конкретное правило. Важно добавить срок пересмотра: иначе migration adapter, появившийся на неделю, станет вечной архитектурой.
// Только synthetic illustration. Это не конфигурация реального repo.
const packageBoundaryRecord = {
packageName: '@synthetic/platform-formatting',
publicSpecifier: '@synthetic/platform-formatting',
publicNames: ['formatMoney', 'formatIsoDate'],
forbiddenConsumerRoutes: ['@synthetic/platform-formatting/internal/*'],
forbiddenUtilityTargets: ['@synthetic/billing-domain/*'],
owner: 'synthetic-formatting-owner',
reviewBy: 'human-review-required',
};
// InvoiceStatus остаётся у synthetic billing owner.
// Formatter принимает amountMinor, currencyCode и locale.
Проверка: не путать evidence с догадкой
Для учебного кейса fixture содержит три неизменяемых набора edge: `fixed-clean-public-api`, `fixed-utility-imports-domain` и `fixed-consumer-deep-import`. Он принимает только case id, явно отклоняет `repositoryPath`, `scan-project`, чужой scope и неизвестный case. Report и decision draft обязаны иметь точный набор полей и совпасть с канонической fixed записью; лишнее поле, подменённое action или разрежённый список действий не дают вызвать даже учебный rollback. В положительном случае он возвращает root API с двумя именами и отдельно перечисляет запрещённые consumer subpath. В двух отрицательных случаях он возвращает разные codes: `utility-imports-domain` и `consumer-bypasses-public-api`.
import {
createFixedSyntheticBoundaryInput,
inspectSyntheticPackageBoundary,
planSyntheticBoundaryRemediation,
runPackageBoundaryFixture,
} from './upgrade-2024-03.mjs';
const report = inspectSyntheticPackageBoundary(
createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'),
);
const draft = planSyntheticBoundaryRemediation(report);
if (!Object.values(runPackageBoundaryFixture().assertions).every(Boolean)) {
throw new Error('synthetic fixture failed');
}
console.log(report.status); // violated
console.log(draft.realCi); // not-executed
// Здесь нет чтения репозитория, файлов, сети, CI или реального import graph.
node web/scripts/upgrade-2024-03.mjs --verify-fixture
# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.
Как проходит review решения
- Симптом. Зафиксируйте exact specifier и imported name из одной заявки или diff. Не расширяйте проблему словами «всё связано со всем».
- Причина. Спросите: это domain meaning в utility или consumer зависится от package internals? Возможно, одновременно присутствуют обе причины, но они остаются разными карточками работы.
- Проверка. Сверьте edge с API record, его owner, allowed direction, Node/TypeScript compatibility и ограничением выбранного static rule. Для legacy пути отдельно назовите срок существования adapter-а.
- Действие. Выберите один из явно названных выходов: вернуть решение domain owner-у; добавить reviewed root export; создать named adapter; отклонить запрос. Зафиксируйте, кто проверит removal исключения.
- Повторная проверка. После изменения подтвердите public API и реальную toolchain в пределах согласованной области. Не заменяйте эту работу synthetic fixture-ом.
Где команды обычно теряют время
Первый тупик — спор о слове «платформа». Не требуется сперва создать отдельную platform team. Достаточно признать, что общий package уже имеет consumers и изменение его surface имеет цену. Второй — запретить всё через glob. Это даёт ложные violations у adapters и подталкивает к suppressions. Третий — объявить любой new export ошибкой. Иногда public API действительно растёт; важно, чтобы рост имел owner, migration и обратимую границу, а не происходил как побочный эффект internal import.
Четвёртый тупик — надеяться, что TypeScript type check подтверждает смысл dependency. Compiler правильно проверяет формы модулей и типы, но не знает, кто владеет `InvoiceStatus`. Пятый — считать package `exports` непробиваемой стеной. Node прямо ограничивает такую интерпретацию: encapsulation не является сильной защитой от прямого абсолютного обращения к файлу. И наконец, ESLint имеет область действия static imports; если проект использует dynamic loading или generated layers, это следует признать в policy и проверить отдельным способом.
Ограничения и следующий шаг
Кейс не даёт готовую структуру папок, не доказывает производительность, не выбирает versioning scheme и не описывает permission model для всех команд. Node, TypeScript и ESLint решают разные части задачи и зависят от конкретных версий, runtime и build pipeline. Внешний package может иметь ещё более строгие semver-обязательства; внутренний package может жить в migration периоде. Любая реальная проверка boundary должна начинаться с разрешённой области чтения и явного списка инструментов, а не с догадки по именам каталогов.
Следующий шаг — провести такой review для одной реальной связи, не для всего монорепозитория. Договоритесь об owner-е, root public API, forbidden route, способе проверять static import и сроке, когда повторите выбор. Если конкретного route пока нет, не добавляйте глобальный запрет ради диаграммы. Если route уже есть, не оставляйте его «временно» без даты. Такая дисциплина не делает систему неподвижной; она делает цену следующей зависимости видимой заранее.
Историческая граница марта 2024
К марту 2024 Node.js уже поддерживал package `exports`; TypeScript 4.7 поддерживал Node-oriented `exports`, `imports` и self-reference; ESLint 8.55.0 уже включал расширение `no-restricted-imports`. Эти факты используются только в их заявленных границах. Автор уровня M7 формулирует решение как проверяемый контракт и не изображает synthetic case полевым наблюдением из production.
Проверяемые источники
- Node.js v20.11.1: Modules: Packages, февраль 2024 — Первичная документация Node.js, доступная до марта 2024. Поле package.json "exports" задаёт доступные entry points; неэкспортируемые subpath для обычного package import недоступны. Node отдельно оговаривает, что это не сильная изоляция против прямого абсолютного пути. Документ не рисует архитектуру конкретного монорепозитория и не заменяет правило команды.
- TypeScript 4.7: ECMAScript Module Support in Node.js, май 2022 — Первичные release notes TypeScript. В режимах node16 и nodenext TypeScript поддерживает package.json "exports", "imports" и self-reference, а также различает ESM/CJS entry points и декларации. Это описание поведения компилятора, а не политика допустимых зависимостей между доменами.
- ESLint v8.55.0 release notes, 01.12.2023 — Первичный релиз ESLint, опубликованный до марта 2024: rule no-restricted-imports получила option importNamePattern. Она помогает фиксировать статические запреты импорта, но сама по себе не доказывает архитектуру, не строит полный dependency graph и не заменяет review динамических загрузок.