On this page

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 · ·

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

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:

  1. Register every dependency before asking for a service. The order of add() calls does not matter once registration is complete.
  2. By default, the first request creates one instance that later requests reuse.
  3. Wait for build() and getOrFail() with await.

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:

  1. Pass dependencies to application classes through constructors.
  2. Put database, network, and other external code in separate modules.
  3. Register the concrete classes and selectors in container.ts.
  4. At startup, ask the container only for the server, worker, or other entry point.
  5. 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:

  1. Replace each provider value with valueFactory.
  2. Add await to get() and getOrFail(). Make their callers async where needed.
  3. Await build() or finalize() at startup.
  4. Check custom factories: their first argument is an array of resolved dependencies.
  5. Use Node.js 20 or newer.
  6. 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 inject list becomes one constructor argument;
  • a singleton factory returning false, 0, '', or null runs 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.

Back to top