Documents injecute 1.x · Migrating from 0.x
Skip to content

Scoped state ​

Some state belongs to one unit of work: a user session, a request, a transaction, a queue job. There are two ways to give it to services, and this page compares them:

  • A scope: a fork for the unit of work, with services of its own. Its services may depend on the root's; the root's never depend on a scope's.
  • Patterns that keep every service in the root: the root is built once, and the state reaches the services as an argument, through a context, or through a singleton that hands out one instance per unit of work.
OptionForThe container's part
A scopeSeveral services that belong to the unit of work, share its instances and use the rootCreates, wires and disposes the scope's services
Pass it as an argumentValues of one operation: the order, the transactionNone
A factory and usingInstances an operation creates and releases: a unit of workRegisters the factory as a singleton, with its dependencies
A context accessorData many layers read: the trace id, the tenant, the userRegisters the accessor first, in the root
One instance per contextLazy instances per unit of work: a cache, a DataLoader, an identity mapRegisters a singleton that hands out one instance per context
Resources released at the endA transaction several layers share, released when the work endsRegisters a singleton that hands out one resource per context
A strategy chosen by the contextA different implementation per tenantRegisters the implementations and the singleton that picks one

What each costs:

  • A scope creates its services again for every unit of work, and a service that depends on a scope service belongs in the scope too. In return its services take the scoped state as ordinary dependencies, and the scope disposes them.
  • The patterns build the graph once, but the services that use the state know that it is scoped: they take it as an argument or read it through current().

Choosing at the end lists common needs with the option that fits each.

Scopes ​

A scope is a fork for one unit of work. Several services that belong to it share its instances and use the root's services; the scope wires them and disposes them when the work is done:

ts
const app = new DIContainer()
  .addSingleton('db', Database)
  .addSingleton('gateway', Gateway);

// Several services share one transaction and also need the app's services: the fork wires them,
// and disposes the transaction at the end (rolled back unless committed).
export async function placeOrder(item: string, quantity: number) {
  await using scope = app
    .fork()
    .addSingleton('tx', (db) => db.begin(), ['db'])
    .addSingleton('orders', OrderRepository, ['tx'])
    .addSingleton('payments', Payments, ['tx', 'gateway']);

  scope.get('orders').insert(item);
  if (quantity < 1) throw new Error('quantity must be at least 1');
  scope.get('payments').charge(quantity * 10);
  scope.get('tx').commit();
}

Open in the playground

  • The scope's singletons are the unit of work's instances: one tx here, shared by orders and payments, and rolled back by await using unless it was committed.
  • The direction is fixed. A scope's services may depend on the root's; a root service can't name a key that only a scope registers, so no singleton keeps the first scope's instance. Other containers call this a scoped lifetime.
  • Create the scope in one place (the handler, the job runner, a framework hook) and dispose it with await using.
  • A scope can decorate a root service for its own services, such as a logger with the request id: see Decorating a root service in a scope.

A root service that names a scope's key doesn't compile; the error points at the key:

ts
new DIContainer()
  .addSingleton('db', Database)
  .addSingleton('report', (tx) => tx, ['tx']);
// error: Type '"tx"' is not assignable to type 'Dependency<{}, { db: Database; }>'.

Scopes also fit work without AsyncLocalStorage (some edge runtimes), where passing the state explicitly would reach too many services.

Scope modules ​

A module can't register scoped services in the root: the root would create them once, for every unit of work. Give a feature with scoped services two modules, one for the root and one for the scope. The composition root applies the root modules; the place that creates the scope applies every scope module:

ts
// orders/orders.container.ts: the feature's two modules

/** What the app shares: registered once, in the root. */
export const addOrders = (c: ServiceRegistry) =>
  c.addSingleton('gateway', Gateway);

/** What one request owns: its scope module, applied to each request's scope. */
export const addOrdersRequest = (
  c: ServiceRegistry<{ db: Database; gateway: Gateway }>,
) =>
  c
    .addSingleton('tx', (db) => db.begin(), ['db'])
    .addSingleton('orders', OrderRepository, ['tx'])
    .addSingleton(
      'payments',
      (tx, gateway): Payments => new GatewayPayments(tx, gateway),
      ['tx', 'gateway'],
    )
    .addSingleton('checkout', Checkout, ['orders', 'payments', 'tx']);

