SaaS Multi-Tenant Architecture: Complete Guide
A comprehensive guide to designing and implementing multi-tenant SaaS architectures with data isolation, performance, and scalability in mind.
SaaS Multi-Tenant Architecture: Complete Guide
Multi-tenancy is the foundation of SaaS applications, allowing a single application instance to serve multiple customers (tenants) efficiently. Here's everything you need to design and build a robust multi-tenant architecture.
Multi-Tenancy Models
1. Shared Database, Shared Schema
Structure:
┌─────────────────────────┐
│ Application │
└───────────┬─────────────┘
│
┌───────▼────────┐
│ Database │
│ ┌──────────┐ │
│ │ users │ │ ← tenant_id column
│ ├──────────┤ │
│ │ posts │ │ ← tenant_id column
│ └──────────┘ │
└────────────────┘
Implementation:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id INTEGER NOT NULL REFERENCES users(id),
title VARCHAR(255),
content TEXT,
CHECK (tenant_id = (SELECT tenant_id FROM users WHERE id = user_id))
);
Pros:
- Lowest infrastructure cost
- Simplest to operate
- Easy resource sharing
- Simple backup/restore
Cons:
- Highest data leak risk
- Noisy neighbor problems
- Harder to customize per tenant
- Complex queries (always filter by tenant)
Best for:
- Early-stage SaaS
- Homogeneous tenants
- Cost-sensitive products
- Small to medium data per tenant
2. Shared Database, Separate Schema
Structure:
┌─────────────────────────┐
│ Application │
└───────────┬─────────────┘
│
┌───────▼────────┐
│ Database │
│ ┌──────────┐ │
│ │ tenant_a │ │ ← separate schema
│ ├──────────┤ │
│ │ tenant_b │ │ ← separate schema
│ └──────────┘ │
└────────────────┘
Implementation:
-- For each tenant
CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255)
);
CREATE TABLE tenant_abc.posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES tenant_abc.users(id),
title VARCHAR(255),
content TEXT
);
Pros:
- Better data isolation
- Easier per-tenant customization
- Cleaner queries (no tenant_id filtering)
- Can set different retention policies
Cons:
- More complex operations
- Schema migration complexity
- Connection pooling challenges
- Limits on number of schemas
Best for:
- Medium-scale SaaS
- Tenants with custom fields
- Compliance requirements
- 100-1000 tenants
3. Separate Database
Structure:
┌─────────────────────────┐
│ Application │
└───────────┬─────────────┘
│
┌───────┴────────┐
│ │
┌───▼────┐ ┌───▼────┐
│ DB_A │ │ DB_B │
│ Tenant │ │ Tenant │
│ A │ │ B │
└────────┘ └────────┘
Pros:
- Maximum data isolation
- Independent scaling
- Easy to move tenants
- Best security posture
Cons:
- Highest operational cost
- Complex management at scale
- Expensive for small tenants
- Hard to share resources
Best for:
- Enterprise SaaS
- Regulated industries
- Large tenants
- High-value customers
Implementation Details
Tenant Identification
Subdomain-based:
// acme.myapp.com → tenant: acme
function getTenantFromSubdomain(hostname: string): string {
const parts = hostname.split('.');
if (parts.length < 3) {
throw new Error('Invalid hostname');
}
return parts[0];
}
app.use((req, res, next) => {
const tenant = getTenantFromSubdomain(req.hostname);
req.tenant = tenant;
next();
});
Header-based:
// X-Tenant-ID: acme
app.use((req, res, next) => {
const tenant = req.headers['x-tenant-id'];
if (!tenant) {
return res.status(400).json({ error: 'Missing tenant' });
}
req.tenant = tenant;
next();
});
Path-based:
// /api/tenants/acme/users
app.use('/api/tenants/:tenantId/*', (req, res, next) => {
req.tenant = req.params.tenantId;
next();
});
Database Access Layer
Shared Schema Implementation:
import { Pool } from 'pg';
export class TenantDatabase {
constructor(private pool: Pool) {}
async query<T>(
tenantId: string,
sql: string,
params: any[] = []
): Promise<T[]> {
// Automatically inject tenant_id into WHERE clause
const client = await this.pool.connect();
try {
// Set tenant context for Row Level Security
await client.query('SET app.current_tenant = $1', [tenantId]);
const result = await client.query(sql, params);
return result.rows;
} finally {
client.release();
}
}
}
// Usage
const db = new TenantDatabase(pool);
const users = await db.query(
tenantId,
'SELECT * FROM users WHERE email = $1',
['user@example.com']
);
Row Level Security (PostgreSQL):
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Now queries automatically filter by tenant
SET app.current_tenant = 'tenant-uuid';
SELECT * FROM users; -- Only returns rows for this tenant
Separate Schema Implementation:
export class MultiSchemaDatabase {
constructor(private pool: Pool) {}
async query<T>(
tenantId: string,
sql: string,
params: any[] = []
): Promise<T[]> {
const schema = this.getSchemaName(tenantId);
const client = await this.pool.connect();
try {
// Set search path to tenant schema
await client.query(`SET search_path TO ${schema}`);
const result = await client.query(sql, params);
return result.rows;
} finally {
client.release();
}
}
private getSchemaName(tenantId: string): string {
// Sanitize tenant ID for use as schema name
return `tenant_${tenantId.replace(/[^a-z0-9_]/gi, '')}`;
}
}
Connection Pooling
import { Pool } from 'pg';
// Single pool for all tenants (shared database)
const sharedPool = new Pool({
max: 20,
connectionTimeoutMillis: 2000,
});
// Pool per tenant (separate databases)
class TenantPoolManager {
private pools = new Map<string, Pool>();
getPool(tenantId: string): Pool {
if (!this.pools.has(tenantId)) {
const config = this.getTenantConfig(tenantId);
this.pools.set(tenantId, new Pool(config));
}
return this.pools.get(tenantId)!;
}
private getTenantConfig(tenantId: string) {
// Fetch from tenant registry
return {
host: `${tenantId}.db.myapp.com`,
database: `tenant_${tenantId}`,
max: 10,
};
}
}
Tenant Management
Tenant Registry
interface Tenant {
id: string;
name: string;
subdomain: string;
plan: 'free' | 'basic' | 'premium' | 'enterprise';
status: 'active' | 'suspended' | 'deleted';
databaseConfig: {
type: 'shared' | 'dedicated';
host?: string;
database?: string;
schema?: string;
};
features: string[];
limits: {
maxUsers: number;
maxStorage: number;
maxApiCalls: number;
};
createdAt: Date;
updatedAt: Date;
}
export class TenantRegistry {
constructor(private redis: Redis, private db: Pool) {}
async getTenant(identifier: string): Promise<Tenant | null> {
// Try cache first
const cached = await this.redis.get(`tenant:${identifier}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const result = await this.db.query(
'SELECT * FROM tenants WHERE id = $1 OR subdomain = $1',
[identifier]
);
if (result.rows.length === 0) {
return null;
}
const tenant = result.rows[0];
// Cache for 5 minutes
await this.redis.setex(
`tenant:${identifier}`,
300,
JSON.stringify(tenant)
);
return tenant;
}
async createTenant(data: Partial<Tenant>): Promise<Tenant> {
// Generate unique ID
const id = uuidv4();
// Create tenant record
const result = await this.db.query(`
INSERT INTO tenants (id, name, subdomain, plan, status, database_config, features, limits)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`, [
id,
data.name,
data.subdomain,
data.plan || 'free',
'active',
data.databaseConfig,
data.features || [],
data.limits
]);
const tenant = result.rows[0];
// Provision tenant resources
await this.provisionTenant(tenant);
return tenant;
}
private async provisionTenant(tenant: Tenant): Promise<void> {
if (tenant.databaseConfig.type === 'shared') {
// Create schema for tenant
await this.db.query(`CREATE SCHEMA IF NOT EXISTS tenant_${tenant.id}`);
// Run migrations
await this.runMigrations(tenant.id);
} else {
// Create dedicated database
await this.createDedicatedDatabase(tenant);
}
}
}
Tenant Middleware
import { Request, Response, NextFunction } from 'express';
export function tenantMiddleware(registry: TenantRegistry) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Identify tenant
const tenantId = getTenantIdentifier(req);
if (!tenantId) {
return res.status(400).json({ error: 'Tenant not specified' });
}
// Load tenant
const tenant = await registry.getTenant(tenantId);
if (!tenant) {
return res.status(404).json({ error: 'Tenant not found' });
}
// Check tenant status
if (tenant.status !== 'active') {
return res.status(403).json({
error: 'Tenant suspended',
status: tenant.status
});
}
// Attach to request
req.tenant = tenant;
next();
} catch (error) {
next(error);
}
};
}
function getTenantIdentifier(req: Request): string | null {
// Try subdomain
const subdomain = req.hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
return subdomain;
}
// Try header
const header = req.headers['x-tenant-id'];
if (header) {
return header as string;
}
// Try path
const match = req.path.match(/^\/tenants\/([^/]+)/);
if (match) {
return match[1];
}
return null;
}
Security Considerations
1. Data Isolation Testing
describe('Tenant Data Isolation', () => {
it('should not allow cross-tenant data access', async () => {
// Create data for tenant A
await db.query('tenant-a', 'INSERT INTO users (email) VALUES ($1)', ['a@example.com']);
// Try to access from tenant B
const users = await db.query('tenant-b', 'SELECT * FROM users WHERE email = $1', ['a@example.com']);
expect(users).toHaveLength(0);
});
it('should enforce RLS policies', async () => {
const directQuery = await pool.query('SELECT * FROM users');
const tenantQuery = await db.query('tenant-a', 'SELECT * FROM users');
// Direct query should be blocked by RLS
expect(directQuery.rows).toHaveLength(0);
});
});
2. Input Validation
function validateTenantIdentifier(identifier: string): boolean {
// Only allow alphanumeric and hyphens
return /^[a-z0-9-]{3,63}$/.test(identifier);
}
function sanitizeForSql(value: string): string {
// Remove any SQL injection attempts
return value.replace(/[';\"\\]/g, '');
}
3. Rate Limiting Per Tenant
export class TenantRateLimiter {
constructor(private redis: Redis) {}
async checkLimit(tenantId: string, limit: number): Promise<boolean> {
const key = `ratelimit:tenant:${tenantId}:${getCurrentMinute()}`;
const count = await this.redis.incr(key);
if (count === 1) {
await this.redis.expire(key, 60);
}
return count <= limit;
}
}
app.use(async (req, res, next) => {
const limit = req.tenant.limits.maxApiCalls;
const allowed = await rateLimiter.checkLimit(req.tenant.id, limit);
if (!allowed) {
return res.status(429).json({
error: 'Rate limit exceeded',
limit: limit
});
}
next();
});
Performance Optimization
1. Tenant Caching
export class TenantCache {
constructor(private redis: Redis) {}
async get<T>(tenantId: string, key: string): Promise<T | null> {
const fullKey = `tenant:${tenantId}:${key}`;
const cached = await this.redis.get(fullKey);
return cached ? JSON.parse(cached) : null;
}
async set(tenantId: string, key: string, value: any, ttl: number = 300): Promise<void> {
const fullKey = `tenant:${tenantId}:${key}`;
await this.redis.setex(fullKey, ttl, JSON.stringify(value));
}
async invalidate(tenantId: string, pattern: string = '*'): Promise<void> {
const keys = await this.redis.keys(`tenant:${tenantId}:${pattern}`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
2. Query Optimization
-- Always index tenant_id
CREATE INDEX CONCURRENTLY idx_users_tenant_email
ON users(tenant_id, email);
-- Partial indexes for common queries
CREATE INDEX CONCURRENTLY idx_active_users
ON users(tenant_id, email)
WHERE status = 'active';
-- Partition large tables by tenant
CREATE TABLE users (
id BIGSERIAL,
tenant_id UUID NOT NULL,
email VARCHAR(255),
...
) PARTITION BY HASH (tenant_id);
3. Connection Pool Sizing
// Formula: pool size = (core_count * 2) + effective_spindle_count
const poolConfig = {
// For shared database
shared: {
min: 5,
max: 20,
idleTimeoutMillis: 30000,
},
// For dedicated databases (per tenant)
dedicated: {
min: 2,
max: 10,
idleTimeoutMillis: 60000,
}
};
Monitoring and Observability
export class TenantMetrics {
constructor(private metrics: MetricsClient) {}
recordRequest(tenantId: string, endpoint: string, duration: number): void {
this.metrics.histogram('tenant.request.duration', duration, {
tenant: tenantId,
endpoint: endpoint
});
}
recordDatabaseQuery(tenantId: string, duration: number): void {
this.metrics.histogram('tenant.db.query.duration', duration, {
tenant: tenantId
});
}
recordError(tenantId: string, error: Error): void {
this.metrics.increment('tenant.errors', {
tenant: tenantId,
type: error.constructor.name
});
}
}
// Usage
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
metrics.recordRequest(req.tenant.id, req.path, duration);
});
next();
});
Deployment Architecture
┌──────────────────────────────────────────────────┐
│ Load Balancer │
│ (Route by Subdomain) │
└──────────┬───────────────────────────────────────┘
│
┌──────┴──────┐
│ │
┌───▼────┐ ┌──▼─────┐
│ App │ │ App │
│Server 1│ │Server 2│
└───┬────┘ └───┬────┘
│ │
└──────┬──────┘
│
┌──────▼──────┐
│ Redis │
│ Cache │
└──────┬──────┘
│
┌──────▼───────────────┐
│ PostgreSQL │
│ (Shared/Dedicated) │
└──────────────────────┘
Migration Strategies
Shared → Separate Schema
async function migrateToSeparateSchema(tenantId: string): Promise<void> {
const schema = `tenant_${tenantId}`;
// 1. Create new schema
await db.query(`CREATE SCHEMA ${schema}`);
// 2. Run schema migrations
await runMigrations(schema);
// 3. Copy data
await db.query(`
INSERT INTO ${schema}.users
SELECT id, email, name FROM public.users
WHERE tenant_id = $1
`, [tenantId]);
// 4. Verify data
const oldCount = await db.query('SELECT COUNT(*) FROM public.users WHERE tenant_id = $1', [tenantId]);
const newCount = await db.query(`SELECT COUNT(*) FROM ${schema}.users`);
if (oldCount.rows[0].count !== newCount.rows[0].count) {
throw new Error('Migration verification failed');
}
// 5. Update tenant config
await updateTenantConfig(tenantId, { schema });
// 6. Delete old data
await db.query('DELETE FROM public.users WHERE tenant_id = $1', [tenantId]);
}
Best Practices
1. Always Test Data Isolation
- Write automated tests
- Simulate cross-tenant access attempts
- Regular security audits
2. Plan for Tenant Migration
- Build tooling early
- Support multiple models simultaneously
- Make migrations zero-downtime
3. Monitor Per-Tenant Metrics
- Resource usage
- Error rates
- Performance
- Costs
4. Implement Soft Deletes
ALTER TABLE tenants ADD COLUMN deleted_at TIMESTAMP;
CREATE INDEX idx_tenants_active ON tenants(id) WHERE deleted_at IS NULL;
5. Design for Tenant Portability
- Standard export format
- Easy backup/restore
- Clear data boundaries
Conclusion
Multi-tenancy is a spectrum, not a binary choice. Start simple (shared database, shared schema), and evolve based on your specific needs around security, compliance, performance, and scale.
Remember: The best architecture is the one you can operate reliably.
What multi-tenancy model are you using? What challenges have you faced?