NestJS Adapter
The NestJS adapter connects NestJS's request execution pipeline to Ambiten's runtime model.
Unlike middleware-first frameworks, NestJS organizes request execution through modules, providers, guards, pipes, interceptors, controllers, and services.
Ambiten integrates with that lifecycle through a NestJS module and a global interceptor so downstream controller and service execution can run inside an active AmbitenContext.
HTTP Request
↓
NestJS Execution Pipeline
↓
Ambiten Interceptor
↓
adapter-runtime
↓
AmbitenContext
↓
Controller
↓
Service
↓
AmbitenModel
↓
Tenant DatabaseThe adapter does not replace NestJS's dependency injection or execution pipeline.
NestJS continues to own routing, providers, guards, pipes, interceptors, validation, exception handling, and application composition.
The Ambiten adapter owns the boundary between NestJS request execution and Ambiten's request-scoped runtime.
Why the NestJS Adapter Exists
Ambiten separates runtime startup, request execution, tenant infrastructure, and persistence into distinct layers.
AmbitenBootstrapFactory
→ prepares runtime infrastructure
NestJS Adapter
→ establishes request execution scope
AmbitenContext
→ carries request-scoped state
MultiTenantManager
→ manages tenant infrastructure
Controllers & Services
→ perform application orchestration
AmbitenModel
→ performs data operations
MongoDB
→ performs persistenceWithout the adapter, controllers or services would need to create Ambiten execution context manually.
The NestJS adapter establishes that boundary at the framework level instead.
Integration Model
The adapter is installed through AmbitenNestAdapterModule.forRoot(...).
import {
Module
} from "@nestjs/common";
import {
AmbitenNestAdapterModule
} from "@ambiten/adapter-nestjs";
@Module({
imports: [
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id"
}
})
]
})
export class AppModule {}Once registered, the Ambiten interceptor becomes part of NestJS's request lifecycle.
Downstream execution can then participate in Ambiten's request-scoped runtime.
Relationship with AmbitenBootstrapFactory
The NestJS adapter and AmbitenBootstrapFactory operate at different lifecycle stages.
AmbitenBootstrapFactory
→ prepares process-level runtime infrastructure
NestJS Adapter
→ establishes request-level execution scopeA typical application lifecycle is:
Process Starts
↓
AmbitenBootstrapFactory
↓
Runtime Ready
↓
NestJS Application
↓
AmbitenNestAdapterModule
↓
Application Starts Listening
↓
Requests Enter AmbitenContextBootstrap prepares the runtime.
NestJS integration connects request execution to it.
How the Adapter Works
NestJS request handling is orchestrated through the framework's execution pipeline.
The Ambiten adapter participates through an interceptor.
Conceptually, the interceptor:
- receives the NestJS
ExecutionContext, - obtains the underlying request,
- normalizes it into an Ambiten-compatible request shape,
- resolves tenant identity,
- validates tenancy when configured,
- establishes
AmbitenContext, - executes downstream controller and service work inside that runtime boundary.
The architecture is:
NestJS ExecutionContext
↓
HTTP Request
↓
Ambiten Request Normalization
↓
adapter-runtime
↓
Tenant Resolution
↓
AmbitenContext
↓
Controller ExecutionThis keeps NestJS-specific request types outside Ambiten Core.
Observable Execution Boundary
NestJS interceptors return RxJS Observables.
That creates an important integration requirement.
The Ambiten runtime boundary must cover the execution of the downstream Observable, not only the moment when next.handle() creates it.
Conceptually:
Interceptor Called
↓
runWithAdapterContext(...)
↓
subscribe to next.handle()
↓
controller executes
↓
service executes
↓
async operations
↓
AmbitenContext remains activeThis matters because next.handle() is typically a cold Observable.
Creating the Observable is not necessarily the same moment that controller execution occurs.
The NestJS adapter is therefore responsible for keeping the actual downstream execution within the Ambiten context boundary.
Execution Flow
A normal request follows:
HTTP Request
↓
NestJS
↓
Global Ambiten Interceptor
↓
Adapter Runtime
↓
Tenant Resolution
↓
AmbitenContext
↓
Controller
↓
Service
↓
AmbitenModel
↓
MultiTenantManager
↓
Tenant DatabaseNestJS continues to control the framework lifecycle.
Ambiten establishes the execution context used by application work.
Controllers Remain Focused
Once the adapter is registered, controllers should not need to coordinate Ambiten runtime infrastructure manually.
import {
Body,
Controller,
Get,
Post
} from "@nestjs/common";
@Controller("users")
export class UserController {
constructor(
private readonly service:
UserService
) {}
@Get()
getUsers() {
return this.service
.getUsers();
}
@Post()
createUser(
@Body() data: any
) {
return this.service
.createUser(data);
}
}The controller does not need to:
- create
AmbitenContext, - resolve tenant infrastructure,
- create tenant MongoDB clients,
- pass tenant headers into services,
- propagate request metadata manually.
Those concerns are handled through the surrounding runtime boundary.
Service Execution
Services can remain independent from the transport layer.
import {
Injectable
} from "@nestjs/common";
@Injectable()
export class UserService {
async getUsers() {
return UserModel.find({});
}
async createUser(
data: any
) {
return UserModel.create(
data
);
}
}The service does not need access to:
Request
ExecutionContext
HTTP headers
NestJS interceptor statejust to determine which tenant owns the operation.
That identity is available through AmbitenContext.
Tenant-Aware Execution
A common tenancy configuration resolves the tenant from an HTTP header.
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id"
}
});A request may contain:
POST /users
x-tenant-id: tenant5The adapter resolves:
AmbitenContext.get().tenantId;
// "tenant5"Conceptually:
x-tenant-id: tenant5
↓
NestJS Interceptor
↓
TenantResolver
↓
tenant5
↓
AmbitenContext
tenantId = tenant5
↓
Controller
↓
ServiceDownstream application code should normally use the resolved runtime identity:
const {
tenantId
} = AmbitenContext.get();rather than repeatedly reading the raw HTTP header.
Tenant Resolution vs Tenant Infrastructure
The NestJS adapter identifies which tenant belongs to the request.
It does not manage that tenant's infrastructure.
NestJS Adapter
→ Who is this request for?
AmbitenContext
→ Which tenant belongs to this execution?
MultiTenantManager
→ What resources belong to that tenant?The adapter may resolve:
tenant5without knowing:
MongoDB URI
database name
connection state
deployment region
tenant metadataThose concerns remain under MultiTenantManager.
Tenant Validation
Resolved tenants can be validated before controller execution proceeds.
For example:
import {
Module
} from "@nestjs/common";
import {
MultiTenantManager
} from "@ambiten/core";
import {
AmbitenNestAdapterModule
} from "@ambiten/adapter-nestjs";
@Module({
imports: [
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id",
validate: async (
tenantId
) => {
const tenant =
await MultiTenantManager
.resolveTenant(
tenantId
);
if (!tenant) {
throw new Error(
`Tenant with ID "${tenantId}" not found.`
);
}
return true;
}
}
})
]
})
export class AppModule {}The lifecycle becomes:
Request
↓
Resolve tenantId
↓
tenant5
↓
Validate
↓
Valid?
┌──┴──┐
yes no
↓ ↓
Context reject
↓
ControllerValidation may be asynchronous.
This allows tenant validation to work with dynamically discovered tenants as well as tenants registered during startup.
Dynamic Tenants
The NestJS adapter does not need to know whether a tenant was registered during application startup.
For example:
Request
x-tenant-id: tenant5
↓
NestJS Adapter
↓
tenantId = tenant5
↓
AmbitenContext
↓
Controller
↓
Service
↓
ModelIf tenant5 is not registered:
MultiTenantManager
↓
TenantConfigResolver
↓
external lookup
↓
register tenant5
↓
getClient()
↓
db_tenant5From the NestJS adapter's perspective, static and dynamic tenants are identical.
The adapter carries identity.
The runtime manages infrastructure.
Custom Tenant Resolution
Tenant identity does not have to originate from x-tenant-id.
Applications can provide custom tenant resolution logic.
Conceptually:
AmbitenNestAdapterModule.forRoot({
resolvers: {
tenantId: async (req) => {
return resolveTenantForRequest(
req
);
}
}
});A custom resolver may derive tenancy from:
- authenticated identity,
- JWT claims,
- cookies,
- subdomains,
- route information,
- gateway metadata,
- custom request state.
The runtime contract remains:
NestJS Request
↓
TenantResolver
↓
tenantId
↓
AmbitenContextControllers and services remain independent from the original resolution mechanism.
Authentication and Guards
NestJS applications commonly use guards for authentication and authorization.
Tenant resolution may depend on identity established by those guards.
Conceptually:
Request
↓
Authentication / Guard
↓
Authenticated Identity
↓
Tenant Resolution
↓
AmbitenContext
↓
ControllerIf a custom tenant resolver depends on framework state populated earlier in the NestJS pipeline, make sure the required information is available when the Ambiten interceptor performs resolution.
Authentication and tenancy should remain conceptually separate.
Authentication
→ Who is the caller?
Tenant Resolution
→ Which tenant is this execution for?
Authorization
→ May this caller act for this tenant?Security
A tenant identifier is not automatically proof of authorization.
For example:
x-tenant-id: tenant5may correctly identify a valid tenant while the authenticated caller is not permitted to access it.
A stronger lifecycle may look like:
HTTP Request
↓
Authentication
↓
Authenticated Identity
↓
Tenant Resolution
↓
Tenant Authorization
↓
AmbitenContext
↓
ControllerAmbiten provides the execution and tenancy runtime.
Application security remains the responsibility of the application.
Request Metadata
The adapter runtime can carry additional execution metadata alongside tenant identity.
Common context values include:
tenantId
requestId
dbName
collectionName
debug state
logger metadata
custom metadataFor example:
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id"
},
requestIdHeader:
"x-request-id",
dbNameHeader:
"x-db-name",
collectionNameHeader:
"x-collection-name"
});These values belong to the active request execution.
They should not be confused with process-level tenant configuration owned by MultiTenantManager.
Async Context Propagation
NestJS service methods frequently cross asynchronous boundaries.
For example:
@Injectable()
export class UserService {
async inspectContext() {
const before =
AmbitenContext
.get()
.tenantId;
await Promise.resolve();
const after =
AmbitenContext
.get()
.tenantId;
return {
before,
after
};
}
}For a request belonging to tenant5:
{
before: "tenant5",
after: "tenant5"
}The adapter's execution boundary should remain active throughout controller and service execution associated with that request.
Services Do Not Need Request Injection for Tenant Identity
Without runtime context, an application may be tempted to pass request objects through service layers.
Controller
↓ passes Request
Service
↓ passes Request
Repository
↓ reads tenant headerAmbiten allows:
NestJS Interceptor
↓
AmbitenContext
↓
Controller
↓
Service
↓
Repository
↓
ModelA service can access:
const {
tenantId,
requestId
} = AmbitenContext.get();without becoming request-scoped merely to receive tenant identity.
This is especially useful in NestJS applications where unnecessary request-scoped providers can affect lifecycle complexity and performance.
Singleton Providers and Request Context
NestJS services are commonly singleton providers.
Ambiten context does not require every service that accesses request state to become a NestJS request-scoped provider.
For example:
@Injectable()
export class AuditService {
logOperation() {
const {
tenantId,
requestId
} = AmbitenContext.get();
// ...
}
}The service instance may remain long-lived while AmbitenContext provides execution-specific state for the active request.
Conceptually:
Singleton Service
↓
called by Request A
↓
AmbitenContext A
same Service
↓
called by Request B
↓
AmbitenContext BDo not store tenant-specific request state directly on singleton provider instance fields.
Avoid:
@Injectable()
export class UserService {
currentTenantId:
string | undefined;
}Request-specific state belongs in the execution context, not in shared provider instances.
Models Use the Active Runtime Context
Controllers and services can perform ordinary model operations:
@Injectable()
export class UserService {
async createUser(
data: any
) {
return UserModel.create(
data
);
}
}If the active request contains:
{
tenantId: "tenant5"
}the runtime can resolve:
UserModel.create(...)
↓
AmbitenContext
tenantId = tenant5
↓
MultiTenantManager
↓
tenant5 client
↓
db_tenant5Neither the controller nor service needs to manually select the database.
Transactions
The NestJS adapter can establish transaction-aware request execution.
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id"
},
enableTransactions: true
});When enabled, downstream controller and service execution enters Ambiten through a transaction-aware request boundary.
Conceptually:
HTTP Request
↓
NestJS Interceptor
↓
AmbitenContext
↓
Transaction Boundary
↓
Controller
↓
Service
↓
Model OperationsFor example:
@Injectable()
export class OrderService {
async createOrder(
data: any
) {
const order =
await OrderModel.create(
data
);
await InventoryModel.updateOne(
{
_id: data.itemId
},
{
$inc: {
stock: -1
}
}
);
return order;
}
}When request-wide transactions are enabled, the service already executes inside that transaction-aware boundary.
Explicit Transaction Boundaries
Applications may instead leave automatic request transactions disabled and create explicit transaction scopes only where needed.
import {
Injectable
} from "@nestjs/common";
import {
AmbitenContext
} from "@ambiten/core";
@Injectable()
export class OrderService {
async createOrder(
data: any
) {
return AmbitenContext
.withTransaction(
async () => {
const order =
await OrderModel.create(
data
);
await InventoryModel
.updateOne(
{
_id:
data.itemId
},
{
$inc: {
stock: -1
}
}
);
return order;
}
);
}
}The distinction is:
enableTransactions: true
→ request-wide transaction boundary
AmbitenContext.withTransaction(...)
→ explicit operation-specific transactionChoose the strategy that matches the application's consistency requirements.
Transaction Scope Should Be Intentional
Not every NestJS request should automatically require a transaction.
Use request-wide transactions when the entire request represents one atomic unit.
Use explicit transaction scopes when only a particular workflow requires atomic consistency.
Request
↓
multiple dependent writes?
│
├── yes → transaction may be appropriate
│
└── no → ordinary execution may be sufficientAvoid adding transaction cost without a consistency requirement.
NestJS Dependency Injection
Ambiten does not replace NestJS dependency injection.
Providers continue to be constructed and managed by NestJS.
For example:
@Injectable()
export class UserService {
constructor(
private readonly audit:
AuditService
) {}
}The dependency graph remains under NestJS.
Ambiten provides the runtime state associated with the active execution.
NestJS DI
→ manages application dependencies
AmbitenContext
→ manages execution-scoped runtime stateThese systems complement one another.
Request-Scoped Providers vs AmbitenContext
NestJS supports request-scoped providers, but not every service needs to become request-scoped simply because it needs the current tenant.
If the only request-specific information required is runtime state such as:
tenantId
requestId
transaction sessionthat state can remain in AmbitenContext.
Request-scoped providers remain useful when the provider itself genuinely requires a per-request lifecycle.
The two mechanisms solve different problems.
Guards, Pipes, and Interceptors
NestJS has multiple execution stages.
Conceptually:
Request
↓
Middleware
↓
Guards
↓
Interceptors
↓
Pipes / Controller
↓
ServiceApplications should consider where required tenant-resolution inputs become available.
For example, if authentication information is produced by a guard and tenant resolution depends on that information, integration order becomes part of the application's architecture.
The Ambiten adapter should have access to the data required by its configured resolver when it establishes runtime context.
Exception Handling
NestJS exception filters remain responsible for framework-level error handling.
Tenant resolution or validation may fail before controller logic executes.
Conceptually:
Request
↓
Ambiten Interceptor
↓
Tenant Resolution
├── unresolved
↓
Tenant Validation
├── invalid
↓
Controller
├── application error
↓
Model
├── database errorThese failure classes can be mapped into NestJS exceptions or filters according to the application's API policy.
For tenant-required routes, unresolved tenancy should not silently fall back to an unrelated tenant database.
Adapter Registration Is Application Infrastructure
The adapter module should normally be registered once in the application composition.
@Module({
imports: [
AmbitenNestAdapterModule.forRoot({
tenancy: {
header: "x-tenant-id"
}
})
]
})
export class AppModule {}Conceptually:
Application Bootstrap
↓
Register Ambiten Module
↓
Global Interceptor Active
↓
Request A → AmbitenContext A
Request B → AmbitenContext B
Request C → AmbitenContext CThe module and interceptor infrastructure are long-lived.
The contexts established for requests are isolated execution scopes.
Runtime Shutdown
The NestJS adapter manages request execution boundaries.
Ambiten runtime infrastructure should still be shut down at the application lifecycle boundary.
For example:
async function shutdown() {
await app.close();
await runtime.shutdown();
}Do not call:
runtime.shutdown();inside individual controllers or services.
The runtime belongs to the application process rather than one request.
ESM and CommonJS
NestJS applications can participate in Ambiten's ESM and CommonJS package boundaries.
Conceptually:
ESM NestJS Application
↓
NestJS Adapter ESM
↓
adapter-runtime ESM
↓
Ambiten Core ESMand:
CommonJS NestJS Application
↓
NestJS Adapter CJS
↓
adapter-runtime CJS
↓
Ambiten Core CJSThis matters because request-scoped state must remain consistent across the adapter and Core boundary.
Use public package imports.
For example:
import {
AmbitenContext
} from "@ambiten/core";and the public NestJS adapter package.
Avoid internal build imports such as:
@ambiten/core/dist/...
@ambiten/adapter-runtime/dist/...Public exports allow the correct runtime entry to be selected for the application's module format.
Testing NestJS Integration
A meaningful NestJS integration test should verify execution inside the real framework lifecycle.
For example:
@Injectable()
export class ContextService {
async getTenant() {
await Promise.resolve();
return AmbitenContext
.get()
.tenantId;
}
}A controller may expose:
@Controller("context")
export class ContextController {
constructor(
private readonly service:
ContextService
) {}
@Get()
getContext() {
return this.service
.getTenant();
}
}A request containing:
GET /context
x-tenant-id: tenant5should observe:
tenant5inside the service after the asynchronous boundary.
This verifies more than module registration.
It verifies:
HTTP Request
↓
NestJS Interceptor
↓
Observable Execution
↓
AmbitenContext
↓
Controller
↓
Service
↓
async boundary
↓
tenant5 still availableFor tenant-aware database integration, the test can also inspect:
const tenant =
MultiTenantManager
.getTenant("tenant5");after a model operation.
Why NestJS Integration Is Different
NestJS is designed around framework-managed execution and dependency injection.
Ambiten therefore integrates through:
Dynamic Module
→ configuration
Global Interceptor
→ execution boundary
adapter-runtime
→ shared Ambiten request execution
AmbitenContext
→ runtime state
Controllers & Services
→ application logic
MultiTenantManager
→ tenant infrastructureThis approach follows NestJS's architectural model instead of forcing an Express-style middleware pattern into the framework.
Why the Observable Boundary Matters
NestJS controller execution is exposed to interceptors through RxJS.
That means the runtime contract must account for two moments:
Observable createdand:
Observable subscribed / executedThose moments are not necessarily equivalent.
For Ambiten, the important requirement is:
Controller and service execution
must occur inside
the active AmbitenContextA correct adapter therefore treats the Observable execution lifecycle as part of the request boundary.
This is the key NestJS-specific difference from middleware-oriented adapters.
When to Use the NestJS Adapter
The NestJS adapter is appropriate for applications that rely on:
- NestJS modules,
- dependency injection,
- controllers,
- providers,
- layered service architecture,
- guards and interceptors,
- multi-tenant request execution,
- transaction-aware workflows,
- runtime context propagation.
It is especially useful when application services should remain independent from transport-specific request plumbing.
Recommended Mental Model
Think of the NestJS adapter as a framework-level execution-boundary initializer.
NestJS
→ owns application lifecycle and DI
NestJS Adapter
→ enters Ambiten runtime
TenantResolver
→ identifies tenant
AmbitenContext
→ carries execution state
Controllers
→ expose application operations
Services
→ orchestrate domain behavior
MultiTenantManager
→ resolves tenant resources
AmbitenModel
→ performs data operations
MongoDB
→ persists dataNestJS manages application composition.
Ambiten manages runtime execution state.
Summary
The NestJS adapter connects NestJS's execution pipeline to Ambiten's runtime model.
It:
- integrates through a NestJS module and interceptor,
- normalizes incoming request information,
- resolves tenant identity,
- validates tenancy when configured,
- establishes
AmbitenContext, - preserves execution state through controller and service async flows,
- supports transaction-aware request execution,
- allows dynamic tenant discovery downstream,
- keeps singleton services free from shared request state,
- keeps tenant infrastructure outside controllers and providers.
The complete flow is:
HTTP Request
↓
NestJS Execution Pipeline
↓
Ambiten Interceptor
↓
Tenant Resolution
↓
AmbitenContext
↓
Controller
↓
Service
↓
AmbitenModel
↓
MultiTenantManager
↓
Tenant Database