Skip to content

Архитектурный манифест ядра InferDI

Этот документ задаёт правила для @inferdi/inferdi в packages/inferdi. Читайте его перед ревью PR, который меняет публичный API, систему типов, получение сервисов через get(), структуру регистраций, семантику скоупов или освобождение ресурсов.

1. Философия и обещание

Миссия

InferDI доказывает, что DI в TypeScript может сохранять гибкость во время выполнения без отказа от статических гарантий. Граф зависимостей - это тип TypeScript. Если компилятор может проверить правило, InferDI должен выразить его в публичных сигнатурах. Проверки во время выполнения нужны для as-кастов, захваченных внешних контейнеров, динамических ключей и других мест, где TypeScript не видит граф.

Ценность

Граф и есть тип. Отсутствующий ключ, неверный порядок аргументов конструктора, повторная регистрация или захват scoped-зависимости singleton-сервисом должны приводить к ошибке до запуска рабочего кода. Реализация InferDI остаётся небольшой: без зависимостей времени выполнения, декораторов, рефлексии метаданных, ловушек Proxy и фреймворковой логики в ядре.

При попадании в кэш получение сервиса ограничивается одним Map.get(). Для классов с 0–7 зависимостями используются отдельные прямые вызовы new Ctor(...) для каждого числа аргументов. Для 8+ зависимостей предусмотрена отдельная ветка, проверенная бенчмарками.

Если новая возможность ослабляет эти гарантии, отклоните её или вынесите за пределы ядра.

2. Необсуждаемые опоры

2.1 Сквозная типобезопасность

Каждая публичная сигнатура должна делать неверные состояния графа невыразимыми там, где TypeScript способен выразить правило.

  • register* принимает key: K & NoKeyOverlap<K, keyof T>. Тип NoKeyOverlap проверяет [K & keyof T] целиком, без распределения по вариантам объединения. Допустимы литеральные, широкие и символьные ключи, а также объединения типов ключей. Любое пересечение отклоняет весь тип ключа; конфликтующий вариант не исключается незаметно.
  • DepsOf<AllowedDeps<T, L>, A> проверяет кортеж deps по позициям параметров конструктора и структурной совместимости.
  • AllowedDeps<T, L> сужает тип контейнера, передаваемого фабрикам. В singleton-фабрике вызов c.get('scoped') приводит к ошибке типов.
  • Базовая форма registerFactory получает контейнер, отфильтрованный по времени жизни. Перегрузка с зависимостями ограничивает доступ объявленными синхронными ключами. Функция registerAsyncFactory получает готовые значения зависимостей в аргументах. Сохраняйте это различие.
  • Lazy, AsyncLazy, Lifetime, Spec, AsyncSpec, LazySpec, AsyncLazySpec, ScopeInputMap, WithRequirements, DependenciesMap, SpecMap, ContainerOptions, Module, а также Container.ReadyKeys, Container.SyncReadyKeys, Container.Resolve, Container.ResolveUnwrapped, Container.UnwrappedValue и Container.Providers входят в публичный API. Изменение их совместимости или вывода типов считается изменением API, даже если исполняемый код не меняется.
  • Обобщённые функции получения сервисов используют Container.SyncReadyKeys<C> для get() и Container.ReadyKeys<C> для getAsync(). Метод .has() подтверждает только наличие регистрации. Он не гарантирует синхронность и не предоставляет недостающие входные данные скоупа.
  • Новые и изменённые публичные типы требуют проверок допустимого и недопустимого кода с // @ts-expect-error в container.test-d.ts или наборе тестов типов декларативного асинхронного графа. Диагностические сообщения для модулей и входных данных скоупа требуют отдельных тестовых примеров, проверяющих вывод компилятора. Сгенерированные декларации должны по-прежнему проходить consumer-dts.ts на TypeScript 5.2.

Известные ограничения TypeScript нужно документировать, а не прятать. Например, две зависимости с одинаковым структурным типом остаются взаимозаменяемыми, пока пользователь не введёт номинальное различие через ключи unique symbol или брендированные типы значений.

2.2 Без декораторов и рефлексии метаданных

