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

Request context ​

Services often need to know about the current request: a logger adds the trace id, a repository filters by tenant, an audit log records the user. The recommended way is a context accessor:

  1. The application declares the context it needs and depends on an accessor for it.
  2. The host (an HTTP server, a queue worker, a CLI) registers the accessor first, in the root container, and keeps the current context in AsyncLocalStorage.
  3. Services stay singletons and read the context when they use it.

For state that a unit of work owns, such as a transaction or a per-request cache, see Scoped state.

A context accessor or a scope ​

A service registered in the root container runs in the root (see Forks), so it can't depend on a request that a scope registers: in the root, the key is a type error and INJECUTE_NOT_REGISTERED at runtime. Both options work; they differ in where the services live:

  • A context accessor keeps every service in the root, built once. Services read the request's data when they run, so a logger, a repository or a domain service registered in the root can use it.
  • A scope gives the services that need the request a home of their own: they are registered in the scope, take the request as an ordinary dependency, and are created again for every request.

Data that many layers read, like the trace id, the tenant or the user, usually fits an accessor. A group of services that belong to the request fits a scope. Scoped state has a table for choosing.

The application declares what it needs ​

ts
/** What the application needs to know about the current request, job or command. */
export interface RequestContext {
  readonly traceId: string;
  readonly tenantId?: string;
}

/** Reads the current context; `undefined` outside a request (at startup, in timers). */
export interface ContextAccessor<T> {
  current(): T | undefined;
}

class Logger {
  constructor(private readonly context: ContextAccessor<RequestContext>) {}

  info(message: string): void {
    // read the context when it is used, never in the constructor
    const { traceId = '-', tenantId = '-' } = this.context.current() ?? {};
    lines.push(`[${traceId} ${tenantId}] ${message}`);
  }
}

class Orders {
  constructor(
    private readonly logger: Logger,
    private readonly context: ContextAccessor<RequestContext>,
  ) {}

  async place(item: string): Promise<string> {
    await new Promise((resolve) => setTimeout(resolve, 1)); // the context survives awaits
    this.logger.info(`order placed: ${item}`);
    return `${this.context.current()?.tenantId ?? 'public'}: ${item}`;
  }
}

// The module requires a `context` service: `extend()` checks that the host registered one.
export const addOrders = (
  c: ServiceRegistry<{ context: ContextAccessor<RequestContext> }>,
) =>
  c
    .addSingleton('logger', Logger, ['context'])
    .addSingleton('orders', Orders, ['logger', 'context']);
  • The application owns the shape of RequestContext. Put in what your domain needs: tenant, user, locale, a deadline. injecute has no context type of its own.
  • The application depends on the accessor, not on AsyncLocalStorage: it imports neither node:async_hooks nor HTTP types, and runs the same under any host.
  • Services read the context when they use it. Reading it in a factory or constructor would keep the context of whichever request created the singleton (see Pitfalls).

The host provides it ​

ts
export const storage = new AsyncLocalStorage<RequestContext>();
const context: ContextAccessor<RequestContext> = {
  current: () => storage.getStore(),
};

// The first layer is the context; the application is added on top of it.
export const app = new DIContainer()
  .addInstance('context', context)
  .extend(addOrders);

await preload(app); // create the singletons at startup, outside any request

The context is the host's first layer. extend(addOrders) is a type error when context is missing, so an application module can't be added to a host that doesn't provide it (see Modules).

Each request runs inside storage.run():

ts
export function handle(request: Request): Promise<string> {
  const context: RequestContext = {
    traceId: request.headers['x-trace-id'] ?? crypto.randomUUID(),
    tenantId: request.headers['x-tenant-id'],
  };
  // everything the request calls sees `context`, including code after `await`
  return storage.run(context, () => app.get('orders').place('book'));
}

Everything the request calls sees its context, including code after await and in parallel promises. Concurrent requests don't see each other's context.

A whole app on this pattern, with a logger and a tracer that read the context and feature modules in namespaces: open it in the playground.

Queue consumers, cron jobs, CLI commands ​

Every entry point runs its work in storage.run(), so the application code is the same everywhere:

ts
// A queue consumer provides the same context, so the application code does not change.
export function consume(job: {
  id: string;
  tenantId: string;
  item: string;
}): Promise<string> {
  return storage.run({ traceId: `job-${job.id}`, tenantId: job.tenantId }, () =>
    app.get('orders').place(job.item),
  );
}

Frameworks ​

Start the context in a middleware that runs before your handlers, and wrap the rest of the request with storage.run(context, next). The framework pages show where: Express, Fastify (after the body is parsed), Hono and Next.js.

Logging ​

