Multi-Tenant Architecture Without the Footguns
One database, many organizations, zero data leaks. The guard chain, tenant context, and per-tenant feature flags I use to keep tenants isolated and the codebase sane.
Multi-tenancy sounds like a database question — one schema per tenant? one database? shared tables? — but the hard part is never storage. It's making sure that a request from Organization A can never read Organization B's data, even when a developer forgets to add a where clause. You cannot rely on discipline for that. You need the isolation to be structural.
I build shared-database, row-level multi-tenancy: every core row carries an organizationId, and tenant scoping is enforced by a chain of guards that runs before any controller sees the request. Forgetting to scope a query becomes hard by construction.
The guard chain
Order matters. Each guard has exactly one job, and a request has to clear all three:
- Auth — validate the bearer token, or let through explicitly public routes.
- Tenant context — resolve which organization (and location) the request is acting as, and verify the user actually belongs to it.
- Feature flags — enforce that the feature the route exposes is enabled for this tenant.
@Injectable()
export class TenantContextGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
if (this.reflector.get(IS_PUBLIC, ctx.getHandler())) return true;
const orgId = req.headers["x-organization-id"];
const membership = req.user.memberships.find((m) => m.orgId === orgId);
if (!membership) {
throw new ForbiddenException("No access to this organization");
}
req.tenantContext = { organizationId: orgId, locationId: req.headers["x-location-id"] };
return true;
}
}Now every downstream service reads request.tenantContext instead of trusting a client-supplied id. The tenant is resolved once, verified once, and threaded through automatically.
Making scoping the default
The guard proves access, but queries still have to filter by the tenant. I expose the resolved context through a tiny decorator so it's a parameter, not a lookup:
export const ActiveTenant = createParamDecorator(
(_data, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest().tenantContext,
);@Get()
findAll(@ActiveTenant() tenant: TenantContext) {
return this.patients.findForOrg(tenant.organizationId);
}On the storage side, the payoff is composite indexes. Almost every query is "these rows, for this org," so (organizationId, createdAt) and (organizationId, status) indexes keep tenant-scoped reads fast even as the shared tables grow across all tenants.
One product, many shapes
The feature that surprised people most wasn't isolation — it was that a dental clinic and a general practice run the same codebase but see different products. That's per-tenant feature flags: a catalog of capabilities, seeded when an organization is created, toggled per org, and cached in memory as a Map<orgId, Set<featureKey>> so the check is a hash lookup, not a database round-trip.
const required = this.reflector.get(REQUIRE_FEATURE, ctx.getHandler());
if (required && !this.flags.isEnabled(tenant.organizationId, required)) {
throw new ForbiddenException(`Feature "${required}" is not enabled`);
}A route decorated with @RequireFeature("dental-charting") simply doesn't exist for a tenant that hasn't enabled it. The cache is invalidated on any flag mutation, so toggling a feature takes effect without a redeploy.
What I'd tell my past self
- Isolate structurally, not by convention. If correctness depends on every developer remembering a
whereclause, it will eventually leak. - Resolve the tenant once, at the edge. Everything downstream reads a verified context object. No service re-derives it from headers.
- Index for the access pattern you actually have. In shared-table multi-tenancy that pattern is "scoped to one org," so lead every composite index with
organizationId. - Treat feature flags as product surface, not config. They're how one platform becomes many products without forking the code.
Shared-database multi-tenancy gets a bad reputation because the naive version leaks. Done with a guard chain and a resolved tenant context, it's the leanest way I know to run many organizations on one codebase — and keep them from ever seeing each other.