InferDI - это обычный TypeScript с целью компиляции ES2022. Не добавляйте декораторы, reflect-metadata, experimentalDecorators, emitDecoratorMetadata, TS-трансформеры или плагины транспайлера.

  • Сигнатура конструктора определяет типы зависимостей.
  • Явный кортеж deps определяет порядок аргументов.
  • Во время выполнения контейнер не читает имена параметров конструктора, сгенерированные метаданные или поля классов.

Декораторы и метаданные превратили бы InferDI в другую библиотеку. Они добавляют состояние во время выполнения, требования к инструментам сборки и затраты на холодный старт. Основной пакет этого не допускает.

2.3 Время жизни отражается в типе

В ядре есть три варианта времени жизни регистрации: singleton, scoped и transient. Каждая регистрация хранит время жизни в Spec<V, L> и публичном свойстве lifetime.

  • Singleton не должен напрямую зависеть от scoped- или transient-сервиса. AllowedDeps<T, L> проверяет это при компиляции, а стандартный проверяемый контракт защищает от приведений типов и динамических регистраций во время выполнения. Если объединение типов времени жизни допускает singleton, применяется безопасный для singleton фильтр. Короткоживущие зависимости допустимы только когда singleton исключён.
  • Lazy<V> и AsyncLazy<V> сохраняют время жизни целевого сервиса. Singleton может зависеть только от управляемой обёртки с точно заданным временем жизни цели singleton. Scoped-, transient-цели, смешанные варианты времени жизни и объединения управляемых и обычных обёрток для такого потребителя запрещены.
  • Флаг Registration.lazy равен true только у ленивой обёртки, целевой сервис которой имеет время жизни singleton.
  • Флаг Registration.owned равен true только у регистраций классов и фабрик, чьи результаты принадлежат контейнеру. Для registerValue, .override(), ленивых обёрток, входных данных скоупа и transient-результатов он равен false.
  • Значения registerValue, .override() и входные данные скоупа принадлежат приложению. Transient-результаты принадлежат вызывающему коду. Контейнер не добавляет их в очередь освобождения ресурсов.
  • .override() предназначен для тестовых подмен. Он сохраняет исходные kind, lazy и async и действует только в текущем скоупе. Метод отклоняет входные данные скоупа, неизвестные ключи, освобождённые контейнеры и ключи из локального кэша. Проверка замечает локально закэшированные singleton- и scoped-регистрации, registerValue и повторные подмены. Она не видит получение transient-сервисов и значений предка через дочерний контейнер в проверяемом режиме. Выполняйте подмены до первого получения сервисов, даже если проверка не может обнаружить позднюю замену.
  • dispose() освобождает только экземпляры, принадлежащие этому контейнеру. Родительские и дочерние контейнеры не освобождают друг друга.

2.3.1 Асинхронный режим отражается в типе

Декларативные асинхронные сервисы используют тот же граф, реестр, кэш, поиск по скоупам, правила владения и механизм освобождения ресурсов, что и синхронные.

  • registerAsyncFactory записывает итоговый тип значения в AsyncSpec<V, L>, а не Promise<V>. Кортеж позиционных зависимостей проверяется так же, как кортеж конструктора, и остаётся неизменяемым: регистрация сохраняет классификацию асинхронных позиций.
  • Асинхронные singleton- и scoped-регистрации кэшируют один нативный Promise. Параллельные вызовы getAsync() используют одну инициализацию. Получение обёртки AsyncLazy не должно запускать целевой сервис.
  • Асинхронность зависимости распространяется на класс и далее по графу. Такой класс получают через getAsync(), а его управляемая обёртка имеет тип AsyncLazy. Если ключ зависимости допускает синхронную или асинхронную регистрацию, тип класса сохраняет оба возможных режима.
  • get() отклоняет декларативные асинхронные ключи на уровне типов. getAsync() принимает любой готовый ключ, возвращает Promise и превращает синхронные ошибки получения сервиса в отклонение Promise. Отдельный реестр или путь получения сервисов не создаётся.
  • Фабрика registerFactory, возвращающая Promise, остаётся обычной синхронной записью графа: её значение и есть сам Promise. Не превращайте этот прежний контракт в AsyncSpec неявно.
  • Registration.async добавляется в конец записи и служит для классификации зависимостей при регистрации. Горячий путь получения сервиса не должен читать это поле.

