DEV Community

Emmanue; Nodolo
Emmanue; Nodolo

Posted on

Why Request Context Becomes Infrastructure in Multi-Tenant Node.js Applications

A multi-tenant Node.js application often starts with something that looks harmless:

const tenantId =
  req.headers["x-tenant-id"];
Enter fullscreen mode Exit fullscreen mode

You resolve the tenant from the incoming request, pass it to a service, select the right database, and continue.

At first, this feels like ordinary application plumbing.

Then the application grows.

Soon the request also carries:

tenantId
requestId
database selection
transaction session
logging metadata
debug state
instrumentation
operation metadata
Enter fullscreen mode Exit fullscreen mode

And suddenly the question is no longer:

How do I get the tenant ID into this function?

The real question becomes:

How should execution state move through the entire application?

That is the point where request context stops being a convenience and starts becoming infrastructure.

The simple version works — until it spreads everywhere

Imagine a straightforward service:

async function createUser(
  tenantId: string,
  input: CreateUserInput
) {
  const db =
    await resolveTenantDatabase(
      tenantId
    );

  return db
    .collection("users")
    .insertOne(input);
Enter fullscreen mode Exit fullscreen mode

Nothing is particularly wrong with this.

The tenant is explicit.

The code is easy to understand.

Then another layer appears:

async function registerUser(
  tenantId: string,
  input: CreateUserInput
) {
  return createUser(
    tenantId,
    input
  );
}
Enter fullscreen mode Exit fullscreen mode

Then another:

async function handleRegistration(
  tenantId: string,
  requestId: string,
  input: CreateUserInput
) {
  return registerUser(
    tenantId,
    input
  );
}
Enter fullscreen mode Exit fullscreen mode

Then transactions arrive:

async function handleRegistration(
  tenantId: string,
  requestId: string,
  session: ClientSession,
  input: CreateUserInput
) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Then logging:

async function handleRegistration(
  tenantId: string,
  requestId: string,
  session: ClientSession,
  loggerMeta: Record<string, unknown>,
  input: CreateUserInput
) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The business function is slowly becoming an infrastructure transport layer.

The problem is not that these values exist.

They should exist.

The problem is that every application layer becomes responsible for carrying them correctly.

Context propagation becomes a correctness problem

Passing runtime state manually creates several failure modes.

A service can forget to forward the tenant:

await updateAccount(
  accountId,
  update
);
Enter fullscreen mode Exit fullscreen mode

when it should have called:

await updateAccount(
  tenantId,
  accountId,
  update
);
Enter fullscreen mode Exit fullscreen mode

A nested operation can lose the active transaction session.

A logger can emit an event without the request identifier.

A model can resolve the default database because a database name was not forwarded.

None of these failures necessarily produce obvious syntax errors.

The application can still run.

It can simply run with the wrong execution state.

That makes context propagation a runtime correctness concern.

A tenant is not just another function argument

This distinction matters.

A value like:

productName
Enter fullscreen mode Exit fullscreen mode

belongs to application data.

A value like:

tenantId
Enter fullscreen mode Exit fullscreen mode

often determines where and how the operation executes.

For example:

tenantId
   ↓
tenant configuration
   ↓
MongoClient
   ↓
database
   ↓
collection
Enter fullscreen mode Exit fullscreen mode

Likewise, a transaction session determines whether a database operation belongs to an existing atomic unit of work.

A request ID determines how runtime signals can be correlated.

These values describe the execution environment around the application.

That is why treating them exactly like ordinary domain parameters eventually becomes awkward.

Node.js already has a primitive for this

Node.js provides AsyncLocalStorage for storing state associated with an asynchronous execution chain.

A simplified execution context might look like this:

import {
  AsyncLocalStorage
} from "node:async_hooks";

import type {
  ClientSession
} from "mongodb";

interface ExecutionContext {
  tenantId?: string;
  requestId?: string;
  session?: ClientSession;
}

const storage =
  new AsyncLocalStorage<
    ExecutionContext
  >();
Enter fullscreen mode Exit fullscreen mode

We can establish an execution boundary:

function runWithContext<R>(
  context: ExecutionContext,
  operation: () => Promise<R>
) {
  return storage.run(
    context,
    operation
  );
}
Enter fullscreen mode Exit fullscreen mode

And read it deeper in the call tree:

function getContext() {
  return storage.getStore();
}
Enter fullscreen mode Exit fullscreen mode

Now the request boundary can resolve infrastructure state once:

await runWithContext(
  {
    tenantId:
      "tenant-a",

    requestId:
      "req-123"
  },

  async () => {
    await applicationLogic();
  }
);
Enter fullscreen mode Exit fullscreen mode

Inside applicationLogic():

const context =
  getContext();

console.log(
  context?.tenantId
);
Enter fullscreen mode Exit fullscreen mode

The application does not have to forward the tenant through every intermediate function manually.

The important part is not AsyncLocalStorage itself

AsyncLocalStorage solves propagation.

It does not define your architecture.

You still need to decide:

Who establishes the context?

What belongs inside it?

Who is allowed to read it?

How does tenant identity resolve infrastructure?

How are transactions added?

How does context interact with models?

How is the boundary cleaned up?

What happens outside HTTP?
Enter fullscreen mode Exit fullscreen mode

Those decisions are where context turns into runtime design.

The HTTP adapter becomes an execution boundary

In an HTTP application, the framework boundary is a natural place to resolve execution state.

Conceptually:

HTTP Request
      ↓
Framework Middleware
      ↓
Tenant Resolution
      ↓
Request ID
      ↓
Execution Context
      ↓
Application Logic
Enter fullscreen mode Exit fullscreen mode

For example:

app.use(
  async (
    req,
    res,
    next
  ) => {
    const tenantId =
      req.header(
        "x-tenant-id"
      );

    if (!tenantId) {
      return res
        .status(400)
        .json({
          error:
            "Tenant is required."
        });
    }

    const requestId =
      crypto.randomUUID();

    return runWithContext(
      {
        tenantId,
        requestId
      },

      async () => {
        next();
      }
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

The exact implementation varies by framework.

The architectural idea does not:

Resolve execution state at the boundary, then let the runtime carry it.

Tenant resolution and tenant infrastructure are different problems

This separation is particularly important in multi-tenant systems.

The incoming request may tell us:

tenant-a
Enter fullscreen mode Exit fullscreen mode

That answers:

Who is this execution for?

It does not necessarily answer:

Which MongoDB client or database should handle it?

That is an infrastructure problem.

A useful separation is:

Request
      ↓
Tenant Resolver
      ↓
tenantId
      ↓
Execution Context
      ↓
Application
      ↓
Tenant Infrastructure Resolver
      ↓
MongoClient
      ↓
Tenant Database
Enter fullscreen mode Exit fullscreen mode

The request carries tenant identity.

The infrastructure layer owns tenant topology.

That means application code does not need to know that:

tenant-a
Enter fullscreen mode Exit fullscreen mode

currently maps to:

mongodb://cluster-3
database: workspace_eu_17
Enter fullscreen mode Exit fullscreen mode

Those details can change without changing application behavior.

This becomes even more important with models

Consider:

await UserModel.find({
  active: true
});
Enter fullscreen mode Exit fullscreen mode

In a context-aware runtime, that operation can conceptually resolve:

UserModel.find()
      ↓
Active Execution Context
      ↓
tenantId
      ↓
Tenant Infrastructure
      ↓
Correct Database
      ↓
users collection
Enter fullscreen mode Exit fullscreen mode

The application remains focused on:

find active users
Enter fullscreen mode Exit fullscreen mode

while infrastructure resolves:

where this operation belongs
Enter fullscreen mode Exit fullscreen mode

This is much easier to reason about than passing the database identity through every service method.

Transactions reveal why context matters

Transactions make the value of execution context even clearer.

Imagine a workflow:

Create User
      +
Write Audit Log
Enter fullscreen mode Exit fullscreen mode

Both operations should participate in one MongoDB transaction.

A manual approach often means passing the session everywhere:

await createUser(
  input,
  session
);

await createAuditLog(
  audit,
  session
);
Enter fullscreen mode Exit fullscreen mode

That works.

But it pushes transaction plumbing into application APIs.

A context-aware execution can instead establish the session at the transaction boundary:

Transaction Boundary
      ↓
ClientSession
      ↓
Execution Context
      ↓
UserModel
      +
AuditLogModel
Enter fullscreen mode Exit fullscreen mode

Both participating operations can resolve the same active session.

The important principle is:

The transaction boundary owns atomicity. Individual model operations participate in that boundary.

Again, execution state is doing more than carrying metadata.

It is preserving runtime behavior.

Context also improves instrumentation

Suppose every database operation produces an instrumentation signal.

Without execution context, a signal might look like:

{
  "operation": "find",
  "collection": "users"
}
Enter fullscreen mode Exit fullscreen mode

That tells us something happened.

But not much else.

With execution context:

{
  "requestId": "req-123",
  "tenantId": "tenant-a",
  "operation": "find",
  "collection": "users"
}
Enter fullscreen mode Exit fullscreen mode

Now the operation can be correlated with the execution that caused it.

That becomes useful for:

structured logging
query analysis
latency investigation
tenant-level usage patterns
debugging
operational monitoring
Enter fullscreen mode Exit fullscreen mode

Instrumentation becomes far more meaningful when runtime identity automatically follows execution.

Context is not authorization

There is an important security boundary here.

Resolving:

tenantId = tenant-a
Enter fullscreen mode Exit fullscreen mode

does not prove that the caller is allowed to operate on Tenant A.

These remain separate concerns:

Authentication
→ Who is the caller?

Tenant Resolution
→ Which tenant is this execution for?

Authorization
→ May this caller act for this tenant?
Enter fullscreen mode Exit fullscreen mode

Execution context can carry tenant identity after those decisions are made.

It should not be mistaken for the authorization decision itself.

That distinction is especially important when tenant identity comes from something as simple as an HTTP header.

Request context eventually becomes execution context

The phrase request context is useful when discussing HTTP.

But the architecture eventually outgrows the request.

Consider a background worker:

Queue Job
      ↓
tenantId
      ↓
Worker Handler
      ↓
Execution Context
      ↓
UserModel
Enter fullscreen mode Exit fullscreen mode

There is no HTTP request.

The same is true for:

scheduled tasks
event consumers
CLI commands
migration scripts
serverless invocations
MCP tool calls
agent workflows
Enter fullscreen mode Exit fullscreen mode

So the more general concept is not request context.

It is execution context.

HTTP is simply one way to enter that execution.

This is where context becomes infrastructure

At this point the context is coordinating:

tenant identity

request or job identity

database resolution

transaction continuity

logging metadata

instrumentation

model execution
Enter fullscreen mode Exit fullscreen mode

That is no longer a convenience object.

It is runtime infrastructure.

A useful mental model is:

Execution Source
      ↓
Execution Boundary
      ↓
Context
      ↓
Application Logic
      ↓
Persistence Runtime
      ↓
Infrastructure
Enter fullscreen mode Exit fullscreen mode

The application should still own its business logic.

The runtime should own the consistency of execution state.

Where Ambiten fits into this idea

This runtime problem is one of the reasons I have been building Ambiten.

Ambiten is a context-aware runtime for MongoDB applications.

The goal is not to hide MongoDB behind an entirely different programming model.

The goal is to give execution concerns a consistent place to live.

Conceptually:

Request / Worker / Agent
          ↓
    Execution Boundary
          ↓
     AmbitenContext
          ↓
     Application
          ↓
     AmbitenModel
          ↓
 Effective ModelContext
          ↓
Tenant / Transaction /
Middleware / Instrumentation
          ↓
       MongoDB
Enter fullscreen mode Exit fullscreen mode

The application can still perform familiar operations such as:

await UserModel.find({});

await UserModel.create({
  name:
    "Ada"
});
Enter fullscreen mode Exit fullscreen mode

But the runtime can already know:

which tenant is active

which transaction session exists

which database should be used

which instrumentation belongs
to the execution
Enter fullscreen mode Exit fullscreen mode

That allows infrastructure concerns to stay out of the business API without making them implicit global state.

The important word is boundary

Context is useful only when its boundaries are clear.

You should know where execution begins.

You should know what state belongs to it.

You should know when that state stops being valid.

A healthy model looks like:

PROCESS
      ↓
Reusable Infrastructure

EXECUTION
      ↓
Context

OPERATION
      ↓
Model / Database Work
Enter fullscreen mode Exit fullscreen mode

Process infrastructure may live for hours.

An execution may live for milliseconds or seconds.

A database operation may be shorter still.

Keeping those lifetimes separate makes multi-tenant systems easier to reason about.

Final thought

Multi-tenancy is often introduced as a database-routing problem.

It is bigger than that.

Once an application has to preserve tenant identity across services, transactions, models, logs, workers, and instrumentation, the deeper problem is execution consistency.

That is why request context eventually becomes infrastructure.

Not because every variable belongs in context.

Not because function arguments are bad.

But because some state describes the execution itself.

And once that state affects where operations run, which transaction they join, and how they are observed, it deserves a runtime boundary of its own.

Top comments (1)

Collapse
 
raju_dandigam profile image
Raju Dandigam

Treating context propagation as correctness rather than convenience is the key point. I’d be careful to keep authorization inputs out of a mutable ambient store: resolve and validate the tenant at the boundary, freeze the identity/role snapshot, and make infrastructure access fail closed when context is absent. How do you handle detached work (queueMicrotask, event emitters, or background jobs) where inheriting the request’s AsyncLocalStorage context may be wrong?