Multi-tenancy does not always belong at the root of an application.
A production API may have routes such as:
/health
/status
/metrics
that should remain independent from tenant resolution, while application routes such as:
/users
/orders
/projects
need a tenant-aware execution boundary.
Fastify's plugin encapsulation makes this separation surprisingly natural.
While building the Fastify framework track for Ambiten, I ended up with a pattern that expresses this boundary directly in the application structure.
import Fastify from "fastify";
import {
createFastifyAdapter
} from "@ambiten/adapter-fastify";
import {
AmbitenContext,
MultiTenantManager
} from "@ambiten/core";
import {
registerDemoTenants
} from "./core/tenancy";
export function buildApp(
logger = false
) {
registerDemoTenants();
const app =
Fastify({
logger
});
// This route lives outside the
// tenant-protected child plugin.
app.get(
"/health",
async () => ({
status: "ok"
})
);
app.register(
async api => {
const adapter =
createFastifyAdapter();
await adapter.install(
api,
{
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;
}
}
}
);
api.get(
"/context",
async () => {
await new Promise<void>(
resolve =>
setImmediate(
resolve
)
);
const ctx =
AmbitenContext.get();
return {
tenantId:
ctx.tenantId,
requestId:
ctx.requestId,
dbName:
ctx.dbName
};
}
);
}
);
return app;
}
The interesting part is not the amount of code.
It is where the adapter is installed.
The Root Application Stays Public
The health route is registered directly on the root Fastify instance:
app.get(
"/health",
async () => ({
status: "ok"
})
);
It does not need tenant identity.
A monitoring system should be able to ask whether the process is healthy without first pretending to belong to a customer tenant.
Conceptually:
GET /health
↓
Fastify
↓
{ status: "ok" }
No tenant resolution is involved.
That is a useful distinction because infrastructure endpoints and application endpoints often have different execution requirements.
The Child Plugin Becomes the Tenant Boundary
The application routes live inside:
app.register(
async api => {
// tenant-aware application
}
);
Inside that scope, the Ambiten Fastify adapter is installed:
const adapter =
createFastifyAdapter();
await adapter.install(
api,
{
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;
}
}
}
);
The structure now communicates the architecture:
Fastify Application
│
├── /health
│ └── public infrastructure route
│
└── Child Plugin
│
├── Ambiten execution boundary
│
├── tenant resolution
│
└── /context
└── tenant-aware route
This is different from installing tenant behavior globally and then creating exceptions everywhere else.
The protected part of the application is explicitly contained.
Fastify Encapsulation Becomes an Architectural Tool
Fastify's plugin model gives child scopes their own encapsulated environment.
That means a runtime integration can be installed inside a particular plugin instead of automatically becoming a requirement for every route in the process.
For a larger application, the same idea could evolve into:
Application
│
├── /health
├── /metrics
│
├── /public/*
│
└── /api/*
↓
tenant-aware runtime
↓
services
↓
models
You could also create separate application areas with different policies:
/customer/*
↓
customer tenant policy
/admin/*
↓
administrative tenant policy
The framework hierarchy starts reflecting the execution hierarchy.
That is much easier to reason about than one global middleware layer containing many conditional branches.
The Adapter Must Be Installed Before the Routes
There is an important ordering detail.
The adapter should be installed before routes are registered inside the protected scope:
app.register(
async api => {
await createFastifyAdapter()
.install(
api,
options
);
api.get(
"/context",
handler
);
}
);
This ordering matters because the adapter establishes the framework execution boundary for routes registered in that scope.
Putting the route first would mean the route had already been registered before that integration was attached.
So the mental model is:
Create scope
↓
Install execution policy
↓
Register application routes
rather than:
Register application routes
↓
Try to retrofit execution policy
Proving That Context Survives Async Work
The /context endpoint deliberately contains this:
await new Promise<void>(resolve =>
setImmediate(
resolve
)
);
It is not necessary for the endpoint itself.
It is there to demonstrate something more important.
After crossing an asynchronous boundary, the resolver still reads:
const ctx = AmbitenContext.get();
and the active tenant remains available:
return {
tenantId:
ctx.tenantId,
requestId:
ctx.requestId,
dbName:
ctx.dbName
};
The desired execution path is:
Incoming Request
↓
Tenant Resolution
↓
Ambiten Execution Context
↓
Route Handler
↓
await
↓
Nested Async Work
↓
AmbitenContext.get()
↓
same execution state
That is the point of an execution-scoped runtime.
Application code should not have to pass:
tenantId
requestId
session
runtime metadata
through every function signature simply because the call stack became asynchronous.
Trying It
The health endpoint does not require a tenant:
curl http://localhost:3000/health
Response:
{
"status": "ok"
}
The tenant-aware route does:
curl \
-H "x-tenant-id: tenant-a" \
http://localhost:3000/context
A response may look like:
{
"tenantId": "tenant-a"
}
requestId and dbName may also appear when they are resolved or configured for the execution.
Calling the protected endpoint without a resolvable tenant should fail before application logic proceeds.
That gives the application two very different boundaries without maintaining two separate Fastify servers.
Tenant Resolution Is Not Authorization
There is one distinction worth keeping explicit.
Resolving:
x-tenant-id: tenant-a
answers:
Which tenant is this execution for?
It does not automatically answer:
Is this caller allowed to act for tenant-a?
A production application will often have a flow closer to:
Request
↓
Authentication
↓
Authorization
↓
Tenant Resolution
↓
Runtime Context
↓
Application Logic
These responsibilities can cooperate without being collapsed into the same concept.
Why I Like This Pattern
What I like most about this structure is that the code communicates the boundary without requiring much explanation.
The root application contains process-level and public endpoints.
The registered plugin contains tenant-aware application execution.
Inside that scope, the adapter establishes runtime continuity.
The result is:
Infrastructure routes
≠
Tenant application routes
while still keeping both inside one Fastify application.
This is one of those cases where framework encapsulation becomes more than an organizational feature.
It becomes part of the system architecture.
A More General Principle
The pattern also reflects a broader rule I have been following while developing Ambiten:
Execution policy should live at the boundary
where that policy actually becomes valid.
Not every route needs tenancy.
Not every operation needs a transaction.
Not every process-level resource belongs to a request.
Making those lifetimes explicit usually produces simpler code than applying everything globally and then adding exceptions.
For this Fastify example, the boundary is visible directly in app.register().
And that is exactly where the tenant-aware runtime begins.
Ambiten is an execution runtime for context-aware, multi-tenant MongoDB applications.
Documentation: https://docs.ambiten.dev
Top comments (0)