2.3.2 Готовность скоупа отражается в типе

Входные данные скоупа принадлежат приложению и становятся доступны только при создании дочерних скоупов.

  • declareScopeInputs<Inputs>() существует только в типах и не меняет объект контейнера.
  • Объявление принимает конечный набор обязательных строковых или символьных ключей. Числовые ключи, __proto__, широкие индексные сигнатуры, необязательные свойства, объединения с разными наборами ключей и пересечения с существующим графом отклоняются.
  • createScope(inputs) может передать любую часть отсутствующих данных. Готовность распространяется на зависимые регистрации, вложенные скоупы наследуют переданные данные. Получать можно только готовые сервисы.
  • Переданные значения поверхностно копируются в кэш дочернего контейнера и остаются во владении приложения. Их нельзя перерегистрировать или подменить через .override(). Они исключены из Container.Providers<C>.

2.3.3 Модули задают требования

Module<TRequirements, TProvides> описывает переиспользуемое преобразование графа. Этот тип задаёт требования модуля и добавляемые им сервисы, а не весь контейнер целиком.

  • Фактический граф может содержать дополнительные записи. Для каждого требования проверяются совместимость типа сервиса, точное время жизни, синхронный или асинхронный режим, режим управляемой ленивой обёртки, принадлежность к входным данным скоупа и готовность.
  • Функция модуля видит только объявленные требования. Результат сохраняет все фактические записи и добавляет объявленные сервисы.
  • Добавляемые ключи не должны пересекаться с фактическим графом. Уже выполненные требования к входным данным скоупа исключаются из набора недостающих требований результата.
  • Обобщённая функция вида <T>(c: Container<T>) => ... не может доказать новизну произвольного ключа относительно верхней границы DependenciesMap. Используйте лямбда-функцию прямо в .use() или именованный Module<TRequirements, TProvides>.

2.4 Горячий путь разрешения остаётся маленьким

Первая операция в get() - локальный поиск в кэше:

ts
const cached = this.cache.get(key)
if (cached !== undefined) return ...

Не добавляйте работу перед этим поиском.

  • Явный undefined хранится через UNDEFINED_MARKER. Не добавляйте второй вызов cache.has(key) на путь попадания в кэш.
  • Проверка _disposed, поиск регистрации и родителя, проверки циклов и времени жизни, изменение стека singleton-зависимостей выполняются после быстрого возврата из кэша.
  • В стандартном режиме сначала проверяются локальные регистрации, затем точная цепочка родителей. Снимки результатов поиска у родителей не хранятся. Поэтому изменения видны без учёта инвалидации и дополнительных метаданных поиска в каждом скоупе.
  • Сохраняйте отдельные прямые вызовы конструктора для 0–7 аргументов. Ветка для 8+ использует Reflect.construct с плотным массивом без пропусков, собранным через push.
  • get() остаётся синхронным. Общие массивы resolving и singletonStack работают корректно, поскольку получение сервиса и предварительная проверка декларативных асинхронных зависимостей выполняются атомарно в стеке вызовов. getAsync() оборачивает тот же механизм в Promise. Продолжения асинхронных операций никогда не меняют эти стеки.
  • Декларативные асинхронные регистрации используют те же regs, cache, поиск по скоупам, владение и освобождение ресурсов, что и синхронные. Registration.async служит только для классификации зависимостей при регистрации; get() его не читает.
  • {fast: false} задаёт стандартный проверяемый контракт с изменяемым графом. {fast: true} убирает проверки циклов и времени жизни после локального промаха кэша, обращается напрямую к владельцу реестра и сохраняет полученные у предка singleton-значения в локальном кэше. Граф должен быть фиксированным: завершите все register*, .use() и .override() до первого получения сервиса или createScope(). Освобождайте потомков раньше предков. Режим включается только литеральным значением true; любое другое фактическое значение опции сохраняет проверяемый контракт, даже если тип был приведён вручную.
  • Сохраняйте порядок первых полей Registration: {kind, lazy, fn, owned}. Необязательный маркер async добавляется только после них и не должен читаться при получении сервиса.

