Aviary

Customization

The five levels of customizing nestjs-context — custom fields, populating values, non-HTTP entrypoints, the cross-process carrier, and swapping the accessor.

nestjs-context is plug-and-play with sane defaults — ContextModule.forRoot() with no arguments works for most HTTP apps. But every layer is customizable, and the customization surface is organized as five levels, ordered from the most common to the most advanced. Nothing here is mandatory; reach for a level only when the default no longer fits.

LevelWhat you changeHowDefault
1Add your own fieldsmodule augmentation
2Populate / derive fields / trace idinitialize / traceId / enrichers in forRoottraceparent header → random
3Non-HTTP entrypointsautoMiddleware: false + Context.runmiddleware on *
4What survives cross-processcarrier: [...] / serialize override / baggagetraceId + tenantId + userRef
5Swap the accessor libs readoverride the CONTEXT_ACCESSOR tokendefault accessor

Every option below is accepted by both ContextModule.forRoot() and the async ContextModule.forRootAsync() (see Getting Started → async configuration); the examples use forRoot() for brevity.


Level 1 — Custom fields

Add typed fields to the store with module augmentation. This is covered in detail on The Store; the short version:

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

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

Augmentation only declares the field. Populating it is Level 2.


Level 2 — Populate fields and override the trace id

ContextModule.forRoot() accepts hooks that run inside the HTTP middleware at the start of every request. They let you control how the trace id is produced and pre-fill any store field (including your Level 1 custom fields):

import { ContextModule, randomTraceId } from '@dudousxd/nestjs-context';

ContextModule.forRoot({
  // Override how the trace id is produced for a request.
  traceId: (req) => req.headers['x-correlation-id'] ?? randomTraceId(),

  // Merge extra fields into the initial store at request start.
  initialize: (req) => ({
    locale: parseAcceptLanguage(req),
    tenantId: tenantFromSubdomain(req),
  }),
});

The available population options on ContextModuleOptions:

  • traceHeader?: string — the header to read the inbound trace id from. Defaults to the W3C traceparent. When the header is absent or malformed, a fresh trace id is generated.
  • traceId?: (req) => string — a full override of trace-id production. When provided, its return value wins over traceHeader and the random fallback.
  • initialize?: (req) => Partial<ContextStore> — a bag of fields merged into the initial store. Ideal for pre-populating tenantId and your custom fields.
  • enrichers?: ContextEnricher[] — functions that run after the store is assembled to populate derived fields. Covered in Enrichers below.

The req passed to these hooks is structural ({ headers?, [key]: unknown }), so the same hooks work on Express, Fastify, or any other adapter — the library never depends on a concrete request type.

userRef still typically enters later, via Context.set() in your auth guard, because authentication runs after the context is established (see Getting Started).

Precedence — the resolved traceId and requestId always win

The middleware applies values in a specific order, and the order matters:

  1. It computes the trace id (from your traceId hook → the traceHeader → a random fallback) and reads requestId from the x-request-id header.
  2. It merges the initialize(req) bag into the store first.
  3. It writes the dedicated traceId and requestId last.

The consequence: the resolved traceId and requestId always win. A stray traceId returned by initialize() cannot clobber the trace id the middleware resolved — the dedicated path is authoritative.

Two edge cases to keep in mind:
— An empty-string x-request-id ('') is treated as absent — it will not overwrite a requestId you set in initialize().
— If initialize() returns a requestId and there is no x-request-id header, the value from initialize() persists. So initialize() is the right place to set a requestId fallback.

Enrichers — derive fields from the assembled store

initialize() runs before the store exists, so it can only see the request. Enrichers run after the middleware has entered the context, so they see the fully-assembled store (trace id, the initialize() bag, the request id) and the request, and use it to populate derived fields — a displayName from a tenantId, a region from a header.

An enricher is a function (store, req?) => Partial<ContextStore> | undefined. It can either return a partial to merge, or mutate the passed store in place and return nothing:

src/app.module.ts
import { ContextModule } from '@dudousxd/nestjs-context';

ContextModule.forRoot({
  enrichers: [
    // Return a partial to merge…
    (store) => ({ region: regionForTenant(store.tenantId) }),
    // …or mutate the store directly.
    (store, req) => {
      store.locale = parseAcceptLanguage(req);
    },
  ],
});

Enrichers are isolated: an enricher that throws neither breaks the request nor the other enrichers — the error is swallowed. They run from the middleware's own options (so they work whether or not the singleton was configured), and forRoot also pushes them onto the singleton, so non-HTTP entrypoints can run them explicitly with Context.runEnrichers(req?) after a Context.run / Context.enterWith.

Both the derived fields above (region, locale) are Level 1 custom fields — declare them with module augmentation first.

Context.lazy — derive on first access instead

Eager enrichers compute on every request, whether or not anything reads the result. For a value that is expensive or rarely needed, prefer the on-demand counterpart: Context.lazy(key, factory) computes the field on first access and memoizes it onto the active store, so subsequent reads in the same request are free.

src/profile.service.ts
import { Injectable } from '@nestjs/common';
import { Context } from '@dudousxd/nestjs-context';

@Injectable()
export class ProfileService {
  displayName(): string | undefined {
    // Computed once per request, only when something asks for it.
    return Context.lazy('displayName', (store) => lookupName(store.userRef));
  }
}

If the field is already present on the store, the factory is not called; outside any context lazy returns undefined (there is nowhere to cache).


Level 3 — Non-HTTP entrypoints

The HTTP middleware is just the default. For GraphQL subscriptions, gRPC, or a queue consumer, turn the middleware off and create the context yourself with the public primitive:

ContextModule.forRoot({ autoMiddleware: false });

Then, in your own interceptor, guard, or consumer, wrap the unit of work in Context.run:

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

// e.g. a queue consumer
async function handleJob(job: Job) {
  const store = {
    traceId: job.data.traceId ?? randomTraceId(),
    userRef: job.data.userRef,
    tenantId: job.data.tenantId,
  };
  return Context.run(store, () => this.process(job));
}

Here Context.run is the right tool (not enterWith): you own a clean callback boundary, so the context is established for the duration of the job and torn down cleanly when it finishes. (For cross-process jobs there is a dedicated helper, Context.deserialize — see Cross-Process.)

Partial HTTP coverage

If you want the middleware on some HTTP routes only, you do not need to turn it off entirely — use forRoutes and exclude:

ContextModule.forRoot({
  forRoutes: ['api/*'],     // default is ['*']
  exclude: ['health', 'metrics'],
});

These accept the same route descriptors as Nest's MiddlewareConsumer.


Level 4 — Cross-process carrier

When the context crosses a process or queue boundary it is reduced to a carrier — a flat, serializable snapshot. By default the carrier includes traceId, tenantId, and userRef. To carry an additional (Level 1) custom field across the boundary, list the fields explicitly:

ContextModule.forRoot({
  carrier: ['traceId', 'tenantId', 'userRef', 'locale'],
});

Or take full control of both directions with serialize / deserialize overrides:

ContextModule.forRoot({
  serialize: (store) => ({
    traceId: store.traceId,
    tenantId: store.tenantId,
    userRef: store.userRef,
    // ...anything else you want on the wire
  }),
  deserialize: (carrier) => ({
    traceId: carrier.traceId,
    tenantId: carrier.tenantId,
    userRef: carrier.userRef,
  }),
});

The mechanics of serialize() / deserialize() and the queue patterns that use them are covered on Cross-Process. What matters here is a sharp edge in how this config is stored.

The carrier config is process-global

The carrier / serialize / deserialize trio does not live in the DI container — it lives in a module-level singleton, because it has to be readable from places Nest cannot inject into (queue workers, ORM subscribers, the durable worker). That makes it process-global: shared across every ContextModule.forRoot() in the process.

Each forRoot() call replaces the whole carrier config wholesale — it is never merged. The options of a single forRoot() are treated as the complete carrier/serialize/deserialize config. This is on purpose: it guarantees you can never end up pairing one app's serialize with another app's deserialize.

Because of that, a second forRoot() with a different config emits a console.warn — the last one wins, which is rarely what you want. In multi-app setups (one process hosting several Nest apps) or in test suites that configure the module repeatedly, call Context.resetConfig() between apps/tests to clear the singleton back to defaults.

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

// In a test or multi-app harness, between apps:
Context.resetConfig();

Baggage keys

Alongside the bespoke carrier, the context can ride a standards-compliant W3C baggage header via Context.toBaggage() / Context.fromBaggage() (see Cross-Process → baggage interop). The baggage option controls how store fields map onto baggage keys. By default tenantId and userRef map to keys of the same name; set a custom key to namespace it, or false to never propagate that field over baggage:

ContextModule.forRoot({
  baggage: {
    tenantId: 'acme.tenant', // namespace the key
    userRef: false,          // never send the principal over baggage
  },
});

This mapping is independent of the bespoke carrier above — changing baggage does not touch carrier / serialize / deserialize, and vice versa.


Level 5 — Swapping the accessor

Consumer libraries (audit, filter, telescope) do not import Context directly — they inject a read-only accessor through the global CONTEXT_ACCESSOR token. Because it is a DI token, you can override it.

The accessor surface is intentionally narrow — consumers read, they do not drive the lifecycle:

interface ContextAccessor {
  traceId(): string | undefined;
  tenantId(): string | undefined;
  userRef(): UserRef | undefined;
  get(): ContextStore | undefined;
}

The default implementation, contextAccessor, is a thin facade over the singleton Context. To change what every consumer sees — for instance, to make an accessor that resolves the full user from the userRef, or that derives the tenant differently — provide your own implementation against the token:

import { Module } from '@nestjs/common';
import {
  CONTEXT_ACCESSOR,
  type ContextAccessor,
  ContextModule,
  Context,
} from '@dudousxd/nestjs-context';

class HydratingAccessor implements ContextAccessor {
  constructor(private readonly users: UserRepository) {}

  traceId() { return Context.traceId(); }
  tenantId() { return Context.tenantId(); }
  userRef() { return Context.userRef(); }
  get() { return Context.get(); }
  // ...plus whatever extra resolution your consumers expect
}

@Module({
  imports: [ContextModule.forRoot()],
  providers: [
    { provide: CONTEXT_ACCESSOR, useClass: HydratingAccessor },
  ],
})
export class AppModule {}

Because the override is a normal provider, the usual DI rules apply — useClass, useFactory, and useValue all work, and your custom accessor can inject other providers. Consumer libraries pick it up automatically through @Optional() @Inject(CONTEXT_ACCESSOR).


Next steps

  • Cross-Process — the full story behind serialize / deserialize and the queue / durable patterns
  • TestingContext.resetConfig() and the testing helpers
  • The Store — the field-level reference for everything you are customizing

On this page