Архитектурный манифест ядра InferDI
Этот документ задаёт правила для @inferdi/inferdi в packages/inferdi. Читайте его перед ревью PR, который меняет публичный API, систему типов, путь resolve в get(), форму регистрации, семантику scope или поведение очистки.
1. Философия и обещание
Миссия
InferDI показывает, что DI в TypeScript может сохранять гибкость во время выполнения без отказа от статических гарантий. Граф зависимостей - это TypeScript-тип. Если компилятор может проверить правило, InferDI должен выразить его в публичных сигнатурах. Runtime-проверки нужны для as-кастов, захваченных внешних контейнеров, динамических ключей и других мест, где TypeScript не видит весь граф.
Ценность
Граф и есть тип. Отсутствующий ключ, неверная позиция аргумента конструктора, повторная регистрация или утечка singleton-to-scoped должны падать до запуска боевого кода. Runtime-контракт тоже остаётся маленьким: без runtime-зависимостей, декораторов, рефлексии метаданных, ловушек Proxy и фреймворковой логики в основном пакете.
Resolve с попаданием в кеш остаётся быстрым путём из одного Map.get(). Создание классов использует развёрнутые прямые вызовы new Ctor(...) для 0-7 зависимостей и измеренный хвостовой путь для 8+ зависимостей.
Если фича ослабляет эти обещания, её нужно отклонить или вынести за пределы core.
2. Необсуждаемые опоры
2.1 Типобезопасность от начала до конца
Каждая публичная сигнатура должна делать неверные состояния графа невыразимыми там, где TypeScript способен выразить правило.
register*используетK & ([K] extends [keyof T] ? never : unknown), чтобы повторные ключи падали при компиляции, а проблемный ключ оставался виден в ошибке.DepsOf<AllowedDeps<T, Kind>, A>проверяет кортежdepsпо позициям конструктора и структурной совместимости.AllowedDeps<T, Kind>сужает контейнер, переданный в фабрики. Внутри singleton-фабрикиc.get('scoped')является ошибкой типов.Spec,LazySpec,SpecMap,Module,Container.Resolve,Container.ResolveUnwrapped,Container.UnwrappedValueиContainer.Providersвходят в контракт. Их изменение считается изменением публичного API.- Новая или изменённая публичная поверхность типов требует позитивных тестов типов и негативных
// @ts-expect-errortests вpackages/inferdi/__tests__/container.test-d.ts.
Известные ограничения TypeScript нужно документировать, а не прятать. Например, две зависимости с одинаковой структурной формой остаются взаимозаменяемыми, пока пользователь не добавит номинальное различие через unique symbol keys или branded-типы значений.
2.2 Никаких декораторов и reflect metadata
InferDI - это обычный TypeScript с таргетом ES2022. Не добавляйте декораторы, reflect-metadata, experimentalDecorators, emitDecoratorMetadata, TS transformers или плагины транспайлера.
- Тип конструктора является источником истины для типов зависимостей.
- Явный кортеж
depsявляется источником истины для порядка аргументов. - Runtime не читает имена параметров конструктора, сгенерированные metadata или поля классов.
Декораторы и metadata превращают InferDI в другую библиотеку. Они добавляют runtime-состояние, требования к инструментарию и стоимость холодного старта, которые основной пакет отвергает.
2.3 Время жизни является типом
В core есть три вида регистрации: singleton, scoped и transient. Каждая регистрация несёт своё время жизни через Spec<V, Kind>.
- Singleton не должен напрямую зависеть от scoped- или transient-сервиса.
AllowedDeps<T, Kind>обеспечивает это на этапе компиляции, аstrict: true- во время выполнения для кастов и динамических регистраций. Lazy<V>сохраняет время жизни цели. Singleton-потребитель может инжектить толькоLazySpec<V, 'singleton'>.Lazy<scoped>иLazy<transient>легальны для scoped- и transient-потребителей и нелегальны для singleton-потребителей.- Runtime-флаг
Registration.lazyдолжен бытьtrueтолько для lazy companion-регистраций, у которых target kind равен'singleton'. - Значения из
registerValueи.override()принадлежат внешнему коду. Они не попадают в очередь teardown. .override()- тестовая лазейка. Он должен сохранять исходныеkindиlazy, оставаться локальным для scope, отклонять неизвестные ключи, очищенные контейнеры и ключи, уже resolved на этом контейнере.dispose()трогает только экземпляры, которыми владеет данный контейнер. Родительские и дочерние контейнеры не очищают друг друга.
2.4 Горячий путь resolve остаётся маленьким
Первая операция в get() - локальный поиск в кеше:
const cached = this.cache.get(key)
if (cached !== undefined) return ...Не добавляйте работу перед этим поиском.
- Явные
undefined-значения представлены черезUNDEFINED_MARKER; не возвращайте второйcache.has(key)на путь попадания в кеш. _disposed, поиск локальной регистрации, поиск родителя,lookupCache, проверки циклов, проверки времени жизни и мутация singleton-stack находятся после быстрого пути кеша.- Локальные регистрации проверяются до поиска по цепочке родителей.
lookupCache- мемоизация холодного пути только для попаданий в родителя. - Вызов конструктора остаётся развёрнутым по arity для 0-7 аргументов. Путь 8+ использует
Reflect.constructс packed array, собранным черезpush. get()остаётся синхронным. Общиеresolvingarray иsingletonStackработают только потому, что один resolve атомарно выполняется на call stack.strict: falseможет убрать runtime-проверки циклов и времени жизни после быстрого пути кеша. Он не должен менять наблюдаемую семантику попадания в кеш.
packages/inferdi/__tests__/container.bench.ts не проверяется в CI. Ревьюеры должны требовать вывод бенчмарка для изменений в get(), форме объекта регистрации, представлении кеша, поиске scope, lazy companions или вызове конструктора. Локальная регрессия больше 5% в релевантном сценарии блокирует merge, если PR не содержит узкого письменного обоснования.
2.5 Нулевые runtime-зависимости
У @inferdi/inferdi нет runtime dependencies. Так и должно оставаться.
Опубликованный bundle должен оставаться меньше 2.5KB gzip. CI пока не проверяет этот бюджет, поэтому ревьюеры должны проверять размер bundle для PR, которые добавляют код в core implementation или публичные helpers.
3. Фильтр PR
Для каждого PR, который трогает packages/inferdi/src, packages/inferdi/package.json, packages/inferdi/jsr.json или core tests, ответьте на ревью:
- Сохраняет ли изменение compile-time гарантии графа, или переносит правило в runtime-проверки без документированного ограничения TypeScript?
- Трогает ли оно поведение cache-hit в
get(), форму объекта регистрации, lookup scope, lazy resolution или вызов конструктора? Если да, где benchmark evidence? - Добавляет ли оно runtime-зависимость, поддержку декораторов, рефлексию метаданных, resolve через proxy или требование к транспайлеру в основной пакет?
Отклоняйте PR, если пункт 1 переносит type rule в runtime без причины, пункт 2 не содержит benchmark evidence, или пункт 3 получает ответ "да".
4. Чеклист строгого контроля
Любое изменение из списка ниже требует явного обоснования в PR.
Горячий путь и runtime shape
- [ ] Добавлена работа перед
cache.get(key)вget()? - [ ] Изменились
UNDEFINED_MARKER,cache,regs,lookupCacheили формаRegistration? - [ ] Порядок свойств
Registrationизменился с{kind, lazy, fn}? - [ ] Поиск локальной регистрации перенесён после поиска родителя?
- [ ] В resolve добавлены
Proxy,Reflect.get,Object.definePropertyили metadata lookup? - [ ]
get()сталasync? - [ ] Удалены или изменены развёрнутые ветки arity для 0-7 аргументов конструктора?
Система типов
- [ ] Ослаблена защита от повторных ключей вне
.override()? - [ ]
string | symbolсужен доstringв публичном key constraint? - [ ] Ослаблены
AllowedDeps,LazySpecили lifetime filtering? - [ ] Изменились
NoKeyOverlap,Module,SpecMapили namespace helper types? - [ ] В
src/добавлены новые небезопасныеany,unknown asили// @ts-ignore? - [ ] Публичное поведение типов изменилось без type tests?
Зависимости и сборка
- [ ] Runtime dependency добавлена в
packages/inferdi/package.json? - [ ] Добавлен peer dependency на
reflect-metadata,tslibили фреймворковую обвязку? - [ ] Bundle budget превышен без approval на ревью?
- [ ] Потребовался TS plugin, transformer, флаг декораторов или metadata emit?
Жизненный цикл и dispose
- [ ]
dispose()или[Symbol.dispose]()перестал выставлять_disposedдо вызова disposers? - [ ] Очистка state перенесена после вызова disposers?
- [ ] Удалены detach родителя или очистка
lookupCache? - [ ] Изменён порядок LIFO disposal?
- [ ] Порядок проверки disposer изменился с
Symbol.asyncDispose->Symbol.dispose->.dispose()? - [ ] Несколько сбоев teardown больше не становятся
AggregateError? - [ ] Sync teardown больше не сообщает о неправильном использовании async-resource?
Escape hatches и динамическое использование
- [ ]
.override()разрешён после первого resolve? - [ ]
.override()перестал сохранятьkindилиlazy? - [ ]
.has()стал resolver или начал мутировать caches? - [ ] Runtime-constructed keys продвигаются как основной API?
- [ ] В core добавлены auto-wire, auto-inject, injection по именам параметров, сканирование файловой системы или module discovery?
5. Осознанные компромиссы
Эти решения нужно документировать, а не "чинить".
| Компромисс | Причина |
|---|---|
| Нет ES5 и target ниже ES2022 | Map, Symbol, WeakRef, Reflect.construct, Symbol.dispose и Symbol.asyncDispose фундаментальны. Пакет полифилит только disposal symbols для рантаймов без них. Node 16+ остаётся нижней границей. |
| Нет decorator API | DI на декораторах - это другая библиотека. |
| Нет runtime metadata | Сигнатуры конструкторов и явные кортежи deps задают граф. Runtime introspection добавил бы зависимости и более слабые режимы отказа. |
| Нет номинального различия для одинаковых структурных зависимостей | TypeScript использует структурную совместимость. Если два ключа имеют одну форму, DepsOf не знает семантический смысл пользователя. Используйте branded-типы или unique symbol keys, когда порядок важен между сервисами одинаковой формы. |
Нет async get() | Текущие проверки циклов и времени жизни используют общее синхронное состояние call stack. Асинхронному resolve API нужен отдельный per-resolve bookkeeping. |
| Нет детекта циклов между async-фабриками | После await синхронный resolve stack исчезает, а pending promises могут удовлетворять последующие c.get(). Детект добавил бы async tracking в resolve. Разделите цикл, поднимите общую инициализацию или используйте Lazy<singleton> там, где это легально. |
| Нет auto-cycle-breaking | Циклы - архитектурные дефекты, если одна сторона не является явным lazy singleton companion. InferDI детектирует поддерживаемые runtime cycles и сообщает о них; он не создаёт proxies или partial instances. |
Нет generic <T>(c: Container<T>) => ... modules | keyof T схлопывается до верхней границы DependenciesMap внутри generic body. Используйте inline .use() lambdas или Module<TIn, TOut> с известной input shape. |
| Нет dynamic DI resolver API | .has(key) - разрешённая динамическая проверка. Статические ключи должны напрямую использовать .get(). |
| Нет production override story | .override() существует для тестов и hot-reload fixtures. Выбор production graph должен жить в .use() или обычном коде сборщика. |
| Нет каскадного parent-to-child disposal | Каждый контейнер владеет своими экземплярами. Каскадный disposal сделал бы dispose() нелокальным side effect и сломал бы ownership scope. |
| Нет hooks, interceptors или middleware на resolve | Это AOP. Оно добавило бы работу в hot path и размыло core contract. |
| Нет framework glue в core | Адаптеры фреймворков должны жить в пакетах адаптеров. Core остаётся dependency-free и framework-agnostic. |
6. Не-цели
InferDI не станет:
- универсальным IoC framework;
- decorator или reflection container;
- системой request context или заменой
AsyncLocalStorage; - сканером для auto-wiring;
- хостом плагинов для middleware во время resolve;
- слоем совместимости для legacy DI containers.
Финальное правило: граф - это тип, а тип - это контракт.