packages/inferdi/__tests__/container.bench.ts не запускается в CI. Изменения в get(), структуре регистрации, представлении кэша, поиске по скоупам, ленивых обёртках и вызовах конструкторов требуют результатов бенчмарков. Локальное замедление более 5% в соответствующем сценарии блокирует слияние PR, если в нём нет конкретного письменного обоснования.

2.5 Нулевые зависимости времени выполнения

У @inferdi/inferdi нет зависимостей времени выполнения. Так и должно оставаться.

Опубликованный бандл должен оставаться строго меньше 3 KiB (3072 байта) после gzip. CI проверяет размер через pnpm run test:bundle-size. При добавлении кода в ядро или публичные вспомогательные функции ревьюеры также должны оценивать изменение размера.

2.6 Освобождение ресурсов соблюдает правила владения

Контейнер освобождает только принадлежащие ему закэшированные значения. Освобождение идемпотентно, безопасно при повторном входе и выполняется независимо для родительских и дочерних контейнеров.

  • Пометьте контейнер как освобождённый, скопируйте owned и удалите повторяющиеся ссылки на экземпляры. Затем очистите owned, cache, regs, scopeInputs и parent до вызова пользовательских методов освобождения ресурсов. Повторное обращение должно сразу видеть освобождённый контейнер.
  • Сохраняйте обратный порядок первого создания (LIFO). Повторные записи в кэше и разные асинхронные фабрики, вернувшие один ресурс, не должны освобождать его несколько раз.
  • Параллельные вызовы асинхронного dispose() используют общий Promise завершения. Метод дожидается закэшированных Promise фабрик, проверяет Symbol.asyncDisposeSymbol.dispose.dispose() и продолжает очистку после ошибок. Один сбой передаётся как исходная ошибка, несколько собираются в AggregateError.
  • Синхронный [Symbol.dispose]() вызывает только синхронные протоколы. Закэшированный Promise или обычный .dispose(), возвращающий Promise, считается ошибкой использования. Не скрывайте её запуском фоновой очистки.
  • registerValue, .override(), входные данные скоупа, ленивые обёртки и transient-значения не освобождаются контейнером: владение ими ему не передавали.

3. Фильтр PR

Для каждого PR, который затрагивает packages/inferdi/src, packages/inferdi/package.json, packages/inferdi/jsr.json или тесты ядра, ответьте при ревью на следующие вопросы:

  1. Сохраняет ли изменение гарантии графа на этапе компиляции? Если правило переносится в проверки времени выполнения, какое ограничение TypeScript это объясняет?
  2. Меняется ли попадание в кэш в get(), структура регистрации, поиск по скоупам, ленивое получение сервиса или вызов конструктора? Если да, где результаты бенчмарков?
  3. Добавляет ли изменение в основной пакет зависимость времени выполнения, декораторы, рефлексию метаданных, получение сервисов через Proxy или требования к транспайлеру?

Отклоняйте PR, если правило типов переносится в проверки времени выполнения без причины, для изменений из пункта 2 нет результатов бенчмарков или ответ на пункт 3 положительный.

4. Чек-лист строгого контроля

Любое изменение, соответствующее пункту ниже, требует явного обоснования в PR.

Горячий путь и структура объектов

  • [ ] В get() добавлена работа перед cache.get(key)?
  • [ ] Изменены UNDEFINED_MARKER, cache, regs, поиск родителя или структура Registration?
  • [ ] Изменён порядок первых полей Registration {kind, lazy, fn, owned} или маркер async перенесён перед ними?
  • [ ] В проверяемом режиме локальная регистрация ищется после родительской?
  • [ ] При получении сервиса появились Proxy, Reflect.get, Object.defineProperty или чтение метаданных?
  • [ ] get() стал async?
  • [ ] get() начал читать Registration.async или вычислять готовность?
  • [ ] Изменены или удалены отдельные ветки конструктора для 0–7 аргументов?
  • [ ] Скоупы fast-режима перестали обращаться напрямую к владельцу реестра или стали сохранять в локальном кэше что-то кроме singleton-значений, полученных у предка?

