Designing Multi-Tenant SaaS Architectures That Scale
Architect multi-tenant SaaS with database isolation, tenant-aware middleware, quotas, partitioning and security boundaries that scale to ten thousand.

The Isolation Spectrum
Multi-tenancy ranges from fully shared (one database, one schema) to fully isolated (separate databases per tenant). The right choice depends on your compliance requirements, expected tenant count, and operational budget. Most teams start shared and regret it; understanding the tradeoffs early saves painful migrations.
Tenant Context Middleware
Every request must carry tenant identity. Extract it once in middleware and propagate it through the entire request lifecycle.
// ❌ Passing tenantId manually through every function
// async function getOrders(tenantId: string, userId: string) { ... }
// async function getProducts(tenantId: string, category: string) { ... }
// ✅ Tenant context propagated via AsyncLocalStorage
import { AsyncLocalStorage } from "node:async_hooks";
interface TenantContext {
tenantId: string;
plan: "free" | "pro" | "enterprise";
databaseSchema: string;
}
const tenantStorage = new AsyncLocalStorage<TenantContext>();
function getTenantContext(): TenantContext {
const ctx = tenantStorage.getStore();
if (!ctx) throw new Error("No tenant context — middleware not applied");
return ctx;
}
// Express middleware
function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const tenantId = req.headers["x-tenant-id"] as string;
if (!tenantId) {
res.status(400).json({ error: "Missing tenant identifier" });
return;
}
const tenant = tenantRegistry.get(tenantId);
if (!tenant) {
res.status(404).json({ error: "Tenant not found" });
return;
}
tenantStorage.run(
{
tenantId: tenant.id,
plan: tenant.plan,
databaseSchema: `tenant_${tenant.id}`,
},
() => next()
);
}Database Isolation Strategies
Three common patterns exist: shared schema with tenant column, schema-per-tenant, and database-per-tenant.
// Strategy 1: Shared schema — tenant_id column on every table
// Pros: Simple, low overhead
// Cons: One bad query leaks data across tenants
class SharedSchemaRepository {
async getOrders(userId: string): Promise<Order[]> {
const { tenantId } = getTenantContext();
// Every query MUST include tenant_id — missing it leaks data
return db.query(
`SELECT * FROM orders WHERE tenant_id = $1 AND user_id = $2`,
[tenantId, userId]
);
}
}
// Strategy 2: Schema-per-tenant — PostgreSQL schemas
// Pros: Strong isolation, easy per-tenant backup/restore
// Cons: Schema migrations must run N times
class SchemaPerTenantRepository {
private getSchema(): string {
return getTenantContext().databaseSchema;
}
async getOrders(userId: string): Promise<Order[]> {
const schema = this.getSchema();
// search_path isolates queries to tenant schema
await db.query(`SET search_path TO ${schema}`);
return db.query(
`SELECT * FROM orders WHERE user_id = $1`,
[userId]
);
}
}
// Strategy 3: Database-per-tenant — separate connections
// Pros: Complete isolation, per-tenant scaling
// Cons: Connection pool overhead, operational complexity
class DatabasePerTenantRepository {
constructor(private pools: Map<string, DatabasePool>) {}
private getPool(): DatabasePool {
const { tenantId } = getTenantContext();
const pool = this.pools.get(tenantId);
if (!pool) throw new Error(`No database pool for tenant ${tenantId}`);
return pool;
}
async getOrders(userId: string): Promise<Order[]> {
const pool = this.getPool();
return pool.query(
`SELECT * FROM orders WHERE user_id = $1`,
[userId]
);
}
}Row-Level Security for Shared Schemas
When using shared schemas, PostgreSQL Row-Level Security prevents data leaks at the database level—even if application code forgets the tenant filter.
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Force RLS even for table owners
ALTER TABLE orders FORCE ROW LEVEL SECURITY;// Set tenant context at the database session level
async function withTenantSession<T>(
pool: Pool,
tenantId: string,
operation: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Set tenant for RLS policies
await client.query(
`SET app.current_tenant = $1`,
[tenantId]
);
return await operation(client);
} finally {
// Reset to prevent tenant leakage on connection reuse
await client.query(`RESET app.current_tenant`);
client.release();
}
}
// Usage — no tenant_id in WHERE clause needed
const orders = await withTenantSession(pool, tenantId, async (client) => {
// RLS automatically filters to current tenant
const result = await client.query(`SELECT * FROM orders WHERE user_id = $1`, [
userId,
]);
return result.rows;
});Resource Quotas and Rate Limiting
One tenant consuming all resources degrades service for everyone. Implement per-tenant quotas at the application level.
interface TenantQuota {
maxRequestsPerMinute: number;
maxStorageBytes: number;
maxUsersPerTenant: number;
}
const PLAN_QUOTAS: Record<string, TenantQuota> = {
free: {
maxRequestsPerMinute: 60,
maxStorageBytes: 100 * 1024 * 1024,
maxUsersPerTenant: 5,
},
pro: {
maxRequestsPerMinute: 600,
maxStorageBytes: 10 * 1024 * 1024 * 1024,
maxUsersPerTenant: 50,
},
enterprise: {
maxRequestsPerMinute: 6000,
maxStorageBytes: 100 * 1024 * 1024 * 1024,
maxUsersPerTenant: 500,
},
};
class TenantRateLimiter {
private counters = new Map<string, { count: number; resetAt: number }>();
check(tenantId: string, plan: string): { allowed: boolean; retryAfter?: number } {
const quota = PLAN_QUOTAS[plan];
if (!quota) return { allowed: false };
const now = Date.now();
const counter = this.counters.get(tenantId);
if (!counter || counter.resetAt < now) {
this.counters.set(tenantId, {
count: 1,
resetAt: now + 60_000,
});
return { allowed: true };
}
if (counter.count >= quota.maxRequestsPerMinute) {
return {
allowed: false,
retryAfter: Math.ceil((counter.resetAt - now) / 1000),
};
}
counter.count++;
return { allowed: true };
}
}Tenant-Aware Migrations
Schema-per-tenant requires running migrations across all tenant schemas. Automate this with a migration runner that tracks per-tenant migration state.
async function migrateAllTenants(
migration: Migration,
tenants: Tenant[]
): Promise<MigrationReport> {
const results: MigrationReport = { succeeded: [], failed: [] };
// Run migrations sequentially to avoid overloading the database
for (const tenant of tenants) {
try {
await db.query(`SET search_path TO ${tenant.schema}`);
await db.query(migration.sql);
await db.query(
`INSERT INTO migration_history (version, applied_at)
VALUES ($1, NOW())`,
[migration.version]
);
results.succeeded.push(tenant.id);
} catch (error) {
results.failed.push({
tenantId: tenant.id,
error: error instanceof Error ? error.message : "Unknown error",
});
// Continue with other tenants — don't let one failure block all
}
}
return results;
}Key Takeaways
Multi-tenant architecture decisions compound over time. The isolation strategy you choose—shared schema, schema-per-tenant, or database-per-tenant—affects security, operations, and scaling for the life of the product. Start with schema-per-tenant when you need strong isolation without operational overhead of separate databases.
Use AsyncLocalStorage to propagate tenant context implicitly instead of threading tenant IDs through every function signature. Enable Row-Level Security as a safety net for shared schemas—it catches the queries where application code forgets the tenant filter. Implement per-tenant rate limiting and resource quotas from the start, because one noisy tenant will eventually consume all shared resources. Every tenant should experience the application as if they are the only customer, regardless of how many tenants share the infrastructure.


