Руководство по архитектуре · API-справочник
Dependency Injection с @zirion/ioc
Полное руководство по архитектуре Node.js-приложений с явным внедрением через конструктор, асинхронными фабриками, singleton и request scope, lifecycle hooks и тестируемым composition root.
Обновлено · · if-else.dev
Какую задачу решает Dependency Injection
Dependency Injection (DI, внедрение зависимостей) отделяет построение графа объектов от использования этого графа. Сервис объявляет необходимые ему объекты через параметры конструктора, а единая точка композиции решает, какие конкретные реализации передать.
Без DI прикладной код часто создаёт инфраструктуру прямо внутри бизнес-логики:
class BillingService {
private database = new PostgresDatabase(process.env.DATABASE_URL!);
private mailer = new SmtpMailer(process.env.SMTP_URL!);
}
Теперь класс знает о конфигурации, конкретных адаптерах и правилах их создания. Чтобы заменить базу данных в тесте, придётся менять класс или перехватывать глобальное состояние.
При внедрении через конструктор класс описывает только контракт:
class BillingService {
constructor(
private readonly database: Database,
private readonly mailer: Mailer,
) {}
}
Само по себе это не превращает приложение в «чистую архитектуру». DI даёт явное место для сборки объектов. Хорошие границы, небольшие интерфейсы и осмысленные тесты всё ещё остаются архитектурными решениями.
@zirion/ioc — компактный контейнер для этой задачи. Вместо декораторов и runtime-метаданных TypeScript он использует явный список inject, лениво разрешает провайдеры, поддерживает асинхронные фабрики и не имеет runtime-зависимостей.
Установка и требования
Выберите используемый в проекте менеджер пакетов и установите актуальную версию:
npm install @zirion/ioc
yarn add @zirion/ioc
pnpm add @zirion/ioc
Версия 2.0.0 требует Node.js 20 или новее и публикуется в форматах CommonJS, ES modules и TypeScript declarations. Все примеры ниже рассчитаны на @zirion/ioc@2.0.0 и используют асинхронный API второй версии.
Основные экспорты, используемые в руководстве:
import {
Container,
InjectScope,
DependencyInjectionError,
ErrorCode,
type IOnInitialized,
type IOnFinalized,
type ILogger,
} from '@zirion/ioc';
Основные понятия
| Термин | Значение в этом руководстве |
|---|---|
| Container | Реестр, который хранит определения провайдеров и разрешает граф объектов |
| Selector | Идентификатор провайдера: класс, строка, symbol или объект |
| Provider | Selector вместе с фабрикой, зависимостями и scope |
| Dependency | Другой selector, указанный в массиве inject провайдера |
| Resolution | Создание или получение значения, связанного с selector |
| Scope | Правило, определяющее срок повторного использования значения |
| Context | Объект, идентичность которого задаёт один request-scoped кеш |
| Composition root | Граница приложения, в которой регистрируются все провайдеры |
Контейнер не анализирует типы TypeScript во время выполнения. Следующая аннотация полезна TypeScript, но для контейнера её недостаточно:
class OrdersService {
constructor(private readonly repository: OrdersRepository) {}
}
Runtime selectors нужно связать явно:
container.add(OrdersService, { inject: [OrdersRepository] });
Быстрый старт
Зарегистрируйте зависимости раньше использующих их классов, соберите контейнер и запросите верхнеуровневый сервис:
import { Container } from '@zirion/ioc';
class Database {
findUser(id: number) {
return { id, name: 'Ada' };
}
}
class UserService {
constructor(private readonly database: Database) {}
greet(id: number) {
return `Hello, ${this.database.findUser(id).name}!`;
}
}
const container = await new Container()
.add(Database)
.add(UserService, { inject: [Database] })
.build();
const users = await container.getOrFail(UserService);
console.log(users.greet(1));
Здесь важны три детали:
- Оба provider зарегистрированы до первого разрешения. Взаимный порядок вызовов
add()не важен, если к этому моменту в контейнере находится полный граф. - По умолчанию провайдеры имеют singleton scope и остаются ленивыми до разрешения.
build(),get()иgetOrFail()асинхронны — их нужно вызывать сawait.
Постройте composition root
Держите настройку контейнера на одной границе приложения, а не распределяйте вызовы container.get() по доменному коду. Такая граница обычно называется composition root.
import { Container } from '@zirion/ioc';
export const APP_CONFIG = Symbol('APP_CONFIG');
export interface AppConfig {
databaseUrl: string;
}
export function createContainer(config: AppConfig) {
return new Container()
.add(APP_CONFIG, { valueFactory: () => config })
.add(Database, { inject: [APP_CONFIG] })
.add(UserRepository, { inject: [Database] })
.add(UserService, { inject: [UserRepository] })
.build();
}
Запуск приложения становится намеренно коротким:
const container = await createContainer({
databaseUrl: process.env.DATABASE_URL!,
});
const users = await container.getOrFail(UserService);
Такая структура создаёт естественную точку замены для тестов: вместо ветвлений по окружению внутри бизнес-сервисов соберите другой контейнер с тестовыми фабриками.
Передавайте разрешённые сервисы во framework adapters. Используйте контейнер как инструмент композиции, а не как глобальный service locator.
Selectors и providers
add(selector, options?) регистрирует ровно один provider. Selector одновременно служит его идентификатором и значением для inject, get и getOrFail.
Class selectors
Класс может быть собственным selector и конструктором:
class Clock {
now() {
return new Date();
}
}
container.add(Clock);
const clock = await container.getOrFail(Clock); // Clock
Если valueFactory не задана, selector, не являющийся классом, невозможно сконструировать — разрешение завершится ошибкой.
String selectors
Строки удобны для небольших значений конфигурации, но в крупных приложениях повышают риск коллизий:
container.add('REGION', { valueFactory: () => 'eu-central-1' });
const region = await container.getOrFail('REGION');
Symbol selectors
Symbols — хороший вариант по умолчанию для интерфейсов и конфигурационных контрактов: в runtime они остаются уникальными.
const MAILER = Symbol('MAILER');
container
.add(MAILER, { valueFactory: () => new SmtpMailer() })
.add(NotificationService, { inject: [MAILER] });
Object selectors
Идентичность объекта тоже может быть selector. При регистрации и разрешении необходимо использовать одну и ту же ссылку:
const metricsToken = { name: 'metrics' };
container.add(metricsToken, {
valueFactory: () => new MetricsClient(),
});
const metrics = await container.getOrFail(metricsToken);
Для object, string и symbol selectors требуется valueFactory. Повторная регистрация selector запрещена; в v2.0.0 нет режимов override или multi-binding.
Внедрение через конструктор
Порядок selectors в inject становится порядком аргументов конструктора:
class CheckoutService {
constructor(
private readonly inventory: Inventory,
private readonly payments: Payments,
) {}
}
container
.add(Inventory)
.add(Payments)
.add(CheckoutService, {
inject: [Inventory, Payments],
});
Не повторяйте один selector в массиве inject. В версии 2.0.0 разрешённые аргументы собираются в Set, поэтому дубликаты схлопнутся в один аргумент конструктора.
К моменту разрешения родителя каждая зависимость должна быть зарегистрирована. Если Payments отсутствует, разрешение CheckoutService выбросит DependencyInjectionError с кодом ErrorCode.UNKNOWN_TARGET.
Constructor injection делает обязательные связи видимыми и позволяет классу не зависеть от контейнера. Избегайте такого паттерна внутри сервиса:
// Не рекомендуется: поиск зависимости скрыт в бизнес-логике.
class CheckoutService {
constructor(private readonly container: Container<any>) {}
async checkout() {
const payments = await this.container.getOrFail(Payments);
}
}
Внедряйте Payments напрямую. Обращение к контейнеру уместно на границах приложения: во время запуска или в адаптере HTTP-запроса.
Фабрики и асинхронные providers
Используйте valueFactory, если selector не является классом, создание асинхронно или требует специальной логики. Первым аргументом фабрика получает массив разрешённых зависимостей, вторым — текущий context для request-scoped provider.
const DATABASE = Symbol('DATABASE');
const CONFIG = Symbol('CONFIG');
const container = await new Container()
.add(CONFIG, {
valueFactory: () => ({ databaseUrl: process.env.DATABASE_URL! }),
})
.add(DATABASE, {
inject: [CONFIG],
valueFactory: async ([config]) => {
const database = new DatabaseClient(config.databaseUrl);
await database.connect();
return database;
},
})
.build();
Контейнер дожидается результата фабрики. Возвращённое значение кешируется в соответствии со scope провайдера, но в v2.0.0 есть граничный случай: falsy singleton-значения (false, 0, '' и null) считаются ещё не закешированными, поэтому фабрика вызывается снова. Если важно однократное создание singleton, оберните такое значение в объект.
Фабрика также может вернуть конструктор класса. Тогда контейнер создаст экземпляр возвращённого класса с уже разрешёнными зависимостями и context. Это поддерживается, но возврат готового значения обычно читается проще.
Фабрики подходят для:
- конфигурации и констант;
- адаптации сторонних клиентов;
- асинхронных подключений;
- выбора реализации по конфигурации;
- создания тестовых замен без изменения production-классов.
Не помещайте весь запуск приложения в одну огромную фабрику. Небольшие фабрики яснее показывают границы ошибок и владение ресурсами.
Жизненные циклы providers
В InjectScope версии 2.0.0 есть два scope:
| Scope | Создание | Повторное использование | Типичный сценарий |
|---|---|---|---|
InjectScope.SINGLETON |
При первом разрешении | Одно значение на контейнер | Конфигурация, пул БД, stateless-сервисы |
InjectScope.REQUEST |
При первом разрешении для context | Одно значение для конкретного объекта context | Метаданные запроса, unit of work, request logger |
По умолчанию используется singleton:
container.add(UserRepository);
const first = await container.getOrFail(UserRepository);
const second = await container.getOrFail(UserRepository);
console.log(first === second); // true
Request scope задаётся явно:
container.add(RequestState, {
scope: InjectScope.REQUEST,
});
Transient scope в v2.0.0 отсутствует. Если каждый вызов должен создавать новый объект, внедрите функцию-фабрику или создавайте короткоживущее значение внутри использующего его сервиса.
Правило безопасности scope
Request-scoped provider может зависеть от singleton providers. Singleton provider не может зависеть от request-scoped provider: иначе singleton захватит данные одного запроса и повторно использует их в других.
Контейнер отклоняет такой граф с кодом ErrorCode.SINGLETONE_SCOPE_WRONG_CONTEXT — это написание является частью текущего публичного enum.
Request context
Request-scoped значения хранятся в WeakMap, где ключом служит объект context. Важна идентичность: два одинаково выглядящих объекта создают два независимых scope.
type RequestContext = {
requestId: string;
userId?: string;
};
class RequestState {
constructor(readonly context: RequestContext) {}
}
const container = await new Container()
.add(RequestState, { scope: InjectScope.REQUEST })
.build();
const context: RequestContext = { requestId: 'req-42' };
const first = await container.getOrFail(RequestState, context);
const second = await container.getOrFail(RequestState, context);
const third = await container.getOrFail(RequestState, { requestId: 'req-42' });
console.log(first === second); // true
console.log(first === third); // false
Для class providers context добавляется после всех внедрённых зависимостей. Фабрика получает его вторым аргументом:
const REQUEST_ID = Symbol('REQUEST_ID');
container.add(REQUEST_ID, {
scope: InjectScope.REQUEST,
valueFactory: (_dependencies, context?: RequestContext) => context?.requestId,
});
Вызов get() или getOrFail() request-scoped provider без объекта context выбросит REQUEST_SCOPE_CONTEXT_REQUIRED.
Создавайте один context на реальный запрос или задачу и передавайте ту же ссылку во все верхнеуровневые разрешения этой операции.
Lifecycle hooks и ленивое разрешение
По умолчанию providers ленивы. Регистрация сохраняет правила создания, но не создаёт все значения сразу.
onInitialized()
Если у разрешённого экземпляра есть onInitialized(), контейнер вызывает его сразу после создания:
class Cache implements IOnInitialized {
onInitialized() {
console.log('Cache created');
}
}
В v2.0.0 контейнер не ожидает результат hook, хотя TypeScript-контракт допускает promise. Критичную асинхронную инициализацию размещайте в ожидаемой valueFactory; используйте onInitialized() для синхронных действий после создания.
onFinalized() и build()
build() — alias для finalize(). Во время этого прохода контейнер вызывает и ожидает onFinalized() у зарегистрированных классов и объектов, реализующих hook:
class Routes implements IOnFinalized {
async onFinalized() {
await this.compileRoutes();
}
private async compileRoutes() {}
}
const container = await new Container()
.add(Routes)
.build();
Класс с onFinalized() разрешается во время сборки, поэтому он уже не остаётся полностью ленивым. Классы без этого hook не создаются до первого запроса.
Не добавляйте onFinalized() request-scoped классу. Во время сборки request context отсутствует, поэтому контейнер не сможет безопасно разрешить такой provider.
Вызывайте build() один раз после всех регистраций. Повторный вызов снова запустит finalization hooks.
Получение сервисов
У контейнера два метода разрешения:
const optional = await container.get(UserService);
// UserService | null
const required = await container.getOrFail(UserService);
// UserService или DependencyInjectionError
Используйте get(), когда отсутствие сервиса является ожидаемой веткой. Для обязательных прикладных сервисов выбирайте getOrFail(), чтобы ошибки композиции проявлялись ближе к запуску.
Оба метода принимают необязательный context вторым аргументом:
const handler = await container.getOrFail(RequestHandler, requestContext);
Разрешение рекурсивно и асинхронно. Сначала создаются зависимости, затем они передаются target в порядке inject.
Логирование
Каждый контейнер предоставляет logger через container.logger. Стандартный ConsoleLoggerImpl делегирует error, warn, info, debug и trace глобальному console.
Можно передать экземпляр, класс или фабрику без аргументов:
class AppLogger implements ILogger {
error(message: unknown, ...args: unknown[]) {}
warn(message: unknown, ...args: unknown[]) {}
info(message: unknown, ...args: unknown[]) {}
debug(message: unknown, ...args: unknown[]) {}
trace(message: unknown, ...args: unknown[]) {}
}
const byClass = new Container({ logger: AppLogger });
const byFactory = new Container({ logger: () => new AppLogger() });
const byInstance = new Container({ logger: new AppLogger() });
byClass.logger.info('Container ready');
Сейчас logger является доступным средством контейнера; v2.0.0 не пишет автоматические логи разрешения. Если он нужен бизнес-сервисам, зарегистрируйте application logger как обычный provider.
Обработка ошибок
Ошибки контейнера представлены классом DependencyInjectionError. Он содержит code, необязательные поля target и dependency, а также метод hasCode().
try {
await container.getOrFail(UserService);
} catch (error) {
if (
error instanceof DependencyInjectionError &&
error.hasCode(ErrorCode.UNKNOWN_TARGET)
) {
console.error('Composition root собран не полностью');
}
throw error;
}
| Код | Значение | Причина |
|---|---|---|
UNKNOWN_SCOPE |
1 | В provider передан неизвестный контейнеру scope |
UNKNOWN_TARGET |
2 | Обязательный selector или dependency не зарегистрирован |
TARGET_TYPE_BAD_RESOLVER |
3 | Selector невозможно создать и у него нет корректной фабрики |
TARGET_NULL |
4 | Selector при регистрации равен null или undefined |
TARGET_DUPLICATE |
5 | Selector уже зарегистрирован |
REQUEST_SCOPE_CONTEXT_REQUIRED |
6 | Request-scoped разрешение не получило объект context |
SINGLETONE_SCOPE_WRONG_CONTEXT |
7 | Singleton зависит от request-scoped provider |
Большинство этих ошибок говорит о неправильной композиции. Лучше обнаружить их при запуске приложения или в smoke test контейнера, чем восстанавливаться внутри бизнес-логики.
Структура production-приложения
Практический проект может держать DI wiring отдельно от доменного кода:
src/
├── application/
│ └── user-service.ts
├── domain/
│ └── user.ts
├── infrastructure/
│ ├── postgres-user-repository.ts
│ └── console-logger.ts
├── interfaces/
│ └── http-server.ts
└── container.ts # composition root
Рекомендуемые границы:
- Domain и application-классы получают collaborators через конструкторы.
- Infrastructure-модули реализуют эти контракты.
container.tsимпортирует конкретные реализации и связывает selectors.- Startup разрешает только верхнеуровневые адаптеры: сервер или worker.
- Request adapters создают context и разрешают request-scoped entry points.
Для контрактов, существующих только на уровне типов, используйте symbols:
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
Свяжите конкретный адаптер в composition root:
container
.add(USER_REPOSITORY, {
valueFactory: () => new PostgresUserRepository(),
})
.add(UserService, {
inject: [USER_REPOSITORY],
});
Пример HTTP-запроса
Контейнер не зависит от фреймворка. Создайте один объект context на HTTP-границе и используйте его для разрешения request-scoped entry point:
import { Container, InjectScope } from '@zirion/ioc';
type HttpContext = {
requestId: string;
request: Request;
};
class RequestLogger {
constructor(readonly context: HttpContext) {}
info(message: string) {
console.info(`[${this.context.requestId}] ${message}`);
}
}
class RequestHandler {
constructor(
private readonly users: UserService,
private readonly logger: RequestLogger,
readonly context: HttpContext,
) {}
async handle() {
this.logger.info('Handling request');
return new Response('ok');
}
}
const container = await new Container()
.add(UserService)
.add(RequestLogger, { scope: InjectScope.REQUEST })
.add(RequestHandler, {
inject: [UserService, RequestLogger],
scope: InjectScope.REQUEST,
})
.build();
export async function handleRequest(request: Request) {
const context: HttpContext = {
request,
requestId: crypto.randomUUID(),
};
const handler = await container.getOrFail(RequestHandler, context);
return handler.handle();
}
Singleton UserService разделяется между запросами. RequestLogger и RequestHandler повторно используются только внутри одного объекта context и могут быть удалены сборщиком мусора, когда context станет недостижимым.
Тестирование сервисов и контейнера
Constructor injection позволяет большинству unit-тестов вообще не использовать контейнер:
const repository: UserRepository = {
async findById(id) {
return { id, name: 'Test user' };
},
};
const service = new UserService(repository);
Container test нужен для проверки самого composition root:
import { describe, expect, it } from 'vitest';
it('builds the user graph', async () => {
const container = await new Container()
.add(USER_REPOSITORY, {
valueFactory: () => ({
findById: async (id: string) => ({ id, name: 'Test user' }),
}),
})
.add(UserService, { inject: [USER_REPOSITORY] })
.build();
await expect(container.getOrFail(UserService)).resolves.toBeInstanceOf(UserService);
});
Создавайте новый контейнер для каждого теста. Повторная регистрация и overrides намеренно не поддерживаются, поэтому общий изменяемый тестовый контейнер усложняет замены и может переносить singleton state между тестами.
Для request scope явно проверяйте identity:
const requestA = {};
const requestB = {};
expect(await container.getOrFail(RequestState, requestA))
.toBe(await container.getOrFail(RequestState, requestA));
expect(await container.getOrFail(RequestState, requestA))
.not.toBe(await container.getOrFail(RequestState, requestB));
Переход с v1 на v2
Вторая версия меняет разрешение и создание providers:
| Паттерн v1 | Замена в v2 |
|---|---|
container.get(Service) как синхронное значение |
await container.get(Service) |
container.getOrFail(Service) как синхронное значение |
await container.getOrFail(Service) |
Поле provider value |
valueFactory: () => value |
| Только синхронная фабрика | Фабрика может вернуть значение или promise |
| Нетипизированная цепочка регистрации | Каждый add() переносит selector в тип контейнера |
Только термин finalize() |
build() доступен как alias |
Последовательность миграции:
- Замените поля
valueу providers наvalueFactory. - Добавьте
awaitко всем вызовам разрешения и распространитеasyncдо их границ. - Ожидайте
build()илиfinalize()во время запуска. - Проверьте пользовательские фабрики: первый параметр — массив разрешённых зависимостей.
- Обновите runtime до Node.js 20 или новее.
- Запустите smoke test композиции, разрешающий каждый верхнеуровневый entry point приложения.
Текущие ограничения и границы дизайна
Понимание отсутствующих возможностей — часть безопасного использования контейнера. В v2.0.0:
- поддерживается внедрение через конструктор и фабрику; property и method injection не реализованы;
- нет декораторов и автоматического обнаружения через
reflect-metadata; - существуют только singleton и request scope; transient scope не реализован;
- повторная регистрация selector запрещена; override и multi-binding не реализованы;
- все зависимости должны существовать до первого разрешения, хотя взаимный порядок
add()не важен; - повторяющиеся элементы одного массива
injectсхлопываются в один аргумент; - falsy-результаты singleton-фабрики создаются повторно вместо кеширования;
- автоматическая проверка циклических зависимостей не реализована;
- изолированные дочерние контейнеры и dependency groups не реализованы;
onInitialized()вызывается, но его promise не ожидается;- контейнер не освобождает ресурсы автоматически;
- logger контейнера не трассирует разрешения автоматически.
Эти ограничения сохраняют библиотеку небольшой, но влияют на архитектуру приложения. Стройте ацикличные графы, централизуйте регистрацию, выполняйте асинхронное создание в фабриках и закрывайте базы данных или серверы в собственном shutdown handler.
API-справочник
new Container(options?)
Создаёт пустой контейнер.
const container = new Container({ logger });
options.logger принимает экземпляр ILogger, конструктор класса ILogger или фабрику без аргументов, возвращающую ILogger. По умолчанию используется ConsoleLoggerImpl.
container.add(selector, options?)
Регистрирует provider и возвращает тот же контейнер с расширенным типом selectors, поддерживая fluent composition.
container.add(Service, {
inject: [DependencyA, DependencyB],
scope: InjectScope.SINGLETON,
valueFactory: async ([a, b], context) => new Service(a, b),
});
| Опция | По умолчанию | Назначение |
|---|---|---|
inject |
undefined |
Упорядоченные selectors, разрешаемые до provider |
scope |
InjectScope.SINGLETON |
Правило кеширования singleton или request |
valueFactory |
undefined |
Синхронная или асинхронная функция создания значения |
Для class selector valueFactory необязательна. Другим видам selectors она необходима.
container.get(selector, context?)
Возвращает Promise<Result | null>. Для неизвестного selector результатом будет null. Другие ошибки разрешения отклоняют promise.
container.getOrFail(selector, context?)
Возвращает Promise<Result>. Неизвестный selector приводит к DependencyInjectionError с кодом UNKNOWN_TARGET.
container.finalize() / container.build()
Запускает finalization hooks и возвращает Promise<Container>. build() делегирует вызов finalize().
container.logger
Возвращает logger, заданный при создании контейнера.
InjectScope
enum InjectScope {
SINGLETON = 'singleton',
REQUEST = 'request',
}
Lifecycle-контракты
interface IOnInitialized {
onInitialized(): void | Promise<void>;
}
interface IOnFinalized {
onFinalized(): void | Promise<void>;
}
Публичные utility types
Пакет также экспортирует Type, MaybePromise, TSelector, TValueFactory, TTargetOptions, TStorageEntry, TContainerOptions, ILogFunction и ILogger. Для application extension points обычно нужны TTargetOptions и ILogger; storage types описывают внутреннее устройство и редко требуются потребителям.
Решение проблем
get() возвращает null
Selector не зарегистрирован. Убедитесь, что в add() и get() приходит одна и та же строка, symbol, класс или ссылка на объект. Для обязательных сервисов используйте getOrFail().
UNKNOWN_TARGET при разрешении зарегистрированного класса
Один из selectors в его inject отсутствует в момент разрешения. Завершите регистрацию графа до первого get() или build() и проверьте импорты symbols на случай случайного создания двух токенов.
TARGET_TYPE_BAD_RESOLVER
Selector не является классом и не получил корректную valueFactory. Строки, symbols и объекты невозможно сконструировать автоматически.
REQUEST_SCOPE_CONTEXT_REQUIRED
Передайте объект вторым аргументом верхнеуровневого разрешения и повторно используйте ту же ссылку на протяжении запроса:
await container.getOrFail(RequestHandler, requestContext);
SINGLETONE_SCOPE_WRONG_CONTEXT
Singleton зависит от request-scoped provider. Переведите родителя в request scope, передавайте request data параметром метода или внедрите фабрику, явно принимающую context.
Provider создаётся раньше ожидаемого
Проверьте, реализует ли он onFinalized(). build() разрешает такие class providers, чтобы выполнить hook.
Singleton-фабрика вызывается несколько раз
В v2.0.0 falsy-значения не распознаются как закешированный singleton. Возвращайте объект-обёртку, например { value: false }, или сделайте фабрику безопасной для повторного вызова.
Асинхронный onInitialized() не успевает завершиться
В v2.0.0 этот hook не ожидается. Перенесите обязательную асинхронную инициализацию в ожидаемую valueFactory.
Ресурсы остаются открытыми при завершении приложения
У контейнера нет фазы disposal. Добавьте application shutdown handler, явно закрывающий серверы, database pools, очереди и другие долгоживущие ресурсы.
Эта документация описывает публичное поведение @zirion/ioc v2.0.0. Детали реализации и историю версий можно найти в репозитории и на странице проекта.