Система типов

  • [ ] Проверка повторных ключей ослаблена за пределами .override()?
  • [ ] Публичное ограничение ключа string | symbol сужено до string?
  • [ ] Ослаблены AllowedDeps, LazySpec, AsyncLazySpec, распространение асинхронности, проверка готовности или фильтрация времени жизни?
  • [ ] Изменены NoKeyOverlap, ScopeInputMap, WithRequirements, совместимость модулей, SpecMap или вспомогательные типы пространства имён?
  • [ ] Входные данные скоупа допускают необязательные, числовые, широкие, различающиеся в объединениях или пересекающиеся ключи? Можно ли получить их до передачи значений?
  • [ ] Именованный модуль может скрыть отсутствующие или несовместимые требования либо добавить ключи, пересекающиеся с фактическим графом?
  • [ ] В src/ добавлены новые небезопасные any, unknown as или // @ts-ignore?
  • [ ] Публичные типы изменены без проверки допустимого и недопустимого кода, применимых диагностических примеров и потребителя собранных деклараций?

Зависимости и сборка

  • [ ] В packages/inferdi/package.json добавлена зависимость времени выполнения?
  • [ ] Добавлена peer-зависимость от reflect-metadata, tslib или кода интеграции с фреймворком?
  • [ ] Превышен строгий бюджет < 3 KiB после gzip или ослаблена его проверка в CI?
  • [ ] Потребовался плагин или трансформер TypeScript, флаг декораторов или генерация метаданных?

Жизненный цикл и освобождение ресурсов

  • [ ] dispose() или [Symbol.dispose]() перестал устанавливать _disposed до вызова методов освобождения ресурсов?
  • [ ] Очистка owned, cache, regs, scopeInputs или parent перенесена после вызова пользовательского метода освобождения?
  • [ ] Удалено отсоединение от родителя?
  • [ ] Удаление повторяющихся ссылок на принадлежащие контейнеру экземпляры больше не сохраняет обратный порядок первого создания?
  • [ ] Изменён LIFO-порядок освобождения?
  • [ ] Изменён порядок проверки асинхронного освобождения: Symbol.asyncDisposeSymbol.dispose.dispose()?
  • [ ] Закэшированные Promise асинхронных фабрик больше не ожидаются перед проверкой или один общий ресурс может быть освобождён дважды?
  • [ ] Параллельные вызовы асинхронного dispose() перестали использовать общий Promise завершения?
  • [ ] Несколько ошибок освобождения ресурсов больше не собираются в AggregateError?
  • [ ] Синхронное освобождение больше не сообщает об ошибочном использовании асинхронного ресурса?

Лазейки и динамическое использование

  • [ ] Ослаблена проверка момента подмены по локальному кэшу в .override() или скрыты её ограничения для transient-сервисов и значений предка?
  • [ ] .override() перестал сохранять kind, lazy или async, стал менять другие контейнеры или разрешил подменять входные данные скоупа?
  • [ ] .has() начал получать сервисы или менять кэши?
  • [ ] .has() начал гарантировать готовность или безопасность синхронного получения?
  • [ ] Fast-дерево стало изменяемым после активации или ослаблен порядок освобождения «сначала потомки, затем предки»?
  • [ ] Ключи, созданные во время выполнения, предлагаются как основной способ работы с API?
  • [ ] В ядро добавлены автоматическое связывание или внедрение зависимостей, внедрение по именам параметров, сканирование файловой системы или обнаружение модулей?

5. Осознанные компромиссы

Документируйте эти решения, а не «исправляйте» их.

