On this page

@zirion/ioc · Migration guide

Migration from v2 to v3

Upgrade @zirion/ioc from v2 to v3: preserve provider lifetimes, retain TypeScript inference, and verify factories and lifecycle hooks.

Updated · ·

What changes first

The registration API still uses add(), inject, and valueFactory; existing async get() and getOrFail() calls still return promises. The main breaking change is the default lifetime: a provider with no explicit scope changes from singleton in v2 to transient in v3.

Start by preserving existing behavior. Introduce transient lifetimes and synchronous resolution after the upgrade is working.

Area v2 v3
Scope when omitted SINGLETON TRANSIENT
Resolution Async get / getOrFail Async API plus getSync / getOrFailSync
Factory value types Limited inference Registered selector and factory result inference
Duplicate inject entries Collapse Preserve order and repetitions
Async onInitialized() Promise not awaited Promise awaited
Singleton falsy results May be recreated Cached, including null and undefined
Concurrent cached resolution Could create more than one value Shares pending singleton/request work
Circular inject graph No cycle error CIRCULAR_DEPENDENCY
Class finalization Could reach request providers Only singleton classes are instantiated

Update the package and runtime

Use Node.js 20 or newer in development, CI, and production. Install the new major version with your package manager:

npm install @zirion/ioc@^3.0.0
# or: yarn add @zirion/ioc@^3.0.0
# or: pnpm add @zirion/ioc@^3.0.0

Run the application’s type check and tests after each migration step. Do not remove await from existing resolution calls yet.

Preserve shared instances

Review every registration without scope. Database pools, caches, shared state, configuration loaders, and services with startup hooks often need explicit singleton scope.

Before, in v2:

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

class Store {
  users: string[] = [];
}

const container = new Container().add(Store);
const first = await container.getOrFail(Store);
const second = await container.getOrFail(Store);
console.log(first === second); // true in v2

After, preserving that behavior in v3:

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

class Store {
  users: string[] = [];
}

const container = new Container()
  .add(Store, { scope: InjectScope.SINGLETON });

const first = await container.getOrFail(Store);
const second = await container.getOrFail(Store);
console.log(first === second); // true in v3

Without the explicit scope, each resolution creates a separate Store, and mutations to one instance are absent from the other. A factory that opens a database connection also runs for every transient resolution; choose a singleton pool instead.

Keep explicit REQUEST registrations as they are. Use one context object per request, and pass that same object to every resolution within the request. Do not move request state into a singleton. A transient can forward context to request dependencies; placing it under a singleton does not make that request dependency safe.

Retain the type returned by add()

V3 knows which selectors are registered and infers factory results. This existing pattern mutates the runtime container but leaves the variable typed as an empty container:

const container = new Container();
container.add('port', { valueFactory: () => 3000 });
// container.getOrFail('port'); // TypeScript error in v3

Keep the returned type instead:

const container = new Container()
  .add('port', { valueFactory: () => 3000 });

const port = await container.getOrFail('port'); // number

For modular registrations, let each composition helper return its registration chain and retain that result. Avoid annotating the result as a bare Container, which loses the registered-selector type. Use ReturnType<typeof createContainer> when another function needs the completed application container type.

The type system does not prove that inject lists match constructors or contain registered dependencies. Keep integration tests that resolve your top-level services.

Review initialization and finalization

Async onInitialized() now completes before an instance becomes available. A failed hook rejects resolution and allows a later retry. If startup used to launch work in this hook without waiting, resolution can now take longer or expose a startup error. Treat required work as initialization; start unrelated background tasks explicitly in application code.

build() / finalize() only instantiates singleton classes with onFinalized(). Set SINGLETON explicitly on classes that need that startup hook. Object selectors implementing onFinalized() are still called. Request and transient class hooks do not run during build.

build() does not resolve every registered graph and is not resource disposal. Continue closing servers, connections, and clients in application shutdown code.

Review factories and inject lists

  • Independent async dependencies run in parallel. If factory B depends on factory A’s result, register A in B’s inject; do not use registration order as an initialization schedule.
  • The inject array is copied during add(). Finish constructing it before registration. Mutating it later no longer changes the graph.
  • Duplicates remain separate constructor arguments. A repeated singleton passes the same object twice; a repeated transient creates two values. Check code that relied on v2 collapsing duplicates.
  • Ordinary functions returned by factories remain function values; returned classes are instantiated. Prefer an explicit factory returning the intended object when the distinction matters.
  • Cached factories store all successful values, including false, 0, '', null, undefined, and NaN. getOrFail() checks registration, not the non-nullness of the result.
  • Concurrent singleton and request resolutions share one pending operation. After failure, a later resolution retries creation. Make initialization safe to retry.
  • Cyclic inject graphs now throw CIRCULAR_DEPENDENCY. Extract shared behavior or split setup and execution instead of hiding lookups inside a factory.

Introduce synchronous calls deliberately

The new sync methods are optional. Existing async calls need no rewrite.

const container = new Container()
  .add('port', { valueFactory: () => 3000 });

const port = container.getOrFailSync('port');
console.log(port); // 3000, not a Promise

Use sync resolution only when every factory, dependency, and initialization hook in the graph is synchronous. Otherwise ASYNC_RESOLUTION_REQUIRED is thrown. A sync call can start an async factory before discovering its promise, so it is not a harmless capability check. Keep await for graphs that may need asynchronous initialization.

Verify behavior after the upgrade

  1. Check singleton identity and shared state across separate resolutions.
  2. Check that one request context reuses request values and different contexts are isolated.
  3. Check that intended transients create fresh values, including repeated injections.
  4. Resolve cached providers concurrently and verify one factory call.
  5. Exercise failed factories and async hooks, and verify that a later retry works.
  6. Verify startup hooks on explicit singleton classes and resource cleanup at shutdown.
  7. Run TypeScript checks and a smoke test for your ESM or CommonJS entry point.

Continue with the v3 guide and runnable examples. The v2 guide remains available for comparison.

Back to top