← Writing
3 min read

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.

BackendArchitectureMulti-TenancyNestJS

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:

  1. Auth — validate the bearer token, or let through explicitly public routes.
  2. Tenant context — resolve which organization (and location) the request is acting as, and verify the user actually belongs to it.
  3. Feature flags — enforce that the feature the route exposes is enabled for this tenant.
tenant-context.guard.ts
@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:

active-tenant.decorator.ts
export const ActiveTenant = createParamDecorator(
  (_data, ctx: ExecutionContext) =>
    ctx.switchToHttp().getRequest().tenantContext,
);
patients.controller.ts
@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.

feature-flag.guard.ts
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 where clause, 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.