Architecture guide · API reference
Dependency Injection with @zirion/ioc
A complete guide to structuring Node.js applications with explicit constructor injection, asynchronous factories, singleton and request scopes, lifecycle hooks, and testable composition roots.
Updated · · if-else.dev
What dependency injection solves
Dependency Injection (DI) separates constructing an object graph from using that graph. A service declares what it needs through constructor parameters; one composition root decides which concrete objects satisfy those needs.
Without DI, application code often constructs infrastructure in the middle of business logic:
class BillingService {
private database = new PostgresDatabase(process.env.DATABASE_URL!);
private mailer = new SmtpMailer(process.env.SMTP_URL!);
}
That class now knows configuration, concrete adapters, and their construction rules. Replacing the database in a test means changing the class or intercepting globals.
With constructor injection, it only describes its contract:
class BillingService {
constructor(
private readonly database: Database,
private readonly mailer: Mailer,
) {}
}
The result is not automatically “clean architecture.” DI gives you a place to make construction explicit. Good boundaries, small interfaces, and meaningful tests remain design decisions.
@zirion/ioc is a small container for this job. It uses an explicit inject list instead of decorators or emitted TypeScript metadata, resolves providers lazily, supports asynchronous factories, and has no runtime dependencies.
Installation and requirements
Choose the package manager used by your project and install the current release:
npm install @zirion/ioc
yarn add @zirion/ioc
pnpm add @zirion/ioc
Version 2.0.0 requires Node.js 20 or newer and publishes CommonJS, ES module, and TypeScript declaration builds. All examples below target @zirion/ioc@2.0.0 and use the asynchronous v2 API.
The main exports used throughout this guide are:
import {
Container,
InjectScope,
DependencyInjectionError,
ErrorCode,
type IOnInitialized,
type IOnFinalized,
type ILogger,
} from '@zirion/ioc';
Core vocabulary
| Term | Meaning in this guide |
|---|---|
| Container | Registry that stores provider definitions and resolves their object graph |
| Selector | Identity of a provider: a class, string, symbol, or object |
| Provider | A selector plus its factory, dependencies, and scope |
| Dependency | Another selector listed in the provider’s inject array |
| Resolution | Creating or retrieving the value associated with a selector |
| Scope | Rule that controls how long a resolved value is reused |
| Context | Object whose identity defines one request-scoped cache |
| Composition root | The application boundary where all providers are registered |
The container does not inspect TypeScript types at runtime. The following constructor annotation is useful to TypeScript, but it is not enough for the container:
class OrdersService {
constructor(private readonly repository: OrdersRepository) {}
}
You must connect the runtime selectors explicitly:
container.add(OrdersService, { inject: [OrdersRepository] });
Quick start
Register dependencies before the classes that consume them, build the container, and resolve the top-level service:
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));
Three details matter:
- Both providers are registered before the first resolution. Their relative
add()order is not important once the complete graph is in the container. - Providers are singleton-scoped by default and remain lazy until they are resolved.
build(),get(), andgetOrFail()are asynchronous and should be awaited.
Build the composition root
Keep container setup at one application boundary rather than spreading container.get() calls through domain code. This boundary is commonly called the 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();
}
Application startup becomes deliberately small:
const container = await createContainer({
databaseUrl: process.env.DATABASE_URL!,
});
const users = await container.getOrFail(UserService);
This structure gives tests a natural replacement point: create another container with test factories instead of adding environment branches inside business services.
Prefer passing resolved services into framework adapters. Treat the container as a composition tool, not as a global service locator.
Selectors and providers
add(selector, options?) registers exactly one provider. The selector is both its identity and the value used by inject, get, and getOrFail.
Class selectors
A class can act as its own selector and constructor:
class Clock {
now() {
return new Date();
}
}
container.add(Clock);
const clock = await container.getOrFail(Clock); // Clock
When no valueFactory is provided, a non-class selector cannot be constructed and resolution fails.
String selectors
Strings are convenient for small configuration values, but collisions become easier in large applications:
container.add('REGION', { valueFactory: () => 'eu-central-1' });
const region = await container.getOrFail('REGION');
Symbol selectors
Symbols are a strong default for interfaces and configuration contracts because they remain unique at runtime:
const MAILER = Symbol('MAILER');
container
.add(MAILER, { valueFactory: () => new SmtpMailer() })
.add(NotificationService, { inject: [MAILER] });
Object selectors
Object identity can also be a selector. The exact same object reference must be used for registration and resolution:
const metricsToken = { name: 'metrics' };
container.add(metricsToken, {
valueFactory: () => new MetricsClient(),
});
const metrics = await container.getOrFail(metricsToken);
Object, string, and symbol selectors require a valueFactory. Duplicate selectors are rejected; v2.0.0 has no override or multi-binding mode.
Constructor injection
The order of selectors in inject becomes the order of constructor arguments:
class CheckoutService {
constructor(
private readonly inventory: Inventory,
private readonly payments: Payments,
) {}
}
container
.add(Inventory)
.add(Payments)
.add(CheckoutService, {
inject: [Inventory, Payments],
});
Do not repeat the same selector in one inject array. Version 2.0.0 collects resolved arguments in a Set, so duplicate selectors collapse into one constructor argument.
Each dependency must already be registered when the parent is resolved. If Payments is missing, resolving CheckoutService throws DependencyInjectionError with ErrorCode.UNKNOWN_TARGET.
Constructor injection makes required collaborators visible and allows the class to stay independent of the container. Avoid this pattern inside a service:
// Avoid: dependency lookup is hidden inside business logic.
class CheckoutService {
constructor(private readonly container: Container<any>) {}
async checkout() {
const payments = await this.container.getOrFail(Payments);
}
}
Instead, inject Payments directly. Container lookup is appropriate at application boundaries such as startup and an HTTP request adapter.
Factories and asynchronous providers
Use valueFactory when a selector is not a class, construction is asynchronous, or creation needs custom logic. A factory receives resolved dependencies as an array and, for request-scoped providers, the current context as its second argument.
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();
The container awaits the factory result. The returned value is cached according to the provider scope, with one v2.0.0 edge case: falsy singleton values (false, 0, '', and null) are treated as not yet cached and their factory runs again. Wrap such a value in an object when one-time singleton creation matters.
A factory may also return a class constructor. In that case the container instantiates the returned class using the already resolved dependency arguments. This is supported, but returning the final value is usually easier to read.
Factories are useful for:
- configuration and constants;
- adapting third-party clients;
- asynchronous connections;
- selecting an implementation from configuration;
- creating test doubles without changing production classes.
Do not perform unrelated application startup in one large factory. Small factories make failure boundaries and ownership clearer.
Provider lifetimes
InjectScope contains two scopes in v2.0.0:
| Scope | Creation | Reuse | Typical use |
|---|---|---|---|
InjectScope.SINGLETON |
First resolution | One value for the container | Configuration, database pools, stateless services |
InjectScope.REQUEST |
First resolution for a context object | One value for that exact context object | Request metadata, unit of work, request logger |
Singleton is the default:
container.add(UserRepository);
const first = await container.getOrFail(UserRepository);
const second = await container.getOrFail(UserRepository);
console.log(first === second); // true
Request scope must be selected explicitly:
container.add(RequestState, {
scope: InjectScope.REQUEST,
});
There is no transient scope in v2.0.0. If every call must produce a new object, model that operation as an injected factory function or create the short-lived value in the consuming service.
Scope safety rule
A request-scoped provider may depend on singleton providers. A singleton provider cannot depend on a request-scoped provider because the singleton would capture data from one request and reuse it in others.
The container rejects that graph with ErrorCode.SINGLETONE_SCOPE_WRONG_CONTEXT—the spelling is part of the current public enum.
Request contexts
Request-scoped values are cached in a WeakMap keyed by the context object. Identity matters: two equal-looking objects create two independent scopes.
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
For class providers, the context is appended after all injected dependencies. For factories, it is the second factory argument:
const REQUEST_ID = Symbol('REQUEST_ID');
container.add(REQUEST_ID, {
scope: InjectScope.REQUEST,
valueFactory: (_dependencies, context?: RequestContext) => context?.requestId,
});
Calling get() or getOrFail() for a request-scoped provider without an object context throws REQUEST_SCOPE_CONTEXT_REQUIRED.
Use one context object per real request or job and pass that same reference to every top-level resolution for the operation.
Lifecycle hooks and lazy resolution
Providers are lazy by default. Registration records construction rules; it does not immediately create every value.
onInitialized()
If a resolved instance exposes onInitialized(), the container calls it immediately after construction:
class Cache implements IOnInitialized {
onInitialized() {
console.log('Cache created');
}
}
In v2.0.0 the hook’s return value is not awaited by the container, even though the TypeScript contract accepts a promise. Keep critical asynchronous setup in an awaited valueFactory; use onInitialized() for synchronous post-construction work.
onFinalized() and build()
build() is an alias for finalize(). During this pass, the container calls and awaits onFinalized() for registered classes and objects that implement the hook:
class Routes implements IOnFinalized {
async onFinalized() {
await this.compileRoutes();
}
private async compileRoutes() {}
}
const container = await new Container()
.add(Routes)
.build();
A class with onFinalized() is resolved during the build pass, so it is no longer fully lazy. Classes without this hook remain unresolved until requested.
Do not put onFinalized() on a request-scoped class. The build pass has no request context and cannot resolve it safely.
Call build() once after all registrations. Calling it again runs finalization hooks again.
Resolving services
The container exposes two resolution methods:
const optional = await container.get(UserService);
// UserService | null
const required = await container.getOrFail(UserService);
// UserService, or DependencyInjectionError
Use get() when absence is an expected branch. Use getOrFail() for required application services so configuration mistakes fail close to startup.
Both methods accept an optional context as their second argument:
const handler = await container.getOrFail(RequestHandler, requestContext);
Resolution is recursive and asynchronous. It constructs dependencies first, then passes them into the target in inject order.
Logging
Every container exposes a logger through container.logger. The default ConsoleLoggerImpl delegates error, warn, info, debug, and trace to the global console.
Provide a logger instance, class, or zero-argument factory:
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');
The logger is currently an exposed container facility; v2.0.0 does not emit automatic resolution logs. Inject your application logger as an ordinary provider when business services need it.
Error handling
Container failures use DependencyInjectionError. It contains code, optional target and dependency fields, and a hasCode() helper.
try {
await container.getOrFail(UserService);
} catch (error) {
if (
error instanceof DependencyInjectionError &&
error.hasCode(ErrorCode.UNKNOWN_TARGET)
) {
console.error('The composition root is incomplete');
}
throw error;
}
| Code | Value | Meaning |
|---|---|---|
UNKNOWN_SCOPE |
1 | Provider contains a scope unknown to the container |
UNKNOWN_TARGET |
2 | Required selector or injected dependency is not registered |
TARGET_TYPE_BAD_RESOLVER |
3 | Selector cannot be constructed and has no valid factory |
TARGET_NULL |
4 | Registration selector is null or undefined |
TARGET_DUPLICATE |
5 | Selector is already registered |
REQUEST_SCOPE_CONTEXT_REQUIRED |
6 | Request-scoped resolution received no object context |
SINGLETONE_SCOPE_WRONG_CONTEXT |
7 | Singleton attempts to depend on a request-scoped provider |
Most of these indicate a composition error. Prefer detecting them during application startup or a container smoke test rather than recovering inside business logic.
Production structure
A practical project can keep DI wiring separate from domain code:
src/
├── application/
│ └── user-service.ts
├── domain/
│ └── user.ts
├── infrastructure/
│ ├── postgres-user-repository.ts
│ └── console-logger.ts
├── interfaces/
│ └── http-server.ts
└── container.ts # composition root
Recommended boundaries:
- Domain and application classes receive collaborators in constructors.
- Infrastructure modules implement those contracts.
container.tsimports concrete implementations and connects selectors.- Startup resolves only top-level adapters such as the server or worker.
- Request adapters create a context and resolve request-scoped entry points.
Use symbols for contracts that exist only at the type level:
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
Then bind the concrete adapter in the composition root:
container
.add(USER_REPOSITORY, {
valueFactory: () => new PostgresUserRepository(),
})
.add(UserService, {
inject: [USER_REPOSITORY],
});
HTTP request example
The container is framework-agnostic. Create one context object at the HTTP boundary and use it to resolve the 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();
}
The singleton UserService is shared. RequestLogger and RequestHandler are reused only within the same context object and can be garbage-collected after that context becomes unreachable.
Testing containers and services
Constructor injection lets most unit tests avoid the container entirely:
const repository: UserRepository = {
async findById(id) {
return { id, name: 'Test user' };
},
};
const service = new UserService(repository);
Use a container test when you want to verify the composition root itself:
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);
});
Create a fresh container per test. Because duplicate registration and overrides are intentionally unsupported, shared mutable test containers make replacement awkward and can leak singleton state between cases.
For request scope tests, assert identity explicitly:
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));
Migrating from v1 to v2
Version 2 changes resolution and provider creation:
| v1 pattern | v2 replacement |
|---|---|
container.get(Service) as a synchronous value |
await container.get(Service) |
container.getOrFail(Service) as a synchronous value |
await container.getOrFail(Service) |
Provider value option |
valueFactory: () => value |
| Synchronous-only factory | Factory may return a value or promise |
| Untyped registration chain | Each add() carries its selector into the resulting container type |
Only finalize() terminology |
build() is available as an alias |
Migration sequence:
- Replace provider
valuefields withvalueFactory. - Add
awaitto all resolution calls and propagateasyncto their boundaries. - Await
build()orfinalize()during startup. - Review custom factories: their first parameter is the array of resolved dependencies.
- Update the runtime to Node.js 20 or newer.
- Run a composition smoke test that resolves every top-level application entry point.
Current limitations and design boundaries
Knowing what the container does not do is part of using it safely. In v2.0.0:
- injection is constructor/factory based; property and method injection are not implemented;
- there are no decorators or automatic
reflect-metadatadiscovery; - only singleton and request scopes exist; transient scope is not implemented;
- duplicate selectors are rejected; provider override and multi-binding are not implemented;
- all dependencies must exist before the first resolution, although their relative
add()order does not matter; - duplicate entries in one
injectarray collapse into one argument; - falsy singleton factory results are recreated instead of being cached;
- circular dependency detection is not implemented;
- isolated child containers or dependency groups are not implemented;
onInitialized()is invoked but its promise is not awaited;- the container does not automatically dispose resources;
- the container logger does not automatically trace resolutions.
These boundaries keep the library small, but they also shape application architecture. Keep graphs acyclic, centralize registration, perform asynchronous construction in factories, and close databases or servers in your own shutdown handler.
API reference
new Container(options?)
Creates an empty container.
const container = new Container({ logger });
options.logger accepts an ILogger instance, an ILogger class constructor, or a zero-argument factory returning an ILogger. It defaults to ConsoleLoggerImpl.
container.add(selector, options?)
Registers a provider and returns the same container with an expanded selector type, enabling fluent composition.
container.add(Service, {
inject: [DependencyA, DependencyB],
scope: InjectScope.SINGLETON,
valueFactory: async ([a, b], context) => new Service(a, b),
});
| Option | Default | Description |
|---|---|---|
inject |
undefined |
Ordered selectors resolved before the provider |
scope |
InjectScope.SINGLETON |
Singleton or request caching rule |
valueFactory |
undefined |
Sync or async function producing the provider value |
For a class selector, valueFactory is optional. Other selector kinds require it.
container.get(selector, context?)
Returns Promise<Result | null>. An unknown selector produces null. Resolution errors still reject the promise.
container.getOrFail(selector, context?)
Returns Promise<Result>. An unknown selector throws DependencyInjectionError with UNKNOWN_TARGET.
container.finalize() / container.build()
Runs finalization hooks and returns Promise<Container>. build() delegates to finalize().
container.logger
Returns the logger configured when the container was created.
InjectScope
enum InjectScope {
SINGLETON = 'singleton',
REQUEST = 'request',
}
Lifecycle contracts
interface IOnInitialized {
onInitialized(): void | Promise<void>;
}
interface IOnFinalized {
onFinalized(): void | Promise<void>;
}
Public utility types
The package also exports Type, MaybePromise, TSelector, TValueFactory, TTargetOptions, TStorageEntry, TContainerOptions, ILogFunction, and ILogger. Prefer TTargetOptions and ILogger in application-facing extension points; storage types describe internals and are rarely needed by consumers.
Troubleshooting
get() returns null
The selector was not registered. Confirm that the exact same string, symbol, class, or object reference reaches both add() and get(). Use getOrFail() for required services.
UNKNOWN_TARGET while resolving a registered class
One of the selectors in its inject list is missing at the time of resolution. Finish registering the graph before the first get() or build(), and check symbol imports for accidental duplicate tokens.
TARGET_TYPE_BAD_RESOLVER
The selector is not a class and no valid valueFactory was supplied. Strings, symbols, and objects cannot be constructed automatically.
REQUEST_SCOPE_CONTEXT_REQUIRED
Pass an object as the second argument to the top-level resolution and reuse that same object throughout the request:
await container.getOrFail(RequestHandler, requestContext);
SINGLETONE_SCOPE_WRONG_CONTEXT
A singleton depends on a request-scoped provider. Change the parent to request scope, move request data to a method parameter, or inject a factory that accepts the request context explicitly.
A provider initializes earlier than expected
Check whether it implements onFinalized(). build() resolves such class providers in order to execute that hook.
A singleton factory runs more than once
In v2.0.0, falsy values are not recognized as cached singleton values. Return an object wrapper such as { value: false }, or make the factory safely repeatable.
An async onInitialized() races with application code
This hook is not awaited in v2.0.0. Move required asynchronous initialization into an awaited valueFactory.
Resources remain open during shutdown
The container has no disposal phase. Add an application shutdown handler that closes servers, database pools, queues, and other long-lived resources explicitly.
This documentation describes the public behavior of @zirion/ioc v2.0.0. For implementation details and release history, see the source repository and the project page.