
Development
One Airport, Many Airlines: The Architecture Behind a Multi-Tenant Backend
How one deployed server serves hundreds of isolated tenants — the design decisions, the code patterns, and the traps we navigated along the way.
The Problem We Set Out to Solve
Picture a major international airport. Thousands of passengers arrive every hour. Some are flying Emirates, some are on Singapore Airlines, some are on budget carriers. They all walk through the same entrance, breathe the same air-conditioned air, use the same runways — yet an Emirates passenger never accidentally boards a Singapore Airlines flight. Their luggage never ends up on the wrong conveyor belt.
How does one building serve hundreds of airlines and thousands of passengers without a single mix-up?
That is the exact problem a multi-tenant backend solves. One deployed server. One database. Hundreds of schools, each completely isolated from the others, each feeling like they have their own private system.
This is how we built that airport.
The Framework — The Airport Building Itself
The entire backend is built on NestJS, a Node.js framework that enforces structure through modules, controllers, and services.
If the backend is an airport:
Every piece of logic lives in exactly the right terminal. Nothing bleeds into somewhere it does not belong.
The Terminal Map — How the Modules Are Organised
The application has three passenger terminals and one operations centre, each with a separate entrance and completely independent staff:

Each module is imported into the root AppModule exactly once. They share a database connection through a global PrismaModule, but their routes, guards, and services never directly call into each other.
The Boarding Pass — Tenant Identity in the JWT
Every airline passenger carries a boarding pass. It contains their name, their destination, their airline — and crucially, it cannot be forged. In our system, the JWT is the boarding pass.
When a user logs in, the token payload includes not just their user ID and role, but their tenant ID — the unique identifier of the school they belong to:
// auth.service.ts — signing the boarding pass
async signIn(dto: SignInDto): Promise<AuthResponse> {
const user = await this.validateUser(dto.email, dto.password);
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
tenantId: user.school_id, // ← the airline code on the boarding pass
};
return { access_token: await this.jwt.signAsync(payload) };
}
Why embed tenantId in the token?
Every request is stateless — no session lookup, no extra DB call to figure out which school this user belongs to. The tenant identity travels with the request, verified by the JWT signature. Fast, tamper-proof, and zero extra round trips.
The Security Scanner — The Tenant Guard
Knowing a passenger’s airline code is not enough. The gate scanner must actively verify that they are only trying to board their own flight. Our equivalent is the Tenant Guard — a NestJS guard that runs after JWT verification on every protected route.
// tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const user: JwtPayload = req.user; // set by JwtAuthGuard
// URL param: /schools/:schoolId/students
const urlTenantId = req.params.schoolId;
if (urlTenantId && urlTenantId !== user.tenantId) {
throw new ForbiddenException('Wrong terminal.');
}
return true;
}
}
This guard runs on every admin and student route. An admin from School A who somehow crafts a request to School B’s endpoint gets a 403 Forbidden — they tried to walk through the wrong gate.
The Conveyor Belt — Tenant-Scoped Database Queries
The guard stops cross-tenant access. But we need a second layer: the database queries themselves must always be scoped to the correct tenant. If the guard is the gate scanner, the database layer is the conveyor belt — luggage must only reach the right carousel.
Every service method that reads data takes tenantId as an explicit parameter, extracted from the JWT payload and passed down from the controller:
// students.service.ts
async findAll(tenantId: string, page: number) {
return this.prisma.student.findMany({
where: {
school_id: tenantId, // ← always scoped
is_active: true,
},
skip: (page - 1) * 20,
take: 20,
orderBy: { created_at: 'desc' },
});
}
Defence in depth
The guard and the DB-level scope are both needed. The guard catches bad URLs. The DB scope catches any code path that forgets to check — a background job, an admin shortcut, a future developer who skips the guard. Never rely on a single layer for tenant isolation.
The Full Request Journey
Here is what happens from the moment a teacher at School A sends a request to fetch her students, to the moment she gets the list back:
Request Lifecycle — Tenant-Scoped Read

Shared vs Isolated Data
Not everything belongs to a single tenant. Some data is shared across the whole platform — think currency lists, country codes, global feature flags. In airport terms: the duty-free shops and the runways are shared infrastructure that serves all airlines.

The Tradeoffs We Accepted
Row-level multi-tenancy (one database, school_id on every table) is the simplest approach but not the only one. We chose it deliberately:
- Schema-per-tenant gives stronger isolation and easier backup per tenant — but migrations become a nightmare at scale. Running one migration across 500 Postgres schemas in sequence is a 2 AM incident waiting to happen.
- Database-per-tenant is the gold standard for isolation — but the infrastructure cost is proportional to tenant count. Fine for enterprise; impractical for hundreds of small schools.
- Row-level (our approach) is operationally simple — one DB, one migration, shared connection pool — but requires discipline at the query layer. One missing
WHERE school_id = ?is a data leak. The two-layer defence (guard + DB scope) is what makes this safe.
The airport insight: the building itself doesn’t guarantee that Emirates passengers don’t board Singapore flights. The signs, the gate agents, the boarding pass scanners — the process does. Architecture is the same. The framework gives you the structure; the guards and query patterns are what actually enforce the rules.
A multi-tenant system is only as isolated as its least-careful query. Write it once, enforce it everywhere, and trust the pipeline — not individual developers remembering to filter correctly.
What I’d Do Differently
- Centralise tenant extraction early. We initially extracted
tenantIdmanually in each controller. Pulling it into a shared decorator (@TenantId()as a param decorator) eliminated the repetition and made it impossible to forget. - Add a Prisma middleware for safety. A Prisma middleware that automatically injects
school_idinto everyfindMany/findFirstcall — via a request-scoped context — is a stronger guarantee than trusting every service author to remember the filter. - Test cross-tenant access explicitly. Write integration tests that log in as a user from Tenant A and attempt to read Tenant B’s data. If those tests exist and pass, you have proof the isolation holds. If they don’t exist, you have hope.
- Log tenantId on every request. Correlating logs across a multi-tenant system is painful without a consistent tenant identifier on every log line. Add it to your logging interceptor from day one.
Multi-tenancy is less about any single clever trick and more about consistent discipline across hundreds of small decisions. The framework enforces structure. The guards enforce identity. The query layer enforces scope. All three have to hold — and when they do, one server really can serve a thousand schools without any of them knowing the others exist.
Every passenger lands at the right gate. Every bag reaches the right carousel. One airport, many airlines — one backend, many tenants.