// container.ts: the composition root applies the root modules

export const createApp = () =>
  new DIContainer().addSingleton('db', Database).extend(addOrders).seal();

export type App = ReturnType<typeof createApp>;

/** Every feature's scope module for a request, in one place. */
export const addRequestModules = (c: ServiceRegistry<ContainerServices<App>>) =>
  c.extend(addOrdersRequest);

// server.ts: one scope per request, created in one place, disposed at the end

export const createHandler =
  (app: App, addRequest = addRequestModules) =>
  async (item: string, quantity: number): Promise<void> => {
    await using scope = app.fork().extend(addRequest);
    scope.get('checkout').placeOrder(item, quantity);
  };

Open in the playground

  • The types keep the two apart: a scope module declares the root services it needs (ServiceRegistry<{ db: Database; gateway: Gateway }>), and extend() checks that the root provides them.
  • Don't register a scope's key in the root too (a default tx, say): root services that depend on it would keep the root's one. A scope module that registers it then throws INJECUTE_ALREADY_REGISTERED and names those services (see Overriding a parent's service in a fork).
  • A service that depends on a scope service belongs in the scope module too. Keep scope modules small: what they register is created again in every scope that uses it.

Testing scoped services ​

ts
// A root dependency: request forks of an isolated fork are built with its replacement.
const testApp = createApp()
  .fork({ isolated: true })
  .addInstance('db', new Database('memory'), { replace: true });
await createHandler(testApp)('book', 1); // tx1: begin on memory, …

// A scoped service: the handler applies the scope modules, then the replacement.
export const charged: number[] = [];
const fakePayments: Payments = {
  charge: (amount) => void charged.push(amount),
};
await createHandler(app, (c) =>
  addRequestModules(c).addInstance('payments', fakePayments, { replace: true }),
)('pen', 2); // charged: [20]
  • To replace a dependency of the root, build the handler from an isolated fork that replaces it. The scopes are forks of the isolated fork, so their services get the replacement.
  • To replace a scoped service, let the handler take the scope modules as a parameter, and pass them with the replacement applied after them, with replace: true.
  • checkWiring() on a scope built in the test checks the scope modules too.

Pass it as an argument ​

The simplest per-request state is a parameter. When one or two layers need a value, pass it:

ts
class OrderRepository {
  insert(tx: Transaction, order: Order) {
    tx.query('insert into orders …', order);
  }
}

The dependency is visible in the types, tests pass what they need, and there is nothing to scope. Move to another pattern when the same value is threaded through many layers only to reach a few places.

A factory and using: a unit of work ​

When an operation creates something and must release it (a transaction, a checked-out connection, temporary files), register a factory and let the caller own what it creates. With using, the instance is disposed at the end of the block, also when it throws:

ts
/** The repositories of one business operation, bound to one transaction. */
class UnitOfWork {
  constructor(private readonly tx: Transaction) {}
  insertOrder(item: string): void {
    this.tx.query(`insert ${item}`);
  }
  commit(): void {
    this.tx.commit();
  }
  [Symbol.dispose](): void {
    this.tx[Symbol.dispose](); // rolls back unless committed
  }
}

class Checkout {
  constructor(private readonly work: { begin(): UnitOfWork }) {}

  placeOrder(item: string, quantity: number): void {
    // the unit of work lives in this block and is disposed at its end, also when it throws
    using work = this.work.begin();
    work.insertOrder(item);
    if (quantity < 1) throw new Error('quantity must be at least 1');
    work.commit();
  }
}

// The container provides the factory, a singleton; the caller owns what it creates.
const app = new DIContainer()
  .addSingleton('db', Database)
  .addSingleton(
    'unitOfWork',
    (db) => ({ begin: () => new UnitOfWork(db.begin()) }),
    ['db'],
  )
  .addSingleton('checkout', Checkout, ['unitOfWork']);

app.get('checkout').placeOrder('book', 1); // tx1: insert book, tx1: commit

Open in the playground

  • Checkout and the factory are singletons. The unit of work lives in one method call, not in a container.
  • The factory is a service like any other: it gets its dependencies from the container, and tests can replace it.
  • The unit of work groups what the operation needs (repositories bound to the transaction), so they are not passed one by one.

One instance per request ​

