A multi-tenant Node.js application often starts with something that looks harmless:
const tenantId =
req.headers["x-tenant-id"];
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
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);
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
);
}
Then another:
async function handleRegistration(
tenantId: string,
requestId: string,
input: CreateUserInput
) {
return registerUser(
tenantId,
input
);
}
Then transactions arrive:
async function handleRegistration(
tenantId: string,
requestId: string,
session: ClientSession,
input: CreateUserInput
) {
// ...
}
Then logging:
async function handleRegistration(
tenantId: string,
requestId: string,
session: ClientSession,
loggerMeta: Record<string, unknown>,
input: CreateUserInput
) {
// ...
}
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
);
when it should have called:
await updateAccount(
tenantId,
accountId,
update
);
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
belongs to application data.
A value like:
tenantId
often determines where and how the operation executes.
For example:
tenantId
↓
tenant configuration
↓
MongoClient
↓
database
↓
collection
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
>();
We can establish an execution boundary:
function runWithContext<R>(
context: ExecutionContext,
operation: () => Promise<R>
) {
return storage.run(
context,
operation
);
}
And read it deeper in the call tree:
function getContext() {
return storage.getStore();
}
Now the request boundary can resolve infrastructure state once:
await runWithContext(
{
tenantId:
"tenant-a",
requestId:
"req-123"
},
async () => {
await applicationLogic();
}
);
Inside applicationLogic():
const context =
getContext();
console.log(
context?.tenantId
);
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?
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
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();
}
);
}
);
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
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
The request carries tenant identity.
The infrastructure layer owns tenant topology.
That means application code does not need to know that:
tenant-a
currently maps to:
mongodb://cluster-3
database: workspace_eu_17
Those details can change without changing application behavior.
This becomes even more important with models
Consider:
await UserModel.find({
active: true
});
In a context-aware runtime, that operation can conceptually resolve:
UserModel.find()
↓
Active Execution Context
↓
tenantId
↓
Tenant Infrastructure
↓
Correct Database
↓
users collection
The application remains focused on:
find active users
while infrastructure resolves:
where this operation belongs
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
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
);
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
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"
}
That tells us something happened.
But not much else.
With execution context:
{
"requestId": "req-123",
"tenantId": "tenant-a",
"operation": "find",
"collection": "users"
}
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
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
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?
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
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
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
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
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
The application can still perform familiar operations such as:
await UserModel.find({});
await UserModel.create({
name:
"Ada"
});
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
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
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)
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’sAsyncLocalStoragecontext may be wrong?