DarkRiDDeR12 мин

Границы пакетов: public API, запрещённые импорты и три уровня защиты

JavaScriptАрхитектура

Сбой границы редко выглядит как архитектурный спор. Сначала 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, входы, выходы, ownerruntime 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
Таблица public API учебного platform formatting package: root specifier предоставляет formatMoney и formatIsoDate с примитивными входами; в красной зоне находятся InvoiceStatus и internal subpath.
Схема отделяет контракт функции от файлового устройства. Она не показывает настоящий package.json и не утверждает, что эти exports опубликованы в каком-либо registry.

Уровень 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.

Симптом → причина → проверка → действие в механике

  1. Симптом. Новый consumer импортирует `/internal`, либо shared package импортирует business type, и это кажется быстрым способом избежать adapter-а.
  2. Причина. Публичная поверхность не названа; runtime visibility, type resolution и team policy были приняты за один и тот же механизм.
  3. Проверка. Сравните каждый edge с root API record: кто владеет входным типом, разрешён ли specifier, покрывает ли выбранный tool именно такой import syntax и есть ли у запрета смысловая альтернатива.
  4. Действие. Вынесите 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 динамических загрузок.