A per-request cache, a DataLoader or an identity map is created on first use and shared by everything in the request. Keep it in a singleton that hands out one instance per context:

ts
/** One value per context (per request): created on first use, forgotten with the context. */
export interface PerContext<T> {
  current(): T;
}

export function perContext<C extends object, T>(
  context: ContextAccessor<C>,
  create: () => T,
): PerContext<T> {
  const values = new WeakMap<C, T>();
  return {
    current() {
      const key = context.current();
      if (!key)
        throw new Error('No request context: call this inside a request');
      if (!values.has(key)) values.set(key, create());
      return values.get(key)!;
    },
  };
}
ts
const storage = new AsyncLocalStorage<{ traceId: string }>();

const app = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addSingleton('db', Database)
  // a singleton that hands out one cache per request
  .addSingleton(
    'users',
    (context, db) => perContext(context, () => new UserCache(db)),
    ['context', 'db'],
  )
  .addSingleton(
    'profiles',
    (users) => ({ name: (id: string) => users.current().get(id).name }),
    ['users'],
  );

export function handle(ids: string[]): string[] {
  // a new context object per request, so a new cache per request
  return storage.run({ traceId: crypto.randomUUID() }, () =>
    ids.map((id) => app.get('profiles').name(id)),
  );
}

handle(['1', '1', '2']); // 2 queries: the second "1" comes from the request's cache
handle(['1']); // 1 more query: a new request, a new cache
  • The context object is the key: create a new object for each request (storage.run({ … }, …)). The WeakMap forgets the instances with the context. A nested storage.run({ ...context, user }, …) starts a new key, and so new instances: to add to the context during a request, set a field instead.
  • Outside a request, current() throws, so code that needs a request fails loudly instead of sharing one instance.
  • The instances are not disposed. For instances that must be released, use a factory and using, or release them with the request.
  • For DataLoaders that only resolvers use, a GraphQL server's per-request context is enough: see GraphQL.

Release what a request creates ​

A transaction that several layers share in one request is opened by the first one that needs it, and must be released when the request ends, also when it fails. One instance per request handles the sharing. The request's resources handle the release:

ts
type Resource = Disposable | AsyncDisposable;

/** What one request owns. Released when the request ends, the last one first. */
export class RequestResources {
  #owned: Resource[] = [];

  add<T extends Resource>(resource: T): T {
    this.#owned.push(resource);
    return resource;
  }

  async [Symbol.asyncDispose](): Promise<void> {
    const owned = this.#owned.reverse();
    this.#owned = [];
    const errors: unknown[] = [];
    for (const resource of owned) {
      try {
        if (Symbol.asyncDispose in resource)
          await resource[Symbol.asyncDispose]();
        else resource[Symbol.dispose]();
      } catch (error) {
        errors.push(error); // release the others before reporting it
      }
    }
    if (errors.length > 0)
      throw new AggregateError(errors, 'Releasing request resources failed');
  }
}

export interface RequestContext {
  readonly traceId: string;
  readonly resources: RequestResources;
}

/** One resource per request: created on first use, released with the request. */
export interface PerRequest<T> {
  current(): T;
}

export function perRequest<T extends Resource>(
  context: { current(): RequestContext | undefined },
  create: () => T,
): PerRequest<T> {
  const values = new WeakMap<RequestContext, T>();
  return {
    current() {
      const request = context.current();
      if (!request)
        throw new Error('No request context: call this inside a request');
      if (!values.has(request))
        values.set(request, request.resources.add(create()));
      return values.get(request)!;
    },
  };
}
ts
class OrderRepository {
  constructor(private readonly tx: PerRequest<Transaction>) {}
  insert(item: string): void {
    this.tx.current().query(`insert ${item}`);
  }
}

class Payments {
  constructor(private readonly tx: PerRequest<Transaction>) {}
  async charge(amount: number): Promise<void> {
    await new Promise((resolve) => setTimeout(resolve, 1)); // the gateway call
    this.tx.current().query(`charge ${amount}`); // the same transaction after awaits
  }
}

class Checkout {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: Payments,
    private readonly tx: PerRequest<Transaction>,
  ) {}

  async placeOrder(item: string, quantity: number): Promise<void> {
    this.orders.insert(item);
    if (quantity < 1) throw new Error('quantity must be at least 1');
    await this.payments.charge(quantity * 10);
    this.tx.current().commit();
  }
}