Pino's mixin runs on every log line, so a singleton logger can add the current context:

ts
import pino from 'pino';

const addLogger = (
  c: ServiceRegistry<{ context: ContextAccessor<RequestContext> }>,
) =>
  c.addSingleton(
    'logger',
    (context) =>
      pino({
        // return a new object on every call: pino merges into it
        mixin: () => ({ ...context.current() }),
      }),
    ['context'],
  );

A framework's request logger (Fastify's request.log) carries the request id, but only where you pass the request. A logger that reads the context works in every service, including code that doesn't know it runs in a request.

Create long-lived services at startup ​

Timers, sockets and event listeners run in the context they were created in. A connection pool created by the first request that needs it keeps that request's context (and keeps it in memory) for as long as it lives:

ts
class Pool {
  readonly checks: (string | undefined)[] = [];
  // a timer (like a socket or an event listener) runs in the context it was created in
  readonly #timer = setInterval(
    () => this.checks.push(storage.getStore()?.traceId),
    5,
  ).unref();

  [Symbol.dispose](): void {
    clearInterval(this.#timer);
  }
}

// ✗ Created by the first request that needs it: its timer runs in that request's context forever
const lazy = new DIContainer().addSingleton('pool', Pool);
storage.run({ traceId: 'a' }, () => lazy.get('pool'));
// lazy.get('pool').checks: ["a", "a", …]

// ✓ Created at startup, outside any request
const eager = new DIContainer().addSingleton('pool', Pool);
await preload(eager);
storage.run({ traceId: 'a' }, () => eager.get('pool'));
// eager.get('pool').checks: [undefined, undefined, …]

await preload(app) before the server starts listening creates every singleton outside any request.

Pitfalls ​

Don't resolve the context once. A singleton that stores the context, or reads it in its factory or constructor, keeps the first request's context for every later one:

ts
// ✗ The context is resolved once, by the first request, and kept for every later one
const captured = new DIContainer()
  .addSingleton('context', () => storage.getStore())
  .addSingleton('logger', (context) => ({ traceId: () => context?.traceId }), [
    'context',
  ]);

// ✓ An accessor is registered, and the context is read when it is used
const accessor = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addSingleton(
    'logger',
    (context) => ({ traceId: () => context.current()?.traceId }),
    ['context'],
  );

const requests = ['a', 'b'];
export const fromCaptured = requests.map((traceId) =>
  storage.run({ traceId }, () => captured.get('logger').traceId()),
); // ["a", "a"]
export const fromAccessor = requests.map((traceId) =>
  storage.run({ traceId }, () => accessor.get('logger').traceId()),
); // ["a", "b"]

The same happens with a function dependency, [() => storage.getStore()], on a singleton: it runs once, when the singleton is created. Don't keep what you read in a field either; call current() each time.

Use run(), not enterWith(). enterWith() sets the context for the rest of the synchronous execution, which can leak it into the next request. run() scopes it to a callback, and every runtime that has AsyncLocalStorage supports it.

Keep the context small. Ids and plain values, not services. A service or a container in the context is a dependency that no registration shows, and nothing disposes it.

Work that outlives the request still sees its context. A setTimeout or a fire-and-forget promise started in a request keeps the request's context, and keeps it in memory. Copy the values it needs, or start it with storage.exit().

Changing the context during a request. Create the context when the request starts. When a later step adds to it (authentication sets the user), let that step set a field of the context object. A nested storage.run({ ...storage.getStore()!, user }, next) works too, but it starts a new context object, which one instance per request treats as a new request.

Beyond ambient data ​

The context carries data that many layers read. For state a request owns (a transaction, a per-request cache, a per-tenant implementation), see Scoped state: a scope, a factory with using, one instance per context or a strategy chosen by the context, with a table for choosing.

Testing ​

Test the application with a fixed context, without AsyncLocalStorage:

ts
// The application needs no AsyncLocalStorage in tests: give it a fixed context.
const fixed: ContextAccessor<RequestContext> = {
  current: () => ({ traceId: 'test-1', tenantId: 'acme' }),
};
const app = new DIContainer().addInstance('context', fixed).extend(addOrders);

export const placed = await app.get('orders').place('book'); // "acme: book"

To give a whole app container another context in a test, replace it in an isolated fork: app.fork({ isolated: true }).addInstance('context', fixed, { replace: true }).

Other runtimes ​

AsyncLocalStorage is available in Node.js, Deno, Bun and Cloudflare Workers (with Node.js compatibility); use run() and getStore(), which all of them support. Browsers don't have it yet; the TC39 AsyncContext proposal would add a standard one. Only the host touches the storage, so moving to another one changes a single registration.

Released under the MIT License.