КомпромиссПричина
Нет поддержки ES5 и других целей компиляции старше ES2022Реализация опирается на Map, Symbol, WeakRef, Reflect.construct, Symbol.dispose и Symbol.asyncDispose. Пакет добавляет полифилы только для отсутствующих символов освобождения ресурсов. Минимальная версия Node остаётся 16.
Нет API декораторовDI на декораторах была бы другой библиотекой.
Нет метаданных времени выполненияГраф задают сигнатуры конструкторов и явные кортежи deps. Интроспекция во время выполнения добавила бы зависимости и менее предсказуемые ошибки.
Нет номинального различия между структурно одинаковыми зависимостямиTypeScript проверяет структурную совместимость. Если два ключа дают значения одной структуры, DepsOf не распознаёт их смысл. Чтобы различать ключи, используйте unique symbol; чтобы проверять порядок структурно одинаковых аргументов, брендируйте типы самих значений.
Нет асинхронного get()get() остаётся синхронным. getAsync() оборачивает тот же механизм и возвращает Promise, не создавая отдельный реестр, кэш или путь получения сервисов.
registerFactory, возвращающий Promise, остаётся синхронной записью графаФабрика может намеренно предоставлять сам Promise как сервис. Только registerAsyncFactory создаёт AsyncSpec и распространяет асинхронность по графу.
Нет обнаружения динамических циклов после перехода к асинхронному выполнениюДекларативные асинхронные связи проходят синхронную предварительную проверку циклов. Обращения из обычных фабрик, возвращающих Promise, или из захваченных контейнеров после await происходят, когда стек получения зависимостей уже очищен. Разорвите такой цикл или вынесите общую инициализацию.
Нет проверки нарушений времени жизни после перехода к асинхронному выполнениюAllowedDeps блокирует неверные типизированные фабрики. Но обращения после await через приведения as или захваченные контейнеры происходят, когда singletonStack уже очищен. Полная дополнительная защита потребовала бы отслеживания асинхронного контекста. Получайте зависимости в синхронной части фабрики до первого await.
Нет автоматического разрыва цикловЦиклические зависимости требуют пересмотра архитектуры; явная ленивая обёртка singleton может отложить связь. InferDI сообщает о поддерживаемых циклах, но не создаёт Proxy или частично готовые экземпляры.
Нет обобщённых модулей <T>(c: Container<T>) => ...В теле обобщённой функции keyof T ограничен верхней границей DependenciesMap. Используйте лямбду прямо в .use() или Module<TRequirements, TProvides> с объявленными требованиями.
Нет неявного источника входных данных скоупаdeclareScopeInputs() существует только в типах. Приложение явно передаёт свои значения в createScope(inputs). Ядро не читает неявный контекст запроса или AsyncLocalStorage.
Нет отдельного API динамического получения зависимостей.has(key) проверяет регистрацию, но не готовность и не синхронность. Для статических готовых ключей вызывайте .get() или .getAsync() напрямую.
Подмена не предназначена для рабочего графа.override() нужен для тестов и сценариев горячей перезагрузки. Проверка момента подмены видит только локальный кэш. Реализации рабочего графа выбираются в .use() или обычном коде сборки.
Fast-режим требует фиксированного графа{fast: true} упрощает поиск и кэширует singleton-значения предков локально, полагаясь на соблюдение правил структуры графа, жизненного цикла и времени жизни, а также отсутствие циклов. По умолчанию действует проверяемый контракт с изменяемым графом.
Нет каскадного освобождения потомковКаждый контейнер владеет своими экземплярами. Каскадное освобождение добавило бы нелокальные побочные эффекты в dispose() и нарушило бы владение скоупами.
Нет хуков, перехватчиков и middleware при получении сервисовЭто относится к AOP. Они добавили бы работу в горячий путь и размыли бы контракт ядра.
Нет интеграций с фреймворками в ядреАдаптеры находятся в отдельных пакетах. Ядро остаётся без зависимостей и привязки к фреймворкам.
Нет движка анализа графа в ядреЗаметки о возможном @inferdi/graph в репозитории описывают предложение, а не текущий API. Любой будущий инструмент разработки или CI должен работать вне механизма получения сервисов и сохранять структуру регистраций на горячем пути.

6. Не-цели

InferDI не станет:

  • Универсальным IoC-фреймворком.
  • Контейнером на декораторах или рефлексии.
  • Системой контекста запроса или заменой AsyncLocalStorage.
  • Сканером автоматического связывания зависимостей.
  • DSL для объявления провайдеров или системой автоматического обнаружения модулей во время выполнения.
  • Движком анализа графа, правил, отчётов и снимков в ядре приложения.
  • Платформой плагинов с middleware для получения сервисов.
  • Слоем совместимости с устаревшими DI-контейнерами.

Итоговое правило: граф - это тип, а тип - это контракт.