Skip to content

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.

4 min read
A multi-tenant architecture diagram showing shared infrastructure with logical tenant boundaries and resource isolation layers

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

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

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

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

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

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX