Multi-Tenant SaaS Architecture: A Complete Guide

Design scalable multi-tenant SaaS applications. Compare database strategies, implement tenant isolation, and handle billing.

Alex Chen
Alex Chen
December 12, 2024 18 min read
Multi-Tenant SaaS Architecture: A Complete Guide

Building a multi-tenant SaaS application requires careful architectural decisions. The wrong choice early on can lead to scaling nightmares, security vulnerabilities, and high operational costs. This guide covers everything you need to design a robust multi-tenant system.

Multi-Tenancy Models

There are three primary approaches to multi-tenancy:

1. Shared Database, Shared Schema

All tenants share the same tables with a tenant_id column.

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL REFERENCES tenants(id),
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

Pros:

  • Simple to implement
  • Easy to maintain
  • Cost-effective

Cons:

  • Risk of data leakage
  • Noisy neighbor problems
  • Complex queries

2. Shared Database, Separate Schemas

Each tenant gets their own PostgreSQL schema.

-- Create schema per tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;

-- Tables within each schema
CREATE TABLE tenant_acme.users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255)
);

Pros:

  • Better isolation
  • Easier data export
  • Schema-level permissions

Cons:

  • More complex migrations
  • Connection pool challenges
  • Moderate maintenance overhead

3. Separate Databases

Each tenant gets their own database.

Pros:

  • Complete isolation
  • Independent scaling
  • Easy compliance

Cons:

  • High operational cost
  • Complex connection management
  • Difficult cross-tenant operations

For most SaaS applications, I recommend a hybrid approach:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Application Layer                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                    Tenant Middleware                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚   Small Tenants (Shared)  β”‚   Large Tenants (Isolated)  β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚   β”‚ Shared DB + Schema  β”‚ β”‚   β”‚ Dedicated Database  β”‚   β”‚
β”‚   β”‚ tenant_id column    β”‚ β”‚   β”‚ or Separate Schema  β”‚   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Implementing Tenant Context

Node.js with AsyncLocalStorage

// lib/tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';

interface TenantContext {
  tenantId: string;
  tenantSlug: string;
  plan: 'free' | 'pro' | 'enterprise';
}

export const tenantStorage = new AsyncLocalStorage<TenantContext>();

export function getTenant(): TenantContext {
  const tenant = tenantStorage.getStore();
  if (!tenant) {
    throw new Error('No tenant context found');
  }
  return tenant;
}

export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T {
  return tenantStorage.run(tenant, fn);
}

Express Middleware

// middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { runWithTenant } from '../lib/tenant-context';
import { db } from '../lib/database';

export async function tenantMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Extract tenant from subdomain or header
  const tenantSlug = extractTenantSlug(req);

  if (!tenantSlug) {
    return res.status(400).json({ error: 'Tenant not specified' });
  }

  // Load tenant from database
  const tenant = await db.tenants.findBySlug(tenantSlug);

  if (!tenant) {
    return res.status(404).json({ error: 'Tenant not found' });
  }

  // Run the rest of the request with tenant context
  runWithTenant(
    {
      tenantId: tenant.id,
      tenantSlug: tenant.slug,
      plan: tenant.plan,
    },
    () => next()
  );
}

function extractTenantSlug(req: Request): string | null {
  // From subdomain: acme.yourapp.com
  const host = req.hostname;
  const subdomain = host.split('.')[0];
  if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
    return subdomain;
  }

  // From header: X-Tenant-ID
  const headerTenant = req.headers['x-tenant-id'];
  if (typeof headerTenant === 'string') {
    return headerTenant;
  }

  return null;
}

Row-Level Security with PostgreSQL

PostgreSQL’s Row-Level Security (RLS) provides database-enforced tenant isolation:

-- Enable RLS on tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Create policy for tenant isolation
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::INTEGER);

-- Force RLS for all users (including table owner)
ALTER TABLE users FORCE ROW LEVEL SECURITY;

Setting Tenant Context in Queries

// lib/database.ts
import { Pool, PoolClient } from 'pg';
import { getTenant } from './tenant-context';

const pool = new Pool();

export async function withTenantContext<T>(
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const tenant = getTenant();
  const client = await pool.connect();

  try {
    // Set tenant context for RLS
    await client.query(
      'SET app.current_tenant_id = $1',
      [tenant.tenantId]
    );

    return await fn(client);
  } finally {
    // Reset and release
    await client.query('RESET app.current_tenant_id');
    client.release();
  }
}

// Usage
const users = await withTenantContext(async (client) => {
  const result = await client.query('SELECT * FROM users');
  return result.rows; // Automatically filtered by tenant!
});

Feature Flags per Tenant

// lib/features.ts
interface FeatureFlags {
  advancedAnalytics: boolean;
  apiAccess: boolean;
  customBranding: boolean;
  ssoEnabled: boolean;
  maxUsers: number;
  maxStorage: number; // in GB
}

const planFeatures: Record<string, FeatureFlags> = {
  free: {
    advancedAnalytics: false,
    apiAccess: false,
    customBranding: false,
    ssoEnabled: false,
    maxUsers: 5,
    maxStorage: 1,
  },
  pro: {
    advancedAnalytics: true,
    apiAccess: true,
    customBranding: false,
    ssoEnabled: false,
    maxUsers: 50,
    maxStorage: 50,
  },
  enterprise: {
    advancedAnalytics: true,
    apiAccess: true,
    customBranding: true,
    ssoEnabled: true,
    maxUsers: Infinity,
    maxStorage: 1000,
  },
};

export function getFeatures(): FeatureFlags {
  const tenant = getTenant();
  return planFeatures[tenant.plan];
}

export function hasFeature(feature: keyof FeatureFlags): boolean {
  const features = getFeatures();
  const value = features[feature];
  return typeof value === 'boolean' ? value : value > 0;
}

Billing and Usage Tracking

// lib/usage.ts
interface UsageMetric {
  tenantId: string;
  metric: string;
  value: number;
  timestamp: Date;
}

export class UsageTracker {
  private buffer: UsageMetric[] = [];
  private flushInterval: NodeJS.Timer;

  constructor() {
    // Flush every 30 seconds
    this.flushInterval = setInterval(() => this.flush(), 30000);
  }

  track(metric: string, value: number = 1) {
    const tenant = getTenant();
    this.buffer.push({
      tenantId: tenant.tenantId,
      metric,
      value,
      timestamp: new Date(),
    });
  }

  private async flush() {
    if (this.buffer.length === 0) return;

    const metrics = [...this.buffer];
    this.buffer = [];

    await db.usageMetrics.insertMany(metrics);
  }
}

// Usage
const usage = new UsageTracker();

// Track API calls
app.use((req, res, next) => {
  usage.track('api_calls');
  next();
});

// Track storage
async function uploadFile(file: Buffer) {
  usage.track('storage_bytes', file.length);
  // ...
}

Database Migrations Strategy

// migrations/runner.ts
import { getTenants, getTenantConnection } from '../lib/database';

async function runMigrations() {
  const tenants = await getTenants();

  for (const tenant of tenants) {
    console.log(`Migrating tenant: ${tenant.slug}`);

    const connection = await getTenantConnection(tenant);

    try {
      await connection.runPendingMigrations();
      console.log(`βœ“ Tenant ${tenant.slug} migrated`);
    } catch (error) {
      console.error(`βœ— Tenant ${tenant.slug} failed:`, error);
      // Continue with other tenants
    } finally {
      await connection.close();
    }
  }
}

// For schema-per-tenant approach
async function runSchemaAwareMigration(migrationFn: Function) {
  const schemas = await db.query(`
    SELECT schema_name FROM information_schema.schemata
    WHERE schema_name LIKE 'tenant_%'
  `);

  for (const { schema_name } of schemas.rows) {
    await db.query(`SET search_path TO ${schema_name}`);
    await migrationFn();
  }

  await db.query('SET search_path TO public');
}

Performance Optimization

Connection Pooling per Tenant

// lib/connection-pool.ts
import { Pool } from 'pg';

const tenantPools = new Map<string, Pool>();

export function getTenantPool(tenantId: string): Pool {
  let pool = tenantPools.get(tenantId);

  if (!pool) {
    pool = new Pool({
      connectionString: getTenantConnectionString(tenantId),
      max: 10, // Per-tenant connection limit
      idleTimeoutMillis: 30000,
    });

    tenantPools.set(tenantId, pool);
  }

  return pool;
}

// Cleanup idle pools periodically
setInterval(() => {
  for (const [tenantId, pool] of tenantPools) {
    if (pool.idleCount === pool.totalCount) {
      pool.end();
      tenantPools.delete(tenantId);
    }
  }
}, 60000);

Caching with Tenant Isolation

// lib/cache.ts
import Redis from 'ioredis';
import { getTenant } from './tenant-context';

const redis = new Redis();

export const cache = {
  async get<T>(key: string): Promise<T | null> {
    const tenant = getTenant();
    const fullKey = `tenant:${tenant.tenantId}:${key}`;
    const value = await redis.get(fullKey);
    return value ? JSON.parse(value) : null;
  },

  async set(key: string, value: unknown, ttlSeconds = 3600): Promise<void> {
    const tenant = getTenant();
    const fullKey = `tenant:${tenant.tenantId}:${key}`;
    await redis.setex(fullKey, ttlSeconds, JSON.stringify(value));
  },

  async invalidate(pattern: string): Promise<void> {
    const tenant = getTenant();
    const keys = await redis.keys(`tenant:${tenant.tenantId}:${pattern}`);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  },
};

Security Checklist

  • Row-Level Security enabled on all tenant tables
  • Tenant ID validated on every request
  • Cross-tenant queries explicitly prevented
  • API keys scoped to tenant
  • Audit logging includes tenant context
  • Data encryption at rest per tenant
  • Separate encryption keys per tenant (enterprise)
  • Regular security audits

Conclusion

Building a multi-tenant SaaS requires balancing:

  • Isolation: Security and compliance needs
  • Efficiency: Resource utilization and costs
  • Complexity: Development and operational overhead

Start with the simplest approach (shared schema) and evolve as needed. Use PostgreSQL RLS for database-level isolation, and implement proper tenant context throughout your application.


Building a SaaS? Share your multi-tenancy challenges in the comments!

Advertisement

In-Article Ad

Dev Mode

Share this article

Alex Chen

Alex Chen

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles