On this page

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

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

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:

  1. Both providers are registered before the first resolution. Their relative add() order is not important once the complete graph is in the container.
  2. Providers are singleton-scoped by default and remain lazy until they are resolved.
  3. build(), get(), and getOrFail() 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:

  1. Domain and application classes receive collaborators in constructors.
  2. Infrastructure modules implement those contracts.
  3. container.ts imports concrete implementations and connects selectors.
  4. Startup resolves only top-level adapters such as the server or worker.
  5. 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:

  1. Replace provider value fields with valueFactory.
  2. Add await to all resolution calls and propagate async to their boundaries.
  3. Await build() or finalize() during startup.
  4. Review custom factories: their first parameter is the array of resolved dependencies.
  5. Update the runtime to Node.js 20 or newer.
  6. 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-metadata discovery;
  • 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 inject array 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.

Back to top