const storage = new AsyncLocalStorage<RequestContext>();

// Every service is a singleton, built once. Only the transaction is per request.
const app = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addSingleton('db', Database)
  .addSingleton('tx', (context, db) => perRequest(context, () => db.begin()), [
    'context',
    'db',
  ])
  .addSingleton('orders', OrderRepository, ['tx'])
  .addSingleton('payments', Payments, ['tx'])
  .addSingleton('checkout', Checkout, ['orders', 'payments', 'tx']);

// The host owns the request, so it releases what the request created: after the handler, also
// when it throws.
export async function handle(item: string, quantity: number): Promise<void> {
  await using resources = new RequestResources();
  const context = { traceId: crypto.randomUUID(), resources };
  await storage.run(context, () =>
    app.get('checkout').placeOrder(item, quantity),
  );
}

Open in the playground

  • The host owns the request, so it owns what the request creates: await using resources releases it after the handler, the last one first, also when the handler throws. The services stay singletons, and dispose() still releases the app.
  • A resource is created on first use: a request that never touches tx opens no transaction.
  • A failing release doesn't stop the others; the errors are thrown together, as an AggregateError.
  • Don't use a request's resources in work that outlives the request, like a timer or a background job: they are released when the request ends.
  • Where AsyncDisposableStack is available (Node.js 24 and later), it can replace RequestResources: resources.use(create()).
  • Compared to a scope, the graph is built once, and every service is registered in the root and its modules. The cost: services that use the resource depend on PerRequest<Transaction> and call current(), so they know it belongs to a request.

A different implementation per tenant ​

Instead of an isolated fork per tenant, register the implementations and a singleton that picks one for the current tenant on every call (the Strategy pattern):

ts
const storage = new AsyncLocalStorage<RequestContext>();
const tenantMailers: Record<string, 'smtp' | 'ses'> = { acme: 'ses' };

const app = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addInstance('tenantMailers', tenantMailers)
  .addSingleton('smtpMailer', SmtpMailer)
  .addSingleton('sesMailer', SesMailer)
  // one singleton that picks the implementation for the current tenant on every call
  .addSingleton(
    'mailer',
    (context, tenantMailers, smtp, ses): Mailer => {
      const mailers = { smtp, ses };
      const current = () =>
        mailers[tenantMailers[context.current()?.tenantId ?? ''] ?? 'smtp'];
      return { send: (to) => current().send(to) };
    },
    ['context', 'tenantMailers', 'smtpMailer', 'sesMailer'],
  );

export const sent = ['acme', 'globex'].map((tenantId) =>
  storage.run({ traceId: tenantId, tenantId }, () =>
    app.get('mailer').send(`ops@${tenantId}.example`),
  ),
); // ["ses → ops@acme.example", "smtp → ops@globex.example"]

For per-tenant resources, such as a connection pool per tenant, keep them in a singleton map (one per tenant) and create them at startup: a pool created lazily inside a request keeps that request's context (see Create long-lived services at startup).

Choosing ​

You needUse
Several services that belong to a session, request, transaction or job and share its statea scope, disposed with await using
Scoped services in several featuresa root module and a scope module per feature
A scope's services with a decorated root service, like a logger with the request ida decoration in the scope
The trace id, tenant, user or locale in logs and servicesa context accessor
Request data in services registered in the root: domain services, repositories, the loggera context accessor
The same application code under HTTP handlers, queue consumers, cron jobs and CLI commandsa context accessor, or the same scope modules in each host
Data that changes during the work, like the user after authenticationa context accessor
A value that one or two layers needan argument
A transaction, a checked-out connection or temporary files, released at the enda factory and using, or a scope
State several root services share for one unit of work: a cache, a DataLoader, an identity mapone instance per context
A transaction several root services share, released at the endresources released at the end
A different implementation of a service per tenanta strategy chosen by the context
Per-tenant resources, like a pool per tenanta singleton map, created at startup
Code that must never run outside a unit of worka scope (the key doesn't exist outside it), or one instance per context (current() throws)
Testsa fixed accessor; isolated forks to replace services
A runtime without AsyncLocalStoragearguments, or a scope

Released under the MIT License.