Dynamic Tenants
Dynamic tenants allow Ambiten to work with tenants that are not registered when the application starts.
Instead of requiring every tenant configuration to be loaded into memory during bootstrap, Ambiten can discover tenant infrastructure on demand when a request references an unknown tenant.
This makes dynamic tenancy suitable for systems where tenant configuration lives in an external source such as:
- a control database,
- a tenant registry service,
- a configuration API,
- an account service,
- a secrets-backed infrastructure store,
- or another application-defined configuration source.
The runtime flow is:
Request
↓
TenantResolver
↓
tenantId
↓
AmbitenContext
↓
MultiTenantManager
↓
tenant registered?
├── yes → use existing configuration
│
└── no
↓
TenantConfigResolver
↓
external tenant lookup
↓
tenant configuration found
↓
register tenant
↓
getClient()
↓
tenant databaseDynamic discovery extends the local runtime registry without changing the way application code interacts with models.
A dynamically discovered tenant ultimately behaves like any other registered tenant.
Static and Dynamic Tenants
Ambiten supports both static and dynamic tenant registration.
A static tenant is known when multi-tenancy is initialized.
For example:
await runtime.registerMultiTenancy({
tenants: {
tenant1: "mongodb://localhost:27017/db_tenant1",
tenant2: "mongodb://localhost:27017/db_tenant2",
tenant3: "mongodb://localhost:27017/db_tenant3"
},
lazy: true
});These tenants enter the runtime registry during application startup.
A dynamic tenant is different.
For example:
tenant5may not exist in the local registry when the application starts.
Instead, Ambiten can discover it later:
Application startup
↓
tenant1 registered
tenant2 registered
tenant3 registered
Later...
Request for tenant5
↓
tenant5 not registered
↓
dynamic resolution
↓
tenant5 registeredStatic and dynamic tenants eventually share the same runtime infrastructure.
The difference is only when and how their configuration becomes known.
TenantConfigResolver
Dynamic tenant discovery is handled through a TenantConfigResolver.
Its responsibility is simple:
Given a tenant ID, return the infrastructure configuration required for that tenant.
Conceptually:
tenantId
↓
TenantConfigResolver
↓
TenantConfigA resolver may look like this:
MultiTenantManager.setTenantConfigResolver({
async resolve(tenantId) {
const config =
await lookupTenant(tenantId);
if (!config) {
return undefined;
}
return {
tenantId,
uri: config.uri,
dbName: config.dbName,
lazy: true,
metadata: config.metadata
};
}
});The resolver does not need to know anything about:
Express
Fastify
NestJS
HTTP headers
route parameters
cookies
controllers
modelsIt receives a resolved tenant identity and returns tenant infrastructure.
This keeps transport concerns separate from tenant configuration.
TenantResolver vs TenantConfigResolver
Dynamic tenancy depends on an important distinction between two resolver types.
TenantResolver
A request-facing tenant resolver answers:
Which tenant does this request belong to?
For example:
HTTP request
↓
x-tenant-id: tenant5
↓
TenantResolver
↓
tenant5The result is only the identity:
"tenant5"TenantConfigResolver
A tenant configuration resolver answers:
Where does tenant5 live?
For example:
tenant5
↓
TenantConfigResolver
↓
MongoDB URI
database name
metadata
lazy configurationConceptually:
TenantResolver
→ request → tenantId
TenantConfigResolver
→ tenantId → tenant configurationThe request resolver should not need to know the tenant's MongoDB connection information.
The configuration resolver should not need to understand the incoming HTTP request.
This separation allows each concern to evolve independently.
Dynamic Resolution Flow
Suppose the runtime starts with three tenants:
tenant1
tenant2
tenant3and all three are registered in lazy mode.
Runtime statistics may initially show:
{
registeredTenants: 3,
connectedTenants: 0,
lazyTenants: 3,
dynamicResolverEnabled: true
}A request then arrives with:
x-tenant-id: tenant5The adapter resolves:
AmbitenContext.get().tenantId;
// "tenant5"When the runtime needs tenant infrastructure:
await MultiTenantManager.resolveTenant(
"tenant5"
);Ambiten follows this path:
resolveTenant("tenant5")
↓
getTenant("tenant5")
↓
not registered
↓
TenantConfigResolver.resolve("tenant5")
↓
configuration found
↓
register tenant5
↓
return tenant configThe tenant is now part of the local runtime registry.
If a database operation then requires an active client:
await MultiTenantManager.getClient(
"tenant5"
);the flow continues:
tenant5 registered
↓
client available?
┌──┴──┐
yes no
↓ ↓
return connect
client ↓
return clientThe resulting tenant state may look like:
{
tenantId: "tenant5",
dbName: "db_tenant5",
connected: true,
lazy: false,
metadata: {
region: "de-west-1",
tier: "supreme"
}
}while the aggregate runtime statistics become:
{
registeredTenants: 4,
connectedTenants: 1,
lazyTenants: 3,
dynamicResolverEnabled: true
}Only the tenant that required database access has become connected.
The original three tenants remain lazy.
Dynamic Discovery Does Not Mean Immediate Connection
Tenant discovery and database connection are separate operations.
When:
await MultiTenantManager.resolveTenant(
"tenant5"
);succeeds, the tenant can become registered without necessarily opening a MongoDB client immediately.
Conceptually:
unknown
↓
resolved
↓
registered
↓
still lazyA connection is only required when:
await MultiTenantManager.getClient(
"tenant5"
);or another runtime operation requires database access.
The lifecycle may therefore be:
unknown tenant
↓
resolved externally
↓
registered
↓
lazy
↓
first database operation
↓
connectedThis separation helps prevent unnecessary tenant connections from being opened simply because tenant configuration was discovered.
Dynamic Resolution Through Model Operations
In normal application code, developers usually do not need to manually orchestrate dynamic resolution.
For example:
app.post("/users", async (req, res) => {
const user =
await UserModel.create(req.body);
res.status(201).json(user);
});If the active context contains:
{
tenantId: "tenant5"
}and "tenant5" has not been registered yet, Ambiten can resolve the tenant as part of the runtime database-selection path.
Conceptually:
UserModel.create(...)
↓
AmbitenContext
tenantId = tenant5
↓
AmbitenClient
↓
MultiTenantManager
↓
tenant5 registered?
↓ no
TenantConfigResolver
↓
tenant5 configuration
↓
register
↓
getClient()
↓
db_tenant5
↓
insertApplication code remains focused on the model operation.
It does not need to manually coordinate:
tenant discovery
tenant registration
client creation
database selectionfor every request.
Validation with Dynamic Tenants
Tenant validation can participate in dynamic discovery.
For example:
await adapter.install(app, {
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;
}
}
});This allows a request to reference a tenant that is valid but not yet registered locally.
The flow becomes:
Request
↓
tenant5
↓
validate("tenant5")
↓
resolveTenant("tenant5")
↓
local registry miss
↓
TenantConfigResolver
↓
tenant found
↓
register
↓
validation succeeds
↓
AmbitenContextThis avoids requiring the application to preload every valid tenant during startup.
Missing Dynamic Tenants
A dynamic resolver may return no configuration.
For example:
MultiTenantManager.setTenantConfigResolver({
async resolve(tenantId) {
const config =
await lookupTenant(tenantId);
if (!config) {
return undefined;
}
return {
tenantId,
uri: config.uri,
dbName: config.dbName,
lazy: true
};
}
});For an unknown tenant:
tenant999the flow may become:
tenant999
↓
local registry miss
↓
TenantConfigResolver
↓
no configuration
↓
unresolved tenantApplication behavior should then follow the tenancy policy configured at the adapter or service boundary.
For tenant-required routes, unresolved tenants should normally be rejected rather than silently falling back to another tenant.
Avoid Default-Tenant Fallback for Unknown Dynamic Tenants
Dynamic tenant resolution should not be treated as:
tenant not found
↓
use default databaseThat behavior can break tenant isolation.
If the request belongs to:
tenant5but "tenant5" cannot be resolved, the safer runtime invariant is:
tenant5 unresolved
↓
do not continue as another tenantA multi-tenant system should avoid silently converting:
unknown tenantinto:
global/default tenantunless the application has explicitly designed that behavior for a specific non-tenant route.
Concurrent Dynamic Resolution
In a real application, multiple requests may reference the same previously unknown tenant at nearly the same time.
Conceptually:
Request A → tenant5
Request B → tenant5
Request C → tenant5Without coordination, all three requests could attempt the same external lookup or registration work.
MultiTenantManager.resolveTenant() is designed as the central resolution path so that dynamic resolution can be coordinated at the runtime level rather than independently by controllers or services.
Application code should therefore prefer:
await MultiTenantManager.resolveTenant(
tenantId
);over directly calling a tenant configuration resolver itself.
The manager is the owner of the runtime registry.
The resolver is only the provider of external configuration.
Application
↓
MultiTenantManager
↓
TenantConfigResolverrather than:
Application
↓
TenantConfigResolver directlyThis keeps dynamic discovery, registration, and runtime state under one authority.
External Tenant Stores
The resolver can use any external source that can provide the required tenant configuration.
Control Database
async function lookupTenant(
tenantId: string
) {
return controlDb
.collection("tenants")
.findOne({ tenantId });
}The resolver can then map the stored record into the configuration expected by Ambiten:
MultiTenantManager.setTenantConfigResolver({
async resolve(tenantId) {
const record =
await lookupTenant(tenantId);
if (!record) {
return undefined;
}
return {
tenantId,
uri: record.mongoUri,
dbName: record.databaseName,
lazy: true,
metadata: {
region: record.region,
tier: record.tier
}
};
}
});Configuration Service
An application may instead call an internal service:
async function lookupTenant(
tenantId: string
) {
const response =
await fetch(
`https://tenant-control.internal/${tenantId}`
);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(
"Tenant configuration service unavailable."
);
}
return response.json();
}The source is application-defined.
The runtime contract remains the same:
tenantId
↓
resolver
↓
tenant configurationTenant Metadata
Dynamic configuration may include tenant metadata.
For example:
return {
tenantId,
uri: config.uri,
dbName: config.dbName,
lazy: true,
metadata: {
region: config.region,
tier: config.tier
}
};The resulting registered tenant may expose:
{
tenantId: "tenant5",
dbName: "db_tenant5",
metadata: {
region: "de-west-1",
tier: "supreme"
}
}Metadata can be useful for:
- observability,
- operational diagnostics,
- region awareness,
- tenant classification,
- infrastructure decisions.
Metadata is runtime tenant information.
It should not be confused with request-scoped context.
Dynamic Tenant Metadata vs AmbitenContext Metadata
These two concepts serve different purposes.
Tenant configuration metadata:
{
region: "de-west-1",
tier: "supreme"
}describes the tenant.
Request context metadata:
AmbitenContext.get().metadescribes the active execution.
Conceptually:
Tenant metadata
→ belongs to the tenant
Context metadata
→ belongs to this request/operationThe fact that both may contain application-defined information does not make them interchangeable.
Dynamic Tenants and Lazy Connections
Dynamic resolution works especially well with lazy connections.
A tenant can be discovered and registered without requiring the runtime to keep every possible tenant connected.
Imagine an application serving thousands of tenants.
Opening every connection at startup would create a runtime model like:
startup
↓
connect tenant1
connect tenant2
connect tenant3
...
connect tenant5000Dynamic resolution with lazy activation instead allows:
startup
↓
load only required static configuration
↓
wait for requests
tenant317 request
↓
discover tenant317
↓
connect tenant317
tenant842 request
↓
discover tenant842
↓
connect tenant842This allows runtime resource usage to follow actual application demand rather than the total theoretical tenant population.
Dynamic Tenants Are Registered Locally After Resolution
Once a dynamic tenant has been successfully resolved, it becomes part of the current runtime registry.
For example:
await MultiTenantManager.resolveTenant(
"tenant5"
);followed by:
const tenant =
MultiTenantManager.getTenant(
"tenant5"
);can now return the locally registered configuration.
The runtime therefore moves through:
before resolution
getTenant("tenant5")
→ not registeredthen:
resolveTenant("tenant5")
→ external lookup
→ registeredthen:
getTenant("tenant5")
→ local registry hitThis means external discovery is not required every time the tenant is referenced during the lifetime of that runtime registration.
getTenant() vs resolveTenant() for Dynamic Tenants
The distinction between these methods is especially important for dynamic tenancy.
getTenant()
MultiTenantManager.getTenant(
"tenant5"
);asks:
Is tenant5 already registered here?
It does not perform external discovery.
resolveTenant()
await MultiTenantManager.resolveTenant(
"tenant5"
);asks:
Can Ambiten resolve tenant5, either locally or externally?
Conceptually:
getTenant()
= local registry only
resolveTenant()
= local registry
+
TenantConfigResolverFor dynamic tenants, use resolveTenant() when external discovery is permitted or required.
resolveTenant() vs getClient()
Dynamic discovery and active database access are also separate concerns.
await MultiTenantManager.resolveTenant(
"tenant5"
);means:
Resolve and register this tenant if possible.
While:
await MultiTenantManager.getClient(
"tenant5"
);means:
Resolve this tenant if necessary and give me a usable MongoDB client.
The relationship is:
resolveTenant()
= identity → configuration
getClient()
= identity → configuration → connectionThis distinction allows tenant configuration to be inspected without necessarily opening a database connection.
Dynamic Tenant Lifecycle
A dynamically discovered tenant typically moves through these states:
UNKNOWN
↓
request references tenant
↓
RESOLVING
↓
TenantConfigResolver
↓
REGISTERED
↓
lazy
↓
database operation
↓
CONNECTING
↓
CONNECTEDIn simplified form:
unknown
→ resolved
→ registered
→ connected when neededThe transition from registered to connected is intentionally independent from discovery.
Dynamic Tenants and Model Routing
Once the dynamic tenant has entered the runtime, Ambiten models use the same routing behavior as they do for statically registered tenants.
For example:
const user =
await UserModel.create({
username: "Abinod Ltd",
email: "aemma@abinod.com"
});with:
AmbitenContext.get().tenantId;
// "tenant5"can result in:
UserModel
↓
tenant5
↓
db_tenant5The model does not need to know whether "tenant5" was:
registered during startupor:
discovered thirty milliseconds agoThat distinction belongs to the runtime.
Dynamic Tenants and Framework Adapters
Framework adapters resolve request identity but do not own dynamic tenant infrastructure.
For example:
Express
Fastify
NestJS
↓
TenantResolver
↓
tenant5
↓
AmbitenContextThe adapter does not need to know:
tenant5 MongoDB URI
tenant5 database name
tenant5 region
tenant5 connection stateThose remain under MultiTenantManager.
This gives the architecture a clear boundary:
Framework Adapter
→ request identity
MultiTenantManager
→ tenant infrastructureSee Framework Adapters for request integration.
Failure Handling
Dynamic tenant resolution can fail for more than one reason.
Tenant Does Not Exist
The resolver may return:
undefinedwhen the tenant is unknown.
This is a normal resolution outcome.
External Resolver Failure
The configuration source may be unavailable:
tenant registry unavailable
network failure
control database unavailableThat is different from:
tenant does not existApplications should preserve this distinction where operationally important.
For example:
async resolve(tenantId) {
try {
const config =
await tenantService.find(tenantId);
return config ?? undefined;
} catch (error) {
throw new Error(
`Unable to resolve tenant "${tenantId}".`,
{ cause: error }
);
}
}Returning undefined should generally mean:
tenant not foundwhile throwing should represent:
resolution failedThis distinction can improve diagnostics and observability.
Security Considerations
A dynamically resolved tenant ID should not automatically be treated as proof that the requester is authorized to use that tenant.
For example:
x-tenant-id: tenant5may successfully resolve "tenant5" from the tenant registry.
That only proves:
tenant5 existsIt does not prove:
this requester may access tenant5A secure application may need:
Authentication
↓
Authenticated identity
↓
Requested tenant
↓
Authorization
↓
Tenant resolution
↓
AmbitenContextor another policy appropriate to the application's security model.
Tenant discovery and tenant authorization are separate concerns.
Avoid Putting Tenant Secrets in Request Context
Dynamic tenant configuration may contain sensitive infrastructure values such as:
MongoDB URI
credentials
service endpoints
internal metadataThese values belong to the tenant resource layer.
Do not copy them into:
AmbitenContextunless there is a specific request-scoped reason to do so.
Prefer:
AmbitenContext
→ tenantId
MultiTenantManager
→ URI, dbName, client, metadataThis reduces unnecessary propagation of infrastructure data through application layers.
Operational Example
Consider a runtime initialized with:
tenant1
tenant2
tenant3all in lazy mode.
Initial state:
{
registeredTenants: 3,
connectedTenants: 0,
lazyTenants: 3,
dynamicResolverEnabled: true
}A request arrives:
POST /users
x-tenant-id: tenant5Tenant resolution produces:
AmbitenContext.get().tenantId;
// "tenant5"The validator or model path invokes:
await MultiTenantManager.resolveTenant(
"tenant5"
);The external resolver returns:
{
tenantId: "tenant5",
uri: "...",
dbName: "db_tenant5",
lazy: true,
metadata: {
region: "de-west-1",
tier: "supreme"
}
}A model operation then requires the client:
await UserModel.create({
username: "Abinod Ltd",
email: "aemma@abinod.com"
});The resulting tenant state becomes:
{
tenantId: "tenant5",
dbName: "db_tenant5",
connected: true,
lazy: false,
metadata: {
region: "de-west-1",
tier: "supreme"
}
}and runtime statistics become:
{
registeredTenants: 4,
connectedTenants: 1,
lazyTenants: 3,
dynamicResolverEnabled: true
}This demonstrates the complete dynamic lifecycle:
request identity
↓
unknown tenant
↓
external discovery
↓
registration
↓
lazy activation
↓
correct tenant databaseRecommended Mental Model
A useful way to think about dynamic tenancy is:
TenantResolver
→ Who?
TenantConfigResolver
→ Where?
MultiTenantManager.resolveTenant()
→ Does Ambiten know how to reach this tenant?
MultiTenantManager.getClient()
→ Give Ambiten an active resource for this tenant.
AmbitenContext
→ Which tenant belongs to this execution?Or as one flow:
Request
↓
Who?
↓
tenant5
↓
Where?
↓
db_tenant5
↓
Need database access?
↓
connect when required
↓
execute operationThis keeps request identity, external discovery, resource lifecycle, and database access related without collapsing them into the same concern.
Related Pages
Continue with:
- Multi-Tenancy Overview — the overall Ambiten multi-tenancy architecture.
- Tenant Resolution — how incoming requests are mapped to tenant identities.
- MultiTenantManager — tenant registry, lookup, discovery, connection lifecycle, and runtime state.
- Framework Adapters — how framework requests enter Ambiten's execution context.
For exact TenantConfigResolver, TenantConfig, and MultiTenantManager type signatures, see the corresponding Ambiten API reference.
