Aviary

The Store

The ContextStore shape, why it carries a UserRef instead of the full user, the always-present traceId invariant, and how to add your own typed fields.

Everything nestjs-context does revolves around one small interface: the ContextStore. It is the object held in AsyncLocalStorage for the lifetime of a request, and it is what every accessor reads from. Understanding its shape — and a couple of deliberate decisions baked into it — makes the rest of the library obvious.


The ContextStore shape

interface ContextStore {
  traceId: string;
  requestId?: string;
  userRef?: UserRef;
  tenantId?: string;
  traceparent?: ParsedTraceparent;
}

interface UserRef {
  type: string;
  id: string | number;
}

The fields, and only one of them is required:

  • traceId — a correlation id for the request. Always present (see the invariant below). Used by telescope and durable to correlate logs, spans, and workflows.
  • requestId — an optional inbound request id, captured from the x-request-id header when present. Distinct from the trace id: the trace id correlates a distributed operation, while the request id identifies this one HTTP request.
  • userRef — an optional reference to the acting principal, as { type, id }. Set by your auth layer.
  • tenantId — the optional active tenant. Read by the filter library to scope queries.
  • traceparent — the parsed upstream traceparent (span-id + trace-flags), captured at request start so a re-emitted downstream traceparent faithfully continues the distributed trace. Process-local on purpose — intentionally not part of the default cross-process carrier. See Cross-Process → faithful traceparent.

You read these through the Context accessors rather than poking at the object directly:

import { Context } from '@dudousxd/nestjs-context';

Context.traceId();  // string | undefined
Context.tenantId(); // string | undefined
Context.userRef();  // UserRef | undefined
Context.get();      // ContextStore | undefined — the whole thing

Why a UserRef, not the full user

The single most important design decision in the store is that it carries a reference to the user — { type, id } — and never the hydrated user entity.

// What the store holds:
Context.set('userRef', { type: 'user', id: 42 });

// NOT this:
Context.set('userRef', fullUserEntityWithRelationsAndOrmConnection); // ✗

This is deliberate, and it pays off in two places:

  1. Serializability. The store has to survive being sent across a process or queue boundary (see Cross-Process). A { type, id } pair serializes to JSON trivially. A full ORM entity does not — it drags along lazy relations, a live database connection, and circular references that cannot cross a boundary.
  2. Decoupling. The context layer should not know what a User is, nor own its lifecycle. It records who is acting as a stable, minimal reference. If a consumer genuinely needs the full user, it resolves it from the ref — that is a job for an application service, not the context.

The type discriminator is what lets a ref point at different kinds of principals — { type: 'user', id: 42 }, { type: 'apiKey', id: 'ak_…' }, { type: 'system', id: 'cron' } — without the context caring which.

If you find yourself wanting the whole user in the context, you usually want one of two things instead: resolve the full user from the ref in a service, or (advanced) swap the accessor your libraries consume so it hydrates the user on demand. See Customization → swapping the accessor.


The traceId invariant

traceId is the only non-optional field on the store, and the library works hard to keep it that way: if there is a context at all, it has a trace id.

  • On an HTTP request, the middleware reads the inbound traceparent header (or your configured header / traceId hook) and falls back to a freshly generated id when none is present or valid.
  • When re-hydrating a context from a cross-process carrier, if the carrier arrives without a trace id, the library synthesizes a fresh one (with a one-time warning) rather than propagate undefined.

The reason is correlation. Telescope and durable use the trace id as the join key across logs, spans, and workflow steps. A traceId that could be undefined would force every consumer to handle a hole that should never exist — so the library guarantees the hole never appears. You can rely on Context.traceId() returning a string whenever a context is active (it still returns undefined outside any context — for example, during bootstrap).

The trace id follows the W3C Trace Context shape: a 32-hex-character id. The package exports helpers for working with it directly:

import {
  extractTraceparent,
  parseTraceparent,
  randomTraceId,
  toTraceparent,
} from '@dudousxd/nestjs-context';

extractTraceparent(req.headers);          // pull the trace-id out of a `traceparent` header
extractTraceparent(req.headers, 'x-b3');  // ...or a custom header
parseTraceparent(req.headers);            // { traceId, parentId?, flags? } — the full upstream span context
randomTraceId();                          // '4bf92f3577b34da6a3ce929d0e0e4736'
toTraceparent(Context.traceId()!);        // '00-<traceId>-<spanId>-01' — a fresh downstream traceparent

When you pass the captured upstream as the second argument — toTraceparent(traceId, Context.get()?.traceparent) — the re-emitted header continues the incoming trace faithfully (propagating the parent span-id and the upstream sampled flag) instead of minting a new one. See Cross-Process → faithful traceparent.


Adding your own typed fields

The four built-in fields cover identity, tenancy, and correlation — but your app probably has its own ambient values worth carrying: a locale, an impersonator id, a feature-flag cohort. The store is open for extension through TypeScript module augmentation, so you add fields without forking the library and keep full type safety.

This is what the docs call Level 1 customization — declare an augmentation block once, anywhere in your project (a context.d.ts or any module file your build includes):

// src/types/context.d.ts
import '@dudousxd/nestjs-context';

declare module '@dudousxd/nestjs-context' {
  interface ContextStore {
    locale?: string;
    impersonatorId?: string;
  }
}

Once that augmentation is in scope, your new fields are fully typed everywhere ContextStore is used:

Context.set('locale', 'pt-BR');        // ✓ typed
Context.set('impersonatorId', 'u_99'); // ✓ typed
Context.get()?.locale;                 // string | undefined
Context.set('locale', 42);             // ✗ type error

Keep augmented fields serializable if you ever want them to cross a process/queue boundary — strings, numbers, plain objects, the same rule as userRef. Adding a field to the store does not automatically make it travel across boundaries; you opt it into the carrier explicitly. See Customization → cross-process carrier.

Module augmentation is a compile-time contract — it tells TypeScript the field exists. It does not populate the field. To set a value at request start, use the initialize hook; to set it later, call Context.set(). See Customization → populating fields.


Next steps

  • Customization — populate your custom fields, override the trace id, and more
  • Cross-Process — make the store (and your custom fields) survive a queue or durable boundary
  • Testing — build a fake store for unit tests

On this page