Сбой границы редко выглядит как архитектурный спор. Сначала TypeScript без возражений принимает `import { InvoiceStatus } from "@domain/billing"` в shared formatter. Затем другой пакет использует private helper, потому что autocomplete его нашёл. Оба импорта работают сегодня. Цена появляется позже: новый billing enum вынуждает выпускать utility, а рефакторинг cache требует искать consumers, которых никто не считал частью API. Команда получает связность без владельца и пытается лечить её новым alias или исключением в linter.
Причина в том, что слово «граница» смешивает три разные вещи. Public API отвечает, что package обещает consumers. Runtime/package metadata отвечает, какие package entry points разрешает resolver. Static policy отвечает, какие import routes команда считает недопустимыми в данном слое. Если назвать их одним механизмом, появится ложная уверенность: `exports` объявляют архитектуру, TypeScript якобы запрещает домены, а lint якобы знает полный граф. Ни одно из этих утверждений не верно без явного контракта.
Модель: пакет обещает меньше, чем содержит
Пакет всегда содержит больше, чем должен обещать. Внутри formatter-а могут быть cache key, locale fallback и вспомогательный adapter. Consumer не должен строить на них зависимость, иначе любая перестройка внутреннего кода становится breaking change. Public API сужает поверхность до root specifier и нескольких имён. Это не попытка спрятать знания от коллег; это способ зафиксировать, какие изменения требуют миграции и какой owner принимает решение о расширении интерфейса.
Доменные типы требуют отдельного внимания. TypeScript type-only import может исчезнуть из emitted JavaScript, но архитектурная зависимость остаётся в исходном коде: formatter начинает понимать чужой словарь, а его declarations начинают отражать этот словарь. Поэтому проверка «в bundle нет billing» недостаточна. Вопрос другой: может ли команда изменить billing-модель, не открывая контракт shared package? Если ответ нет, зависимость уже существует, даже если она была type-only.
| Слой | Что он проверяет | Чего он не доказывает | Практический вывод |
|---|---|---|---|
| Public API record | допустимые specifier, names, входы, выходы, owner | runtime resolution и все реальные imports | сначала договоритесь о смысле |
| Node package.json `exports` | доступные entry points при package import | сильную изоляцию от прямого абсолютного пути и доменную политику | используйте для package surface при подходящем runtime |
| TypeScript node16/nodenext | согласованное разрешение `exports`/`imports` и module format | право одного домена знать модель другого | держите compiler и runtime в одной модели |
| ESLint `no-restricted-imports` | названные статические import routes и, в нужной версии, pattern rules | полный граф, dynamic imports и смысл модели | закодируйте уже принятый локальный запрет |
| Human review | стоит ли новый факт включать в обещание package | машинную полноту без наблюдаемого evidence | принимает исключение или создаёт отдельный adapter |
Уровень 1: записать контракт до конфигурации
Контракт должен быть настолько мал, чтобы reviewer смог прочитать его без поиска по всему репозиторию. Для `@synthetic/platform-formatting` достаточно зафиксировать root specifier, `formatMoney`, `formatIsoDate`, входы и выходы. За пределами остаются `internal` и `src`, доменные types и business decisions. Появление нового export-а — не строка в barrel file, а изменение public surface: нужен owner, потребитель, причина, migration story и версия, если пакет имеет внешних клиентов.
Запрет тоже должен быть написан в терминах направлений. «Не импортировать домены» слишком широко: adapter, который по задаче соединяет UI и billing, станет ложным нарушением. Точнее так: package с ролью `platform-formatting` не импортирует `billing-domain` и `account-domain`; потребители этого package не ходят в `platform-formatting/internal/*`; package с ролью billing может импортировать root API formatter-а. У правила появляются адресат, исключение и проверяемый route.
Уровень 2: package metadata не равна архитектуре
В Node.js поле `exports` разрешает явно описать main entry point и subpath exports. Официальная документация Node v20.11.1 объясняет, что при наличии `exports` неописанные subpath не доступны обычному `import "package/subpath"`; это делает surface package надёжнее для tools и semver-изменений. Но там же есть важная граница: абсолютный путь к файлу может обойти такую инкапсуляцию. Поэтому `exports` нельзя продавать команде как security boundary или доказательство отсутствия плохих imports.
Уровень 3: статический запрет должен быть узким
После контракта можно поставить статический guard. Например, policy для consumer-слоя запрещает `@synthetic/platform-formatting/internal/*` и предлагает root API. Policy для utility запрещает `@synthetic/billing-domain/*` и предлагает вернуть интерпретацию status в billing. ESLint `no-restricted-imports` создан именно для ограничения конкретных static imports; опубликованный 1 декабря 2023 ESLint 8.55.0 добавил `importNamePattern`. Для простого маршрута этого достаточно: error показывает edge и альтернативу, а не абстрактное «нарушение архитектуры».
Пример: от domain type к primitive contract
Плохой вариант не обязательно выглядит огромным. Formatter получает `InvoiceStatus`, выбирает текст «Просрочен» и добавляет вид currency. В нём уже два разных вопроса: как интерпретировать состояние счёта и как отобразить деньги. Разделение выглядит скромно: billing переводит status в свою label или display model, а formatter получает amount, currency и locale. Это не делает код безошибочным, но возвращает изменение status в domain package и оставляет utility независимой от его enum.
// Синтетический контракт, не код чужого репозитория.
// Billing владеет значением статуса.
const display = {
statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',
amountMinor: invoice.amountMinor,
currencyCode: invoice.currencyCode,
};
// Общая утилита получает только форматируемые primitive values.
const amountLabel = formatMoney({
amountMinor: display.amountMinor,
currencyCode: display.currencyCode,
locale: 'ru-RU',
});
Fixed fixture проверяет классификацию, не реальные файлы
Fixture для этой статьи хранит три графа как frozen JS records: clean root API, utility-to-domain edge и consumer-to-internal edge. На вход он принимает только marked case id и возвращает `compliant` или `violated` лишь для записанного synthetic случая. Далее decision draft предлагает удалить ровно найденный edge или сохранить public API record. Это полезно для модели: можно проверить, что domain leak не маскируется под deep import и что неизвестный case, попытка передать `repositoryPath` или режим `scan-project` отклоняются.
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 и отрицательных веток.
Отчёт fixture прямо возвращает `realRepository=not-inspected`, `realImportGraph=not-scanned`, `realLint=not-executed`, `realCi=not-executed` и `effect=no-system-change`. Это не оговорка мелким шрифтом. Она не даёт принять synthetic PASS за доказательство качества текущего монорепозитория. Чтобы проверить настоящий проект, нужны согласованная область, разрешение на чтение, выбранный parser/resolver, зафиксированная toolchain и отдельный результат review.
Симптом → причина → проверка → действие в механике
- Симптом. Новый consumer импортирует `/internal`, либо shared package импортирует business type, и это кажется быстрым способом избежать adapter-а.
- Причина. Публичная поверхность не названа; runtime visibility, type resolution и team policy были приняты за один и тот же механизм.
- Проверка. Сравните каждый edge с root API record: кто владеет входным типом, разрешён ли specifier, покрывает ли выбранный tool именно такой import syntax и есть ли у запрета смысловая альтернатива.
- Действие. Вынесите domain interpretation к owner-у, сузьте export surface, добавьте named static restriction, а исключения оформляйте отдельным adapter/package review.
Ограничения и следующий шаг
Контракт не делает все пакеты идеально независимыми. Есть intentional adapters, plugins, generated clients, framework entry points и миграционные периоды. Для них политика должна сказать, кто может пройти границу и как будет удалено исключение. Не используйте `exports` там, где runtime или consumer compatibility этого не поддерживает; не включайте TypeScript mode без проверки emitted output; не выдавайте ESLint diagnostics за анализ dynamic graph. Наконец, boundary rule не заменяет тест поведения public API.
Следующий шаг — взять один import, который сегодня выглядит «почти нормальным», и провести его по пяти колонкам из таблицы. Если это domain fact внутри utility, переведите его в primitive data на стороне domain owner. Если consumerу действительно не хватает функции, не разрешайте deep import: опишите новый root export, owner и compatibility. Так механизм останется небольшим и проверяемым, а не превратится в набор инструментов, которыми никто не управляет.
Историческая граница марта 2024
Все три источника были доступны к марту 2024: Node v20.11.1, TypeScript 4.7 и ESLint v8.55.0. Статья не приписывает им поздние возможности и не изображает synthetic model проверкой чужого import graph или CI.
Проверяемые источники
- 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 динамических загрузок.