Skip to content

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.

4 min read
A multi-tenant architecture diagram showing shared application layer with tenant-isolated data partitions

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.

tstypescript
// ❌ 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.

tstypescript
// 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.

sqlsql
-- 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;
tstypescript
// 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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX