Multi-Tenant SaaS: Isolation, Scaling and Data Strategy
A deep dive into multi-tenant patterns for SaaS: tenant isolation, database partitioning, request routing, quotas, and shared-everything vs per-tenant.

The Multi-Tenancy Spectrum
Multi-tenancy is not a binary choice between "shared everything" and "separate everything." It is a spectrum, and the correct position on that spectrum depends on your compliance requirements, customer expectations, cost structure, and team capacity.
At one end, all tenants share one database, one application instance, one cache layer. At the other, each tenant gets a dedicated database, dedicated compute, and dedicated networking. Most production systems land somewhere in between.
Tenant Isolation Models
type IsolationModel = "shared" | "schema-per-tenant" | "database-per-tenant";
interface TenantConfig {
id: string;
name: string;
isolationModel: IsolationModel;
tier: "free" | "pro" | "enterprise";
region: string;
resourceQuota: ResourceQuota;
}
interface ResourceQuota {
maxStorageGB: number;
maxRequestsPerMinute: number;
maxConcurrentConnections: number;
maxComputeUnits: number;
}
// ❌ One-size-fits-all isolation — forces enterprise customers
// into shared infrastructure they don't want
function getConnection(): DatabaseConnection {
return sharedPool.getConnection();
}
// ✅ Tier-based isolation — matching isolation to customer needs
function getTenantConnection(tenant: TenantConfig): DatabaseConnection {
switch (tenant.isolationModel) {
case "database-per-tenant":
return getDedicatedConnection(tenant.id);
case "schema-per-tenant":
return getSchemaConnection(tenant.id);
case "shared":
return getSharedConnection(tenant.id);
}
}Shared tables with a tenant_id column are cheapest to operate but hardest to secure. A missing WHERE clause in a single query leaks data across tenants. Schema-per-tenant provides stronger isolation with moderate overhead. Database-per-tenant provides the strongest isolation and simplest compliance but the highest operational cost.
Request Routing and Tenant Resolution
Every request must be resolved to a tenant before any business logic executes. The resolution must be fast, cached, and impossible to bypass.
interface TenantResolver {
resolve(req: IncomingRequest): Promise<TenantConfig | null>;
}
class SubdomainTenantResolver implements TenantResolver {
constructor(
private readonly cache: Cache,
private readonly tenantRepo: TenantRepository
) {}
async resolve(req: IncomingRequest): Promise<TenantConfig | null> {
const host = req.headers.host || "";
const subdomain = host.split(".")[0];
if (!subdomain || subdomain === "www" || subdomain === "app") {
return null;
}
// Check cache first
const cached = await this.cache.get<TenantConfig>(
`tenant:${subdomain}`
);
if (cached) return cached;
const tenant = await this.tenantRepo.findBySubdomain(subdomain);
if (!tenant) return null;
await this.cache.set(`tenant:${subdomain}`, tenant, { ttl: 300 });
return tenant;
}
}
// Middleware that enforces tenant resolution
function tenantMiddleware(
resolver: TenantResolver
): RequestMiddleware {
return async (req, res, next) => {
const tenant = await resolver.resolve(req);
if (!tenant) {
res.status(404).json({ error: "Tenant not found" });
return;
}
if (tenant.tier === "free" && tenant.resourceQuota) {
const allowed = await checkRateLimit(
tenant.id,
tenant.resourceQuota.maxRequestsPerMinute
);
if (!allowed) {
res.status(429).json({ error: "Rate limit exceeded" });
return;
}
}
// Attach tenant to request context
req.tenant = tenant;
next();
};
}Database Strategies for Multi-Tenancy
The database layer is where multi-tenancy gets complicated. Row-level security, connection pooling, migrations, and backups all behave differently depending on your isolation model.
// Shared database with row-level security
class SharedTenantRepository<T extends { tenantId: string }> {
constructor(
private readonly db: Database,
private readonly tableName: string
) {}
async findAll(tenantId: string): Promise<T[]> {
// Always filter by tenant — never trust application code alone
return this.db.query<T>(
`SELECT * FROM ${this.tableName} WHERE tenant_id = $1`,
[tenantId]
);
}
async create(tenantId: string, data: Omit<T, "tenantId">): Promise<T> {
const columns = Object.keys(data);
const values = Object.values(data);
return this.db.queryOne<T>(
`INSERT INTO ${this.tableName}
(tenant_id, ${columns.join(", ")})
VALUES ($1, ${columns.map((_, i) => `$${i + 2}`).join(", ")})
RETURNING *`,
[tenantId, ...values]
);
}
async delete(tenantId: string, id: string): Promise<boolean> {
const result = await this.db.execute(
`DELETE FROM ${this.tableName} WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return result.rowCount > 0;
}
}
// Schema-per-tenant strategy
class SchemaTenantManager {
constructor(private readonly db: Database) {}
async createTenantSchema(tenantId: string): Promise<void> {
const schemaName = this.sanitizeSchemaName(tenantId);
await this.db.execute(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Run migrations within the tenant schema
await this.db.execute(`SET search_path TO "${schemaName}"`);
await this.runMigrations();
await this.db.execute(`SET search_path TO public`);
}
async getTenantConnection(tenantId: string): Promise<Database> {
const schemaName = this.sanitizeSchemaName(tenantId);
const conn = await this.db.getConnection();
await conn.execute(`SET search_path TO "${schemaName}"`);
return conn;
}
private sanitizeSchemaName(tenantId: string): string {
return `tenant_${tenantId.replace(/[^a-zA-Z0-9_]/g, "")}`;
}
private async runMigrations(): Promise<void> {
// Apply schema migrations
}
}Tenant-Aware Caching and Background Jobs
Cache keys must include the tenant ID. Background jobs must carry tenant context. Without these safeguards, cache collisions leak data and jobs run in the wrong tenant context.
class TenantCache {
constructor(private readonly cache: Cache) {}
async get<T>(tenantId: string, key: string): Promise<T | null> {
return this.cache.get<T>(this.tenantKey(tenantId, key));
}
async set<T>(
tenantId: string,
key: string,
value: T,
ttl?: number
): Promise<void> {
await this.cache.set(this.tenantKey(tenantId, key), value, { ttl });
}
async invalidateAll(tenantId: string): Promise<void> {
const pattern = `tenant:${tenantId}:*`;
await this.cache.deletePattern(pattern);
}
private tenantKey(tenantId: string, key: string): string {
return `tenant:${tenantId}:${key}`;
}
}
// Background job with tenant context
interface TenantJob<T = unknown> {
tenantId: string;
jobType: string;
payload: T;
priority: number;
scheduledAt: Date;
}
class TenantJobProcessor {
constructor(
private readonly queue: JobQueue,
private readonly tenantManager: SchemaTenantManager
) {}
async enqueue<T>(
tenantId: string,
jobType: string,
payload: T
): Promise<string> {
const job: TenantJob<T> = {
tenantId,
jobType,
payload,
priority: this.getTenantPriority(tenantId),
scheduledAt: new Date(),
};
return this.queue.add(job);
}
async process(job: TenantJob): Promise<void> {
// Establish tenant context before processing
const db = await this.tenantManager.getTenantConnection(
job.tenantId
);
try {
const handler = this.getHandler(job.jobType);
await handler.execute(job.payload, db);
} finally {
await db.release();
}
}
private getTenantPriority(tenantId: string): number {
// Enterprise tenants get higher job priority
return 1;
}
private getHandler(jobType: string): JobHandler {
// Resolve handler by type
return {} as JobHandler;
}
}Key Takeaways
Multi-tenant architecture is a spectrum of trade-offs between isolation, cost, and operational complexity. Start with shared infrastructure and row-level tenant filtering for early-stage products. Move to schema-per-tenant when compliance requires stronger isolation. Reserve database-per-tenant for enterprise customers who explicitly need it and will pay for it.
Every layer must be tenant-aware: request routing, database queries, cache keys, background jobs, and logging. A single layer that forgets the tenant context creates a data leak. Build tenant resolution as middleware that runs before any business logic, and make it impossible to execute queries without a tenant context.
Design the migration path between isolation models from the beginning. The worst outcome is being locked into shared tables when your biggest customer demands dedicated infrastructure. Abstract the database layer behind a tenant-aware repository so the isolation model can change without rewriting application code.


