Step-by-step guide · v3
@zirion/ioc
A practical guide to @zirion/ioc v3: explicit injection, transient and request scopes, sync and async resolution, lifecycle hooks, examples, and migration.
Updated · · if-else.dev
Why pass dependencies in?
A service often needs other objects, such as a database or mailer. Dependency injection means passing those objects in, usually through the constructor, instead of creating them inside the service.
Here, BillingService creates its own database and mailer:
class BillingService {
private database = new PostgresDatabase(process.env.DATABASE_URL!);
private mailer = new SmtpMailer(process.env.SMTP_URL!);
}
To test this class with a fake database, you first have to change how the class creates its dependencies.
Instead, let the caller provide them:
class BillingService {
constructor(
private readonly database: Database,
private readonly mailer: Mailer,
) {}
}
Now a test can pass a fake database without changing BillingService. You still need to decide where the real objects are created and how the application uses them.
@zirion/ioc handles that setup. You list each dependency in inject. The container creates objects when they are needed and can wait for asynchronous factories. It has no runtime dependencies and needs no decorators.
Install the package
Run the command for your package manager:
npm install @zirion/ioc@^3.0.0
yarn add @zirion/ioc@^3.0.0
pnpm add @zirion/ioc@^3.0.0
Use Node.js 20 or newer. This guide uses @zirion/ioc@3.0.0. Its build(), get(), and getOrFail() methods return promises, so use await. For a fully synchronous dependency graph, getSync() and getOrFailSync() return values immediately. The package includes CommonJS, ES modules, and TypeScript types.
These are the main names you can import:
import {
Container,
InjectScope,
DependencyInjectionError,
ErrorCode,
type IOnInitialized,
type IOnFinalized,
type ILogger,
} from '@zirion/ioc';
Your first container
Register the database and the service, build the container, then ask for the service:
import { Container, InjectScope } 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, { scope: InjectScope.SINGLETON })
.add(UserService, { inject: [Database] })
.build();
const users = await container.getOrFail(UserService);
console.log(users.greet(1));
Notice three things:
- Register every dependency before asking for a service. The order of
add()calls does not matter once registration is complete. - Providers are transient by default: each resolution creates a new instance. The database explicitly uses
SINGLETON, so its instance is shared. - Wait for
build()andgetOrFail()withawait.
Terms used in this guide
| Term | Meaning in this guide |
|---|---|
| Container | Stores setup rules and creates the values you ask for |
| Selector | The key for a value: a class, string, symbol, or object |
| Provider | The rule for creating a value under a selector |
| Dependency | A value another provider needs, listed in inject |
| Resolution | Asking the container for a value |
| Scope | How long the container reuses a created value |
| Context | The object that identifies one request or job |
| Composition root | The place where the application registers its providers |
TypeScript types disappear when the code runs. This constructor type tells your editor what to expect, but does not tell the container what to create:
class OrdersService {
constructor(private readonly repository: OrdersRepository) {}
}
Tell the container which value to pass:
container.add(OrdersService, { inject: [OrdersRepository] });
Keep setup in one place
Put registrations near application startup. This setup file is often called the composition root. Other services should receive what they need through constructors.
import { Container, InjectScope } 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, { scope: InjectScope.SINGLETON, valueFactory: () => config })
.add(Database, { inject: [APP_CONFIG], scope: InjectScope.SINGLETON })
.add(UserRepository, { inject: [Database] })
.add(UserService, { inject: [UserRepository] })
.build();
}
Startup then needs only a few lines:
const container = await createContainer({
databaseUrl: process.env.DATABASE_URL!,
});
const users = await container.getOrFail(UserService);
Tests can build a separate container with test factories. Your services do not need special branches for tests.
Pass the resolved service to your HTTP handler, worker, or other entry point. Avoid calling the container throughout your business code.
Choose a key for each value
add(selector, options?) registers a value. Use the same selector in inject, get(), or getOrFail() to refer to it.
Use a class as a key
If the selector is a class, the container can create it:
class Clock {
now() {
return new Date();
}
}
const container = new Container().add(Clock);
const clock = await container.getOrFail(Clock); // Clock
For strings, symbols, and objects, provide a valueFactory. The container cannot construct them by itself.
Use a string as a key
Strings work for simple settings. In a larger application, two modules could accidentally use the same string:
const container = new Container()
.add('REGION', { valueFactory: () => 'eu-central-1' });
const region = await container.getOrFail('REGION');
Use a symbol as a key
A symbol avoids that collision. It is useful for a TypeScript interface or a configuration value:
const MAILER = Symbol('MAILER');
container
.add(MAILER, { valueFactory: () => new SmtpMailer() })
.add(NotificationService, { inject: [MAILER] });
Use an object as a key
An object can also be a selector. Pass the same object, not a new object with the same fields:
const metricsToken = { name: 'metrics' };
const container = new Container().add(metricsToken, {
scope: InjectScope.SINGLETON,
valueFactory: () => new MetricsClient(),
});
const metrics = await container.getOrFail(metricsToken);
Strings, symbols, and objects need a valueFactory. You cannot register the same selector twice in one container. Version 3.0.0 does not support replacing a registration or binding several providers to one selector. An empty string is a valid selector.
Keep TypeScript registration types
add() returns the same runtime container with a wider TypeScript type. Keep that returned type by chaining registrations or assigning the result to a new variable:
const container = new Container()
.add('port', { valueFactory: () => 3000 })
.add('config', {
scope: InjectScope.SINGLETON,
valueFactory: async () => ({ region: 'eu-central-1' }),
});
const port = container.getOrFailSync('port'); // number
const config = await container.getOrFail('config'); // { region: string }
// container.getOrFail('missing'); // TypeScript error
Registering on a separate line still changes the runtime container, but does not widen the original variable’s type. const empty = new Container(); empty.add('port', ...) leaves empty typed as an empty container. Prefer the chain above; do not silence the error with any.
The type system knows factory return values, including awaited values. A class returned by a factory is inferred as an instance. It does not check that inject arguments match constructor types or that every dependency is registered. Resolve each top-level graph in a startup or integration test.
Pass services through the constructor
List selectors in inject in the same order as the constructor parameters:
class CheckoutService {
constructor(
private readonly inventory: Inventory,
private readonly payments: Payments,
) {}
}
container
.add(Inventory)
.add(Payments)
.add(CheckoutService, {
inject: [Inventory, Payments],
});
The order and duplicate entries in inject are preserved. Injecting a singleton twice passes the same instance twice; injecting a transient twice creates two instances. The array is copied at registration, so later mutations do not change the dependency graph.
Register every required selector before asking for CheckoutService. If Payments is missing, resolution throws DependencyInjectionError with code UNKNOWN_TARGET.
The service can now use its dependencies without knowing about the container. Avoid looking them up inside the 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);
}
}
Pass Payments to the constructor instead. Call get() or getOrFail() at startup or at another application entry point.
Create values with factories
Use valueFactory for settings, asynchronous setup, or custom creation. It receives an array of resolved dependencies. Its second argument is the supplied context for request and transient providers. Singleton factories do not receive request context.
const DATABASE = Symbol('DATABASE');
const CONFIG = Symbol('CONFIG');
const container = await new Container()
.add(CONFIG, {
scope: InjectScope.SINGLETON,
valueFactory: () => ({ databaseUrl: process.env.DATABASE_URL! }),
})
.add(DATABASE, {
scope: InjectScope.SINGLETON,
inject: [CONFIG],
valueFactory: async ([config]) => {
const database = new DatabaseClient(config.databaseUrl);
await database.connect();
return database;
},
})
.build();
The container waits for an async factory and caches the result according to its scope. Singleton results are cached even when they are false, 0, '', null, undefined, or NaN. Concurrent singleton resolutions share one pending factory call. Request resolutions do the same when they use the same context object; failed factories can be retried.
A factory can return a class constructor, which the container then instantiates with the resolved dependencies and context. An ordinary function returned by a factory is kept as a value. Returning the finished value is usually easier to follow.
Common uses for a factory:
- read settings;
- set up an external client;
- wait for a connection;
- choose an implementation from settings;
- provide a fake object in a test.
Keep each factory focused on creating one value. This makes failures easier to find.
Choose how long values live
Version 3 has three scopes. Choose the lifetime for each provider deliberately:
| Scope | Creation | Reuse | Typical use |
|---|---|---|---|
InjectScope.TRANSIENT (default) |
Every resolution or injection | Not cached | Stateless service, independent operation |
InjectScope.SINGLETON |
First resolution | Shared by the container | Database pool, configuration, shared cache |
InjectScope.REQUEST |
First resolution with a context | Shared only with that exact context object | Request data, request logger |
These registrations make the difference visible:
class SharedStore {}
class Operation {}
const container = new Container()
.add(SharedStore, { scope: InjectScope.SINGLETON })
.add(Operation);
console.log(
container.getOrFailSync(SharedStore) === container.getOrFailSync(SharedStore),
); // true
console.log(
container.getOrFailSync(Operation) === container.getOrFailSync(Operation),
); // false
For request-specific values, register scope: InjectScope.REQUEST and pass a context. A transient can depend on a request provider if the caller supplies that context.
V2 used singleton as its default. Follow the v2 to v3 migration guide before updating existing registrations.
Do not put request data in a singleton
A singleton is created once and reused across requests. A value with InjectScope.REQUEST is created separately for each request. For example, RequestState might hold the current user’s ID. If a singleton kept Alice’s RequestState, it would still see Alice’s data while handling Bob’s request.
That is why a request-scoped value can use a singleton, but a singleton cannot take a request-scoped value as a dependency. If a shared service needs a user ID, pass it to a method. If it must keep request data between calls, make the service request-scoped too.
A direct singleton-to-request dependency throws ErrorCode.SINGLETONE_SCOPE_WRONG_CONTEXT. This is the exact API name for the error code. Do not hide a request dependency behind a transient inside a singleton: singletons do not forward request context, so the request dependency fails with REQUEST_SCOPE_CONTEXT_REQUIRED. Keep request state out of the entire singleton dependency graph.
Use one context per request
Pass an object to identify each request or job. Reuse that exact object within the request. Another object with the same fields starts a separate request 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
For a class, the context becomes the last constructor argument. For a factory, it is the second argument:
const REQUEST_ID = Symbol('REQUEST_ID');
container.add(REQUEST_ID, {
scope: InjectScope.REQUEST,
valueFactory: (_dependencies, context?: RequestContext) => context?.requestId,
});
If you ask for a request-scoped value without an object context, the container throws REQUEST_SCOPE_CONTEXT_REQUIRED.
Create one context object for each request or job. Pass it to every top-level get() or getOrFail() call for that operation.
Know when providers are created
Registering a provider usually does not create its value. The container waits until you ask for it.
After creation: onInitialized()
If a created object has onInitialized(), the container calls it right away:
class Cache implements IOnInitialized {
onInitialized() {
console.log('Cache created');
}
}
The container awaits a promise returned by onInitialized() before get() or getOrFail() completes. If the hook fails, resolution fails too and a later singleton or request resolution can retry creation. Synchronous resolution is available only when the hook is synchronous.
During build: onFinalized()
build() calls finalize(). It resolves singleton class selectors with onFinalized() and calls the hook on object selectors that implement it:
class Routes implements IOnFinalized {
async onFinalized() {
await this.compileRoutes();
}
private async compileRoutes() {}
}
const container = await new Container()
.add(Routes, { scope: InjectScope.SINGLETON })
.build();
A singleton class with onFinalized() is created during build(). Other classes are created only when requested. Finalization is not full validation of every registered dependency graph.
Request and transient classes are not instantiated by build() for finalization. Set SINGLETON explicitly when a class must participate in startup; do not expect a request hook to run without a context.
Call build() once after registration. Calling it again runs finalization hooks again.
Get a service
Choose the async API when any factory or initialization hook may need a promise:
const optional = await container.get(UserService);
// UserService | null
const required = await container.getOrFail(UserService);
// UserService, or DependencyInjectionError
Use get() if a missing value is normal. Use getOrFail() for a required service: it throws if the selector is missing.
Both methods accept a context as the second argument:
const handler = await container.getOrFail(RequestHandler, requestContext);
Independent async dependencies resolve in parallel, then their values are passed in inject order. If one factory requires another to finish first, express that dependency explicitly in its own inject list.
For a fully synchronous graph, use the synchronous API:
const optional = container.getSync(UserService);
const required = container.getOrFailSync(UserService);
A promise from any factory, dependency, or onInitialized() hook causes ASYNC_RESOLUTION_REQUIRED. A synchronous call may start an async factory before discovering its promise, so do not use it to probe whether a graph is synchronous. If the graph can be async, use await from the start. A pending singleton or request result started by a synchronous call remains available to an async resolution.
getOrFail() checks that a selector is registered; it does not prohibit a registered factory from returning null. TypeScript rejects unknown selectors in all four methods. At runtime, get() and getSync() return null when the registration is missing.
See the API reference for method signatures, provider options, and exported types.
Add a logger
You can access the container logger as container.logger. By default, it writes error, warn, info, debug, and trace messages to the console.
You can pass a logger object, a logger class, or a factory that creates one:
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 container does not log each resolution automatically. To log from your own services, register and inject a logger as you would any other dependency.
Handle setup errors
The container throws DependencyInjectionError when setup or resolution fails. Read its code to see what went wrong. The error may also include target and dependency; hasCode() checks a code for you.
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 | The scope is not supported |
UNKNOWN_TARGET |
2 | A required selector was not registered |
TARGET_TYPE_BAD_RESOLVER |
3 | The selector needs a factory but has none |
TARGET_NULL |
4 | The selector is null or undefined |
TARGET_DUPLICATE |
5 | The selector was registered already |
REQUEST_SCOPE_CONTEXT_REQUIRED |
6 | A request-scoped value has no object context |
SINGLETONE_SCOPE_WRONG_CONTEXT |
7 | A singleton directly needs a request-scoped value |
CIRCULAR_DEPENDENCY |
8 | The dependency graph contains a cycle |
ASYNC_RESOLUTION_REQUIRED |
9 | A synchronous call encountered async work |
These usually mean that container setup needs fixing. Catch them at startup or in a small container test instead of hiding them inside business code.
Organize application code
Keep container setup in one file and leave business code in its own files:
src/
├── application/
│ └── user-service.ts
├── domain/
│ └── user.ts
├── infrastructure/
│ ├── postgres-user-repository.ts
│ └── console-logger.ts
├── interfaces/
│ └── http-server.ts
└── container.ts # composition root
A simple rule for each layer:
- Pass dependencies to application classes through constructors.
- Put database, network, and other external code in separate modules.
- Register the concrete classes and selectors in
container.ts. - At startup, ask the container only for the server, worker, or other entry point.
- For each request, create a context and resolve the request-scoped entry point.
An interface has no runtime value, so use a symbol as its selector:
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
Then connect that symbol to the real implementation during setup:
container
.add(USER_REPOSITORY, {
valueFactory: () => new PostgresUserRepository(),
})
.add(UserService, {
inject: [USER_REPOSITORY],
});
Example: handle one HTTP request
This complete Fetch API example creates one context per request. The shared service is explicitly singleton; logger and handler belong to the request:
import { Container, InjectScope } from '@zirion/ioc';
type HttpContext = {
requestId: string;
request: Request;
};
class UserService {
async list() {
return [{ id: 1, name: 'Ada' }];
}
}
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 ${this.context.request.method}`);
return Response.json(await this.users.list());
}
}
const container = new Container()
.add(UserService, { scope: InjectScope.SINGLETON })
.add(RequestLogger, { scope: InjectScope.REQUEST })
.add(RequestHandler, {
inject: [UserService, RequestLogger],
scope: InjectScope.REQUEST,
});
export async function handleRequest(request: Request) {
const context: HttpContext = {
request,
requestId: crypto.randomUUID(),
};
const handler = await container.getOrFail(RequestHandler, context);
return handler.handle();
}
UserService is shared by the container. Repeated resolutions of RequestLogger or RequestHandler with the same context reuse that request’s instance. A different context gets independent instances. When nothing refers to the context anymore, its cached request values can be garbage-collected. Application code still owns any external resources.
For a complete server with node:http, see the runnable examples.
Example: inject a function value
Factories can return ordinary functions without the container invoking them. This is useful for callbacks, formatters, or adapters:
import { Container, InjectScope } from '@zirion/ioc';
const container = new Container().add('formatPrice', {
scope: InjectScope.SINGLETON,
valueFactory: () => (amount: number) => `$${amount.toFixed(2)}`,
});
const formatPrice = container.getOrFailSync('formatPrice');
console.log(formatPrice(12.5)); // $12.50
formatPrice is inferred as (amount: number) => string. Its factory is cached as a singleton; calling the formatter is ordinary application code.
Example: share asynchronous initialization
Two callers can request a singleton while its factory is still pending. Both receive the same result and the factory runs once:
import { Container, InjectScope } from '@zirion/ioc';
let attempts = 0;
const container = new Container().add('config', {
scope: InjectScope.SINGLETON,
valueFactory: async () => {
attempts += 1;
return { region: 'eu-central-1' };
},
});
const [first, second] = await Promise.all([
container.getOrFail('config'),
container.getOrFail('config'),
]);
console.log(first === second); // true
console.log(attempts); // 1
If the factory rejects, both callers see the error. A later resolution retries creation. Do not place non-repeatable operations in initialization unless the application can recover from a failed attempt.
Example: wait for an initialization hook
The instance becomes available only after its asynchronous hook completes:
import { Container, InjectScope, type IOnInitialized } from '@zirion/ioc';
class Catalog implements IOnInitialized {
items: string[] = [];
async onInitialized() {
this.items = await Promise.resolve(['book', 'pen']);
}
}
const container = new Container()
.add(Catalog, { scope: InjectScope.SINGLETON });
const catalog = await container.getOrFail(Catalog);
console.log(catalog.items); // ['book', 'pen']
The same rule applies when a factory returns an object with onInitialized(). getOrFailSync(Catalog) before initialization completes would throw ASYNC_RESOLUTION_REQUIRED; use the async API for this provider.
Test services and setup
For most unit tests, create the service directly and pass a fake dependency. You do not need the container:
const repository: UserRepository = {
async findById(id) {
return { id, name: 'Test user' };
},
};
const service = new UserService(repository);
Use a container test to check that registrations fit together:
import { 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 new container for each test. Version 3.0.0 does not support overriding a registration, and a shared container can keep singleton values from another test.
For request-scoped values, check that the same context gives the same value and another context gives a different one:
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));
Migration guides
- Upgrade an existing @zirion/ioc application from v2 to v3: keep singleton lifetimes, update types, and review factory and hook behavior.
- Move from Awilix, InversifyJS, or TSyringe: map registrations and lifetimes, adapt existing constructors, and migrate one module at a time.
- V2 documentation: the previous guide, including the earlier v1 to v2 migration.
Fix common problems
get() returns null
The selector was never registered. Make sure add() and get() use the same class, string, symbol, or object. For a required value, use getOrFail().
UNKNOWN_TARGET while resolving a registered class
A selector in the class’s inject list is missing. Register it before resolution and check that your modules import the same symbol.
TARGET_TYPE_BAD_RESOLVER
A string, symbol, or object selector needs a valid valueFactory.
REQUEST_SCOPE_CONTEXT_REQUIRED
Pass a context object as the second argument and reuse it throughout the request:
await container.getOrFail(RequestHandler, requestContext);
SINGLETONE_SCOPE_WRONG_CONTEXT
A singleton depends on a request-scoped provider. Make the parent request-scoped, pass request data to a method, or inject a factory that takes the context.
A value is created during build()
A singleton class has onFinalized(). build() resolves that class to run its startup hook. A transient class with the same hook is skipped.
A value is created again
V3 defaults to TRANSIENT. Use scope: InjectScope.SINGLETON if repeated resolutions must reuse the value. If an explicit singleton factory or initialization hook failed, the next resolution retries it.
CIRCULAR_DEPENDENCY
Two or more providers depend on each other through inject. Move shared behavior to another service or separate setup from execution. A factory that calls the container itself hides dependencies from graph validation; prefer explicit inject.
ASYNC_RESOLUTION_REQUIRED
A sync method reached a promise. Use await container.getOrFail(...), or make every factory and onInitialized() hook in that graph synchronous. An initialized synchronous singleton can be read synchronously later, but code should not rely on startup order to disguise an async graph.
Resources stay open at shutdown
The container has no shutdown step. Close servers, database pools, queues, and other long-lived resources in your application shutdown handler.
This guide covers @zirion/ioc v3.0.0. See the source repository for implementation details and the project page for release notes.