Context
AmbitenContext is the execution-state backbone of Ambiten.
It provides ambient, execution-scoped state across supported asynchronous boundaries so runtime components can participate in the same operation without forcing tenant identity, transaction sessions, request metadata, database scope, and runtime metadata through every application function.
It can represent an HTTP request:
Request
↓
AmbitenContextbut it is not limited to requests.
The same execution model applies to:
GraphQL operations
Lambda invocations
queue consumers
scheduled jobs
workers
scripts
maintenance tasks
custom runtimesThe central idea is:
Context belongs to an execution, not to a framework.
If you are looking specifically for how model operations consume context, see Context Binding.
Why Context Exists
Without an execution-context layer, infrastructure state tends to spread through application APIs.
For example:
await createUser(
tenantId,
requestId,
dbName,
session,
data
);Those values then move through:
controller
↓
service
↓
repository
↓
modeleven though they are not application data.
This creates several problems:
tenant identity becomes plumbing
transaction sessions require manual propagation
request metadata leaks into service signatures
database routing spreads through application code
framework-specific state reaches domain layersAmbiten moves that state into an execution boundary.
Inside a valid runtime scope:
await UserModel.create(data);can participate in the execution without the application manually forwarding its runtime infrastructure through every layer.
What AmbitenContext Represents
AmbitenContext stores AmbitenContextState.
The runtime contract includes:
export interface AmbitenContextState {
tenantId?: string;
requestId?: string;
dbName?: string;
collectionName?: string;
session?: ClientSession;
loggerMeta?: Record<string, any>;
logger?: AmbitenLoggerLike;
debug?: boolean;
meta?: AmbitenOperationMeta;
observer?: AmbitenQueryObserver;
budget?: AmbitenQuotaBudgetInput;
}These fields describe the current execution.
They do not describe the application's business domain.
Conceptually:
AmbitenContextState
├── execution identity
│ ├── tenantId
│ └── requestId
│
├── persistence scope
│ ├── dbName
│ ├── collectionName
│ └── session
│
├── observability
│ ├── logger
│ ├── loggerMeta
│ ├── debug
│ └── observer
│
└── runtime control
├── meta
└── budgetExecution-Scoped, Not Merely Request-Scoped
It is useful to call AmbitenContext execution-scoped rather than only request-scoped.
An HTTP request is one kind of execution:
HTTP Request
↓
AmbitenContextbut so is:
Queue Message
↓
AmbitenContextor:
Scheduled Job
↓
AmbitenContextor:
Script
↓
AmbitenContext.run(...)The scope lasts for the execution boundary that created it.
Execution Model
Ambiten's context propagation is built on Node.js AsyncLocalStorage.
Conceptually:
Execution Boundary
↓
AsyncLocalStorage
↓
AmbitenContextState
↓
Async Call ChainState remains associated with the active asynchronous execution rather than being stored as mutable process-global request state.
This allows concurrent operations to remain isolated.
Execution A
tenantId = tenant-a
requestId = req-a
│
└── async work A
Execution B
tenantId = tenant-b
requestId = req-b
│
└── async work BBoth executions may use the same services and models while retaining different runtime state.
Creating an Execution Boundary
Use AmbitenContext.run(...) when establishing execution explicitly.
import {
AmbitenContext
} from "@ambiten/core";
await AmbitenContext.run(
{
tenantId: "tenant-a",
requestId: "req-123"
},
async () => {
await UserModel.create({
name: "John"
});
}
);Everything participating in the supported asynchronous call chain executes within that context boundary.
This pattern is useful for:
background jobs
queue consumers
scheduled tasks
scripts
maintenance workflows
custom runtimes
testsFramework integrations normally establish the boundary through their adapters.
Reading the Active Context
Use AmbitenContext.get() to inspect the current execution state.
const ctx =
AmbitenContext.get();
console.log(
ctx.tenantId
);
console.log(
ctx.requestId
);This is useful for infrastructure and cross-cutting runtime concerns such as:
logging
instrumentation
auditing
runtime diagnostics
custom middleware
quota tracking
query observationApplication services should still avoid reading context unnecessarily when their behavior does not depend on execution state.
Runtime Flow
Execution enters an Ambiten runtime boundary
Execution may begin from an HTTP request, GraphQL operation, Lambda invocation, scheduled job, queue consumer, teaching example, script, or explicit application task.
Adapter or explicit runtime helper establishes context
Ambiten establishes execution through a framework adapter, an explicit AmbitenContext.run(...) boundary, or another context-aware runtime helper.
AmbitenContext preserves active execution state
Runtime state is bound to the active execution surface
AmbitenModel merges active AmbitenContext state into an effective ModelContext. Direct AmbitenClient workflows may instead use explicit ModelContext, scoped clients, or client helpers that read ambient context.
await UserModel.find({})Effective operation state resolves persistence infrastructure
The model resolves its effective collection boundary while providers, MultiTenantManager, and AmbitenClient resolve the database, MongoDB client, tenant infrastructure, transaction session, and supported runtime overrides required by the operation.
MongoDB executes against the resolved operation scope
Persistence executes through MongoDB using the resolved database, collection, client, and transaction session produced by the active Ambiten execution path.
At a high level:
Ingress
↓
Execution Boundary
↓
AmbitenContext
↓
Application Logic
↓
AmbitenModel
↓
Effective ModelContext
↓
Infrastructure Resolution
↓
MongoDBNot every layer consumes AmbitenContext in exactly the same way.
That distinction is important.
AmbitenContextState vs ModelContext
AmbitenContextState and ModelContext are related but have different responsibilities.
AmbitenContextState
This is the full execution-scoped runtime contract.
It includes concerns such as:
tenant identity
request identity
database scope
transaction session
logger
logger metadata
debug state
operation metadata
query observer
quota budgetModelContext
ModelContext is the operation-facing context used by the model runtime.
export type ModelContext = {
tenantId?: string;
requestId?: string;
dbName?: string;
db?: Db;
collectionName?: string;
config?: AmbitenConfig;
session?: ClientSession;
withDeleted?: boolean;
onlyDeleted?: boolean;
hardDelete?: boolean;
};It contains the subset of execution state relevant to model persistence plus model-specific operation controls.
Conceptually:
AmbitenContextState
↓
model projection / merge
↓
ModelContextThese are not competing context systems.
ModelContext is the model-operation view of execution state.
Model Context Binding
AmbitenModel connects the two contracts.
The model resolves an effective operation context using:
explicit operation ModelContext
↓
active AmbitenContext
↓
model defaultsConceptually:
AmbitenContext.get()
+
explicit operation context
+
model defaults
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContextThat effective context can then participate in:
schema behavior
middleware
model operation
provider resolution
transaction participationThe important distinction is:
AmbitenContext carries execution state. AmbitenModel binds the relevant state to a model operation.
Why the Projection Exists
The complete runtime context contains values that do not necessarily belong in every persistence operation.
For example:
logger
observer
budget
debugmay belong to the wider execution system.
Meanwhile, a model operation may require additional persistence-specific controls:
withDeleted
onlyDeleted
hardDelete
db
configModelContext therefore gives the model runtime a focused operation contract without forcing every provider or schema API to depend on the entire execution-state interface.
Context-Aware Model Execution
Consider:
await AmbitenContext.run(
{
tenantId: "tenant-a",
requestId: "req-123",
dbName: "tenant_a_db"
},
async () => {
await UserModel.find({});
}
);The model operation can derive:
tenantId = tenant-a
requestId = req-123
dbName = tenant_a_dbinto its effective ModelContext.
The flow is:
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
ModelContext
↓
Schema / Middleware
↓
DbProviderThe provider does not need to reconstruct the original request context.
Tenant Identity
tenantId represents the tenant identity associated with the current execution.
For adapter-managed applications:
Request
↓
TenantResolver
↓
tenantId
↓
AmbitenContextFor explicit jobs:
await AmbitenContext.run(
{
tenantId: job.tenantId
},
async () => {
// tenant-aware work
}
);The context carries the identity after it has been established.
It does not itself authenticate or authorize the caller.
Tenant Resolution Is Not Authorization
These concerns should remain separate:
Authentication
→ Who is the caller?
Tenant Resolution
→ Which tenant belongs to this execution?
Authorization
→ May this caller act for that tenant?Having:
AmbitenContext.get().tenantIddoes not by itself prove that the caller is authorized for that tenant.
That remains an application security responsibility.
Tenant Infrastructure
AmbitenContext carries tenant identity.
It does not store the entire tenant infrastructure lifecycle.
Conceptually:
AmbitenContext
tenantId = tenant-a
↓
Model / Runtime
↓
MultiTenantManager
↓
TenantConfig
↓
MongoDB Client
↓
Tenant DatabaseThe separation is:
AmbitenContext
→ tenant identity
MultiTenantManager
→ tenant infrastructureThis keeps connection lifecycle and tenant registry state out of execution context.
Dynamic Tenants
An execution can carry a tenant that has not yet been registered locally.
AmbitenContext
tenantId = tenant5
↓
MultiTenantManager.resolveTenant()
↓
TenantConfigResolver
↓
register tenant
↓
getClient()The context does not change merely because infrastructure had to be discovered dynamically.
Identity and infrastructure resolution remain separate.
Database Scope
dbName can represent the database scope associated with an execution.
await AmbitenContext.run(
{
dbName: "analytics"
},
async () => {
// execution
}
);For model operations, that value can participate through the effective ModelContext.
AmbitenContext.dbName
↓
AmbitenModel.mergeCtx()
↓
ModelContext.dbName
↓
DbProviderAn explicit operation-level override may take precedence where the public API permits it.
Collection Scope
collectionName can also be carried as execution state where a runtime collection override is required.
AmbitenContext.collectionName
↓
AmbitenModel.mergeCtx()
↓
ModelContext.collectionName
↓
effective collection resolutionThe model still owns its normal collection boundary.
Context provides a supported runtime override rather than redefining the model itself.
Request Identity
requestId provides an execution correlation identifier.
await AmbitenContext.run(
{
requestId: "req-123"
},
async () => {
// execution
}
);Runtime-aware logging or instrumentation can consume it:
const {
requestId
} =
AmbitenContext.get();
logger.info(
"User created",
{
requestId
}
);This allows correlation information to remain execution-scoped instead of being threaded through every service signature.
Logging Context
AmbitenContextState can carry:
logger
loggerMetaThis allows logging infrastructure to enrich events with execution metadata.
For example:
requestId
tenantId
operation metadata
custom logger metadataThe context makes correlation information available.
It does not guarantee delivery, buffering, storage, or transport behavior of the configured logging backend.
Those properties belong to the logger or instrumentation implementation.
Debug State
The debug field allows debug-related runtime behavior to participate in the execution scope.
const {
debug
} =
AmbitenContext.get();Because debug state is execution-scoped, one request or job does not need to mutate global debug behavior for unrelated concurrent executions.
Runtime Metadata
meta allows operation metadata to participate in execution.
Conceptually:
Execution
↓
AmbitenContext.meta
↓
runtime instrumentation
middleware
diagnosticsMetadata should describe execution behavior rather than become a general-purpose store for arbitrary domain objects.
Query Observation
The context can carry an observer:
export interface AmbitenQueryObserver {
onQuery?: (
payload: Record<string, any>
) => void | Promise<void>;
onQueryError?: (
payload: Record<string, any>
) => void | Promise<void>;
}This provides an execution-aware observation hook for query behavior.
Conceptually:
Model Operation
↓
Query Execution
↓
AmbitenQueryObserver
├── onQuery(...)
└── onQueryError(...)Observer behavior remains part of instrumentation.
It should not be confused with persistence correctness or transaction ownership.
Query Budgets
The execution context can also carry quota/budget information.
export interface AmbitenQuotaBudgetInput {
maxQueries?: number;
queriesExecuted?: number;
totalTimeMs?: number;
}This makes execution-level accounting possible without storing budget state as process-global mutable data.
Conceptually:
Execution Boundary
↓
AmbitenContext.budget
↓
runtime operations
↓
query accountingBudget enforcement behavior depends on the runtime components that consume this state.
Transaction-Aware Execution
Transaction continuity is one of the major uses of AmbitenContext.
await AmbitenContext.withTransaction(
async () => {
await UserModel.create({
name: "Alice"
});
await OrderModel.create({
item: "Starter Kit"
});
}
);Conceptually:
Transaction Boundary
↓
ClientSession S1
↓
AmbitenContext.session
↓
AmbitenModel.mergeCtx()
↓
ModelContext.session
↓
participating operationsThe session does not need to be manually passed through each service method.
Transaction Participation vs Ownership
The context carries the active transaction session.
It does not mean each model operation owns the transaction lifecycle.
The surrounding transaction boundary owns:
session creation
commit
rollback
session completionParticipating model operations consume the active session.
withTransaction()
↓
session S1
↓
Model A
↓
Model B
↓
boundary completesThis keeps transaction participation separate from transaction ownership.
Adapter-Managed Transactions
Adapters may establish execution-wide transactions where configured.
Conceptually:
Request
↓
Adapter
↓
AmbitenContext
↓
Transaction Boundary
↓
Application
↓
Model OperationsFrom the model's perspective, the important fact is that an active session exists in the effective operation context.
The model does not need to know whether that transaction was created manually or through an adapter boundary.
Adapter Integration
Supported framework adapters normally establish AmbitenContext before application work begins.
Conceptually:
Framework Request / Invocation
↓
Adapter
↓
Adapter Runtime
↓
AmbitenContext.run(...)
↓
ApplicationAdapters may resolve:
tenantId
requestId
dbName
collectionName
debug state
logger metadata
custom metadataaccording to their configuration before invoking application logic.
The exact integration differs by framework, but the execution-state contract remains the same.
Framework Boundaries
Examples include:
Express
→ middleware boundary
Fastify
→ lifecycle boundary
NestJS
→ interceptor / Observable subscription boundary
GraphQL
→ operation context boundary
AWS Lambda
→ invocation boundaryThe adapter is responsible for keeping the actual application execution inside the active Ambiten boundary.
Explicit Execution Without an Adapter
Adapters are not required for every Ambiten execution.
For background or custom runtime work:
await AmbitenContext.run(
{
tenantId: "tenant-a",
requestId: "job-42"
},
async () => {
await executeJob();
}
);This gives non-framework execution the same context model used by adapter-managed requests.
Background Jobs
For a job carrying tenant identity:
await AmbitenContext.run(
{
tenantId: job.tenantId,
requestId: job.id
},
async () => {
await processJob(job);
}
);The job creates a new execution boundary.
Do not assume the AsyncLocalStorage context from the producer process automatically appears in a worker process.
Queues and Process Boundaries
AsyncLocalStorage propagates through supported asynchronous work inside one process execution chain.
It does not automatically cross:
message queues
worker processes
HTTP service boundaries
separate Node.js processes
external event systemsExecution identity must be propagated explicitly across those boundaries.
For example:
Service A Context
↓
message payload / headers
↓
Queue
↓
Worker B
↓
new AmbitenContext.run(...)This is an important distinction between async continuity and distributed propagation.
Detached Work
Work that intentionally outlives the execution boundary should establish its own context.
Avoid conceptually relying on:
Request Context
↓
fire-and-forget task
↓
continues after request boundaryInstead:
Request
↓
enqueue explicit execution identity
↓
Worker
↓
new AmbitenContextAn execution boundary should remain finite.
Context and AmbitenClient
AmbitenClient can interact with execution state in more than one way.
Through the Model Runtime
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
ModelContext
↓
AmbitenClient.db(ctx)Here, the model performs the context binding.
Through Ambient Client Helpers
Certain client helpers can read AmbitenContext directly.
Conceptually:
AmbitenContext
↓
AmbitenClient.resolveRuntime()
↓
runtime infrastructureThese paths should not be conflated.
Not every AmbitenClient method independently reads ambient context.
Context and Schema
The schema participates through the model's effective operation context.
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
AmbitenSchema / MiddlewareThe schema does not independently resolve the original request context or tenant.
This allows schema behavior to remain framework-independent.
Context and Middleware
Middleware can consume runtime-aware model context during operation execution.
For example:
Effective ModelContext
↓
before middleware
↓
model operation
↓
after middlewareThis allows persistence-oriented policies to observe values such as:
tenantId
requestId
session
soft-delete controls
database scopewithout requiring controllers to forward them manually.
Context and Providers
The provider consumes the effective ModelContext prepared by the model.
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
ModelContext
↓
DbProvider
↓
AmbitenClientThe provider resolves database/client/session infrastructure.
It does not own the original execution boundary.
What Belongs in Context
Good context values describe execution.
Examples:
tenant identity
request identity
database override
collection override
transaction session
logging metadata
debug state
operation metadata
query observer
runtime budgetThese values answer questions such as:
Who does this execution belong to?
What infrastructure scope applies?
What transaction is active?
How should this execution be observed?
What runtime constraints apply?What Does Not Belong in Context
Avoid using context as a hidden domain-data store.
Examples that generally should remain in ordinary application data flow:
user form payloads
orders
shopping carts
business entities
large request bodies
domain aggregates
application workflow stateContext should describe how execution behaves, not replace function arguments for business data.
Common Runtime Pattern: Tenant Identity
const {
tenantId
} =
AmbitenContext.get();
console.log(
tenantId
);This is appropriate for runtime infrastructure that needs the current execution identity.
Application authorization should still be handled explicitly by the security layer.
Common Runtime Pattern: Logging
const {
requestId,
tenantId
} =
AmbitenContext.get();
logger.info(
"User created",
{
requestId,
tenantId
}
);Context makes correlation metadata available without changing every service signature.
Common Runtime Pattern: Auditing
const {
requestId,
tenantId
} =
AmbitenContext.get();
await AuditLogModel.create({
requestId,
tenantId,
event: "user.created"
});This can associate application audit data with the active execution.
Whether and what to audit remains an application policy decision.
Concurrent Execution
Because context state is execution-scoped, concurrent operations can carry different identities.
Request A
tenantId = tenant-a
requestId = req-a
↓
shared services
↓
shared models
Request B
tenantId = tenant-b
requestId = req-b
↓
shared services
↓
shared modelsShared application objects should not copy tenant or request state into mutable instance fields.
Read execution state from the current boundary where necessary.
Process Lifetime vs Execution Lifetime
This distinction is central to Ambiten.
PROCESS LIFETIME
────────────────
AmbitenRuntime
AmbitenClient
MongoDB connections
MultiTenantManager
provider configuration
logging infrastructureEXECUTION LIFETIME
──────────────────
AmbitenContext
tenantId
requestId
dbName
collectionName
session
logger metadata
debug state
operation metadata
observer
budgetLong-lived resources should be reusable.
Execution-specific state should remain isolated.
Context Does Not Make Infrastructure Request-Scoped
A context may be short-lived while the infrastructure it selects is long-lived.
For example:
Request A
↓
tenant-a context
↓
shared tenant-a MongoClientand later:
Request B
↓
tenant-a context
↓
same reusable tenant-a MongoClientContext isolation does not require creating a new database client for every execution.
Important Runtime Rules
Context exists only within the boundary that established it.
For supported request-driven environments:
use the framework adapterFor standalone execution:
use AmbitenContext.run(...)For explicit transactions:
use AmbitenContext.withTransaction(...)For detached or cross-process work:
propagate execution identity explicitly
and establish a new boundaryInside ordinary model execution, prefer runtime propagation:
await UserModel.create(data);instead of repeatedly reconstructing execution state:
await UserModel.create(
data,
{
tenantId,
session,
dbName
}
);unless an explicit operation-level override is intentional.
Operation-Level Overrides
Context is not an unconditional immutable lock on every model value.
Where the model API supports explicit operation context, the precedence is:
explicit operation context
↓
active AmbitenContext
↓
model defaultsThis allows controlled overrides while preserving deterministic resolution.
An override belongs to that operation.
It should not mutate the active context for unrelated operations.
Context Isolation
Avoid storing context-derived request state on shared process objects.
For example, avoid:
service.tenantId =
AmbitenContext.get().tenantId;when service is shared across concurrent executions.
That turns execution-scoped state into mutable process state.
Prefer reading the active context when needed or allowing the model runtime to derive its effective ModelContext.
Troubleshooting Context Loss
When execution state appears missing, inspect the execution boundary first.
Typical causes include:
application work executed outside the adapter boundary
background work never established AmbitenContext
queue/process boundary expected AsyncLocalStorage to cross automatically
detached asynchronous work outlived its boundary
framework subscription occurred outside the active context
transaction started outside the intended execution scopeThe useful debugging question is:
Is the code that needs the state still executing inside the boundary that created it?
If not, a new explicit execution boundary may be required.
Relationship with the Runtime
The detailed relationship is:
Execution Boundary
↓
AmbitenContextState
↓
AmbitenContext
↓
Application Logic
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
Schema / Middleware
↓
Provider / MultiTenantManager
↓
AmbitenClient
↓
MongoDBDifferent runtime components consume context at different levels.
AmbitenContext carries the full execution state.
AmbitenModel derives operation state.
Providers and infrastructure layers resolve the resources needed to execute that operation.
Mental Model
A useful mental model is:
Boundary creates execution.
Context carries execution.
Model binds execution to an operation.
ModelContext carries operation state.
Infrastructure interprets operation state.Or more compactly:
AmbitenContextState
= full execution state
AmbitenContext
= execution-state propagation
ModelContext
= model-operation state
AmbitenModel
= context binding + operation coordinationSummary
AmbitenContext is Ambiten's execution-scoped state mechanism.
It carries runtime information such as:
- tenant identity,
- request identity,
- database scope,
- collection scope,
- transaction sessions,
- logger metadata,
- debug state,
- operation metadata,
- query observers,
- execution budgets.
Its role is to make execution state available across supported asynchronous work without turning application services into infrastructure carriers.
For model execution:
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
Schema / Middleware / ProviderFor runtime infrastructure that reads context directly:
AmbitenContext
↓
runtime helper
↓
infrastructure behaviorAcross process, queue, or service boundaries, execution identity must be propagated explicitly and a new context boundary established.
The architectural principle is:
Context carries execution state. It does not replace application data, authorization, infrastructure ownership, or distributed coordination.
