Step-by-step guide · API reference
@zirion/ioc
A practical guide to @zirion/ioc for Node.js: register services, choose lifetimes, fix setup errors, and test your application.
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
yarn add @zirion/ioc
pnpm add @zirion/ioc
Use Node.js 20 or newer. This guide uses @zirion/ioc@2.0.0. Its build(), get(), and getOrFail() methods return promises, so use await. 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 } 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));
Notice three things:
- Register every dependency before asking for a service. The order of
add()calls does not matter once registration is complete. - By default, the first request creates one instance that later requests reuse.
- 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 } 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();
}
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();
}
}
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:
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' };
container.add(metricsToken, {
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 2.0.0 does not support replacing a registration or binding several providers to one selector.
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],
});
Do not list the same selector twice in one inject array. In v2.0.0 duplicates collapse into one argument.
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. For a request-scoped provider, its second argument is the current context.
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 waits for an async factory. It then reuses the result according to its scope. In v2.0.0, a singleton factory that returns false, 0, '', or null runs again on the next request. Wrap such a value in an object if it must be created only once.
A factory can return a class constructor, which the container then creates. 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 2.0.0 has two scopes:
| Scope | Creation | Reuse | Typical use |
|---|---|---|---|
InjectScope.SINGLETON |
When first requested | Reused by the whole container | Database pool, shared service |
InjectScope.REQUEST |
When first requested with a context | Reused only with that same context object | Request data, request logger |
Singleton is the default. These two calls return the same object:
container.add(UserRepository);
const first = await container.getOrFail(UserRepository);
const second = await container.getOrFail(UserRepository);
console.log(first === second); // true
Choose request scope when a value belongs to one request or job:
container.add(RequestState, {
scope: InjectScope.REQUEST,
});
There is no transient scope in v2.0.0. To make a fresh value every time, inject a factory function or create that value inside the service that uses it.
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.
The container prevents this dependency and throws ErrorCode.SINGLETONE_SCOPE_WRONG_CONTEXT when you try to resolve the singleton. This is the exact API name for the error code.
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');
}
}
In v2.0.0 the container does not wait for a promise returned by onInitialized(). Put required async setup in an awaited valueFactory. Use this hook for synchronous work.
During build: onFinalized()
build() calls finalize(). It creates registered classes or objects with onFinalized() and waits for their hooks:
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 created during build(). Other classes are still created only when requested.
Do not use onFinalized() on a request-scoped class. build() has no request context to pass to it.
Call build() once after registration. Calling it again runs finalization hooks again.
Get a service
Choose one of two methods:
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);
Resolution is async. The container creates dependencies first and passes them to the target in inject order.
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 in v2.0.0. 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 needs a request-scoped value |
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 example creates one context object per HTTP request and passes it to the container:
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();
}
UserService is shared by the container. RequestLogger and RequestHandler are reused only while the same context object is used. When nothing refers to the context anymore, its cached request values can be garbage-collected.
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 { 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 new container for each test. Version 2.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));
Upgrade from v1 to v2
Version 2 changes how you create and retrieve values:
| v1 pattern | v2 replacement |
|---|---|
Synchronous container.get(Service) |
await container.get(Service) |
Synchronous container.getOrFail(Service) |
await container.getOrFail(Service) |
value in provider options |
valueFactory: () => value |
| Factory returns only a direct value | Factory can return a value or a promise |
| Registration chain without selector types | Each add() updates the container type |
Only finalize() |
build() also calls finalize() |
To update an application:
- Replace each provider
valuewithvalueFactory. - Add
awaittoget()andgetOrFail(). Make their callers async where needed. - Await
build()orfinalize()at startup. - Check custom factories: their first argument is an array of resolved dependencies.
- Use Node.js 20 or newer.
- Add a small test that resolves each top-level service.
What v2.0.0 does not do
Keep these limits in mind when designing your application:
- dependencies can be passed through constructors or factories, but not properties or methods;
- there are no decorators and no automatic TypeScript metadata lookup;
- scopes are singleton and request only; there is no transient scope;
- you cannot replace a registration or bind several providers to one selector;
- register dependencies before resolving a value; the order of
add()calls is otherwise flexible; - a repeated selector in one
injectlist becomes one constructor argument; - a singleton factory returning
false,0,'', ornullruns again on the next request; - circular dependencies are not detected for you;
- there are no child containers or dependency groups;
onInitialized()runs, but its promise is not awaited;- the container does not close resources automatically;
- the container logger does not trace resolutions automatically.
Keep the dependency graph free of cycles. Register providers in one place, use factories for async setup, and close database connections or servers when your application shuts down.
API reference
new Container(options?)
Starts an empty container.
const container = new Container({ logger });
options.logger can be an ILogger object, class, or zero-argument factory. Without it, the container uses ConsoleLoggerImpl.
container.add(selector, options?)
Registers one provider and returns the container, with the selector added to its TypeScript type.
container.add(Service, {
inject: [DependencyA, DependencyB],
scope: InjectScope.SINGLETON,
valueFactory: async ([a, b], context) => new Service(a, b),
});
| Option | Default | Description |
|---|---|---|
inject |
undefined |
Selectors to resolve before creating this value, in argument order |
scope |
InjectScope.SINGLETON |
Whether this value is shared or tied to a context |
valueFactory |
undefined |
A function that creates the value; it may be async |
A class selector can be created without a factory. Other selectors need valueFactory.
container.get(selector, context?)
Returns a promise for the value or null when the selector is unknown. Other resolution errors still reject the promise.
container.getOrFail(selector, context?)
Returns a promise for the value. An unknown selector throws DependencyInjectionError with code UNKNOWN_TARGET.
container.finalize() / container.build()
Runs and awaits finalization hooks, then returns the container. build() calls finalize().
container.logger
The logger set 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>;
}
Other exported types
The package also exports Type, MaybePromise, TSelector, TValueFactory, TTargetOptions, TStorageEntry, TContainerOptions, ILogFunction, and ILogger. Most application code needs only TTargetOptions or ILogger; storage types are mainly for library internals.
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()
Check for onFinalized(). build() creates providers with that hook so it can run the hook.
A singleton factory runs again
A factory returning false, 0, '', or null is not cached as a singleton in v2.0.0. Return an object such as { value: false } or make repeat calls safe.
Async setup in onInitialized() is unfinished
The container does not await this hook. Move required async setup to an awaited valueFactory.
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 v2.0.0. See the source repository for implementation details and the project page for release notes.