On this page

@zirion/ioc · Migration guide

Migration from other containers

Move services from Awilix, InversifyJS, or TSyringe to @zirion/ioc v3 with explicit registrations, lifetime mappings, and constructor adapters.

Updated · ·

Start with one application boundary

Choose one handler, worker, or module to migrate first. List its dependencies, current lifetimes, initialization order, and resource owners. Keep the old container for the rest of the application until that boundary works.

@zirion/ioc registers one provider per selector. Dependencies are ordered constructor arguments in inject, or an array passed to valueFactory. Async resolution uses await; getOrFailSync() works when the entire graph is synchronous. The default lifetime is transient, so mark shared resources explicitly.

Existing feature @zirion/ioc approach
Constant or existing instance valueFactory: () => instance, usually SINGLETON
Constructor injection add(Service, { inject: [Dependency] })
Decorator or property injection Move dependencies to the constructor or an adapter factory
Shared instance scope: InjectScope.SINGLETON
Request-specific instance scope: InjectScope.REQUEST with an explicit context object
Fresh instance Default TRANSIENT
Several providers under one token Separate selectors and a factory assembling an array
Resource disposal Application-owned shutdown code

The examples below are independent registration snippets. Replace the demonstration classes and values with your application’s equivalents.

From Awilix

Map .singleton() to SINGLETON, .transient() to the default, and .scoped() to REQUEST when one context object represents one application request. Awilix PROXY constructors receive a cradle object; keep that constructor shape with an adapter:

import { Container, InjectScope } from '@zirion/ioc';

class Database {
  users = ['Ada'];
}

class UserService {
  constructor(private readonly cradle: { db: Database }) {}

  list() {
    return this.cradle.db.users;
  }
}

const container = new Container()
  .add('db', {
    scope: InjectScope.SINGLETON,
    valueFactory: () => new Database(),
  })
  .add(UserService, {
    inject: ['db'],
    scope: InjectScope.REQUEST,
    valueFactory: ([db]) => new UserService({ db }),
  });

const context = { requestId: 'req-1' };
console.log((await container.getOrFail(UserService, context)).list());

New services can use direct constructor arguments instead of a cradle. With Awilix CLASSIC injection, translate parameter-name dependencies into an explicit inject list. A request context replaces application use of createScope() for caching, but does not provide a child container or override registrations. Awilix .disposer() and scope disposal need explicit cleanup in application code.

See the Awilix documentation for the original injection modes and lifetimes.

From InversifyJS

Keep class, string, and symbol tokens. Replace decorator-based declarations with ordered inject entries. This example uses a symbol for an interface without runtime metadata:

import { Container, InjectScope } from '@zirion/ioc';

interface Mailer {
  send(message: string): void;
}

const MAILER = Symbol('MAILER');

class ConsoleMailer implements Mailer {
  send(message: string) {
    console.log(message);
  }
}

class Notifications {
  constructor(private readonly mailer: Mailer) {}

  welcome() {
    this.mailer.send('Welcome');
  }
}

const container = new Container()
  .add(MAILER, {
    scope: InjectScope.SINGLETON,
    valueFactory: () => new ConsoleMailer(),
  })
  .add(Notifications, { inject: [MAILER] });

container.getOrFailSync(Notifications).welcome();

Map inSingletonScope() to SINGLETON and inTransientScope() to TRANSIENT. Inversify inRequestScope() caches within one resolution tree; @zirion/ioc REQUEST also shares separate resolutions given the same context. Choose the intended lifetime before mapping it.

Use await getOrFail() for getAsync() calls, and getOrFailSync() for a synchronous graph. Named or tagged bindings and multiple bindings need separate selectors or a selection factory; there is no direct getAll() equivalent.

See the Inversify binding guide and Container API for the original scope and resolution behavior.

From TSyringe

Replace @injectable() and @inject(TOKEN) with explicit registrations. Here CLOCK represents an interface dependency:

import { Container, InjectScope } from '@zirion/ioc';

interface Clock {
  now(): Date;
}

const CLOCK = Symbol('CLOCK');

class Report {
  constructor(private readonly clock: Clock) {}

  createdAt() {
    return this.clock.now().toISOString();
  }
}

const container = new Container()
  .add(CLOCK, {
    scope: InjectScope.SINGLETON,
    valueFactory: () => ({ now: () => new Date() }),
  })
  .add(Report, { inject: [CLOCK] });

console.log(container.getOrFailSync(Report).createdAt());

Lifecycle.Transient maps to TRANSIENT, and singleton registrations to SINGLETON. ContainerScoped can use REQUEST if the application supplies a context for each boundary that formerly owned a child container. ResolutionScoped lasts for one resolution chain and is not equivalent to a context shared across separate calls.

Interceptors, @injectAll, child registration overrides, and dispose() need adapters or remain in the old container during migration. Remove reflect-metadata and decorator compiler settings only after every dependent module is migrated.

See the TSyringe documentation for its lifecycle and disposal APIs.

Bridge an existing container

Resolve one legacy service ahead of time and register that same object in the new container. Both sides then use one resource owner instead of accidentally creating two singletons:

import { Container, InjectScope } from '@zirion/ioc';

declare const legacy: {
  resolve(token: string): { send(message: string): void };
};

const existingMailer = legacy.resolve('mailer');
const container = new Container().add('mailer', {
  scope: InjectScope.SINGLETON,
  valueFactory: () => existingMailer,
});

const mailer = container.getOrFailSync('mailer');
mailer.send('Hello');

For lazy creation, use valueFactory: () => legacy.resolve('mailer'). If the legacy lookup is async, use await container.getOrFail('mailer'). Avoid a bridge in both directions that creates a hidden cycle through container lookups; inject cycle validation cannot see those calls.

If the old API requires synchronous instances but a migrated graph is async, resolve the service at startup and pass the completed instance to the old code. Do not block on a promise from a synchronous accessor.

Replace multi-binding explicitly

Register implementations under separate selectors and build the group yourself:

const container = new Container()
  .add('email', { valueFactory: () => ({ name: 'email' }) })
  .add('sms', { valueFactory: () => ({ name: 'sms' }) })
  .add('channels', {
    inject: ['email', 'sms'],
    valueFactory: (channels) => channels,
  });

const channels = container.getOrFailSync('channels');
console.log(channels.map((channel: { name: string }) => channel.name));

There is one registration per selector and no registration override. Build a new container for tests or for a different application configuration.

Verify the new boundary

Resolve the migrated entry point and check identity across singleton, request, and transient lifetimes. Test async initialization and failures. Check resource cleanup separately: finalize() is a startup hook mechanism and does not replace disposal. Keep the old container until these checks pass for the migrated boundary.

Continue with the main guide and runnable examples.

Back to top