zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Request ContextSupported

There is no ambient request context — no AsyncLocalStorage, no RequestContext, no @Req(). Ctx is a parameter, and anything a request-scoped value needs to reach must be passed. That is a real constraint with a clean pattern behind it.

What a handler gets#

export interface Ctx<Params, Body, Query> {
  readonly params: Params;
  readonly body: Body;
  readonly query: Query;
  readonly headers: Readonly<Record<string, string>>;
  readonly method: string;
  readonly path: string;
}

Six fields. No signal, no user, no requestId, no mutable bag, and no way to write to it — the router builds it and hands it over. There is no facility to attach a field in a guard for a handler to read later.

Why not AsyncLocalStorage#

The two costs an ambient context imposes:

outside a request.

Supabase RLS case is the concrete version of this.

So the design is: pass it. Explicit, checked, and testable.

Threading a value through#

@Get('/posts')
async list(ctx: Ctx<Record<never, string>, unknown>) {
  const tenant = tenantFrom(ctx.headers);
  return this.service.listFor(tenant);
}

tenantFrom reads and validates a header; listFor takes it as an argument. One extra parameter per call in the chain — tedious in a deep stack, and the tedium is the trade-off.

A per-request driver: the pattern that solves most of it#

Where the request-scoped value belongs to the _database session_ rather than to your code, build the driver per request. This is how you get tenant scoping, row-level security, query tagging and per-request query counting without ambient state. Request batching is a separate explicit value: construct a LoaderScope alongside the driver.

function driverFor(tenant: string): Driver {
  return {
    async execute(query) {
      const client = await pool.connect();
      try {
        await client.query('SELECT set_config($1, $2, true)', ['app.tenant', tenant]);
        const result = await client.query(query.text, [...query.parameters]);
        return result.rows;
      } finally {
        client.release();
      }
    },
  };
}

@Get('/posts')
async list(ctx: Ctx) {
  const repo = defineRepository(posts, driverFor(tenantFrom(ctx.headers)));
  return repo.list({ page: { limit: 20 } });
}

Two details that are not optional:

cross-tenant data leak with no error.

A request-scoped bundle#

When several things are request-scoped, one object beats several parameters:

interface RequestScope {
  readonly tenant: string;
  readonly userId: number;
  readonly requestId: string;
  readonly posts: PostRepo;
  readonly loaders: LoaderScope;
}

function scopeFor(ctx: Ctx<Record<string, string>, unknown>): RequestScope {
  const claims = verifyToken(ctx.headers.authorization);
  const driver = driverFor(claims.tenant);
  return {
    tenant: claims.tenant,
    userId: claims.sub,
    requestId: ctx.headers['x-request-id'] ?? crypto.randomUUID(),
    posts: defineRepository(posts, driver),
    loaders: createLoaderScope(),
  };
}

Now handlers take (ctx) and build a scope, services take (scope, args), and every dependency is visible in a signature. It tests without a server: construct a scope, call the service.

⚠️ Warning

Never store request state on a controller or provider field. Both are singletons — each controller instance is built once per app. Writing this.currentUser = … in a handler is a race that serves one user's data to another under concurrency, and it will look correct in every single-request test.

Request ids and logging#

const requestId = ctx.headers['x-request-id'] ?? crypto.randomUUID();

Pass it to the driver wrapper so queries carry it, and put it in every log line — that is what makes logs correlatable without ambient storage:

const driver = withLogging(base, e => console.log(JSON.stringify({ ...e, requestId })));

See Logging and SQL Comments.

If you want ambient storage anyway#

Nothing stops you. AsyncLocalStorage is a Node built-in and the framework does not interfere:

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage<RequestScope>();

// in the adapter, around app.handle
const out = await als.run(scope, () => app.handle(req));

It works, and it is not available on Workers or Deno Deploy, so it costs you edge portability. Use it as an addition — for logging correlation, say — rather than as the mechanism your data access depends on.

What it would take#

An optional Ctx.state bag plus a place to populate it (guards and pipes, if the chain were wired into the router). The type question is the interesting one: an untyped Record<string, unknown> state bag would need a cast at every read, which is exactly what the framework's assertion policy rules out. A typed version means Ctx<Params, Body, Query, State> and threading State through the router — doable, and a change to a core public type.

---

See also: Typed Request Context · Injection Scopes · DataLoaders