SaaS Architecture Best Practices: Building Scalable Applications
The SaaS Architecture Challenge
Building a SaaS application isn't just about features—it's about architecture decisions that impact every user, every day. Get it wrong early, and you'll face painful rewrites. Get it right, and you can scale from 10 to 10,000 customers without breaking a sweat.
I've architected SaaS platforms for fintech, healthcare, e-commerce, and AI companies across Dubai, London, and the US. The patterns in this guide have been battle-tested in production environments handling millions of requests daily.
Multi-Tenancy Architecture Patterns
Pattern 1: Shared Database, Shared Schema
Best for: Startups, early-stage products, when simplicity matters
-- Every table has a tenant_id column
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
UNIQUE(tenant_id, email)
);
-- Always filter by tenant_id
CREATE INDEX idx_users_tenant ON users(tenant_id);
-- Row Level Security (RLS) in PostgreSQL
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::UUID);
Pros:
- Simplest to implement and maintain
- Easiest to query across tenants (for analytics)
- Lowest infrastructure cost
- Simplest backup/restore
Cons:
- Risk of data leakage (mitigated by RLS)
- Harder to customize per tenant
- Performance can degrade with scale
Pattern 2: Shared Database, Schema-per-Tenant
Best for: B2B SaaS with enterprise customers needing customization
-- Each tenant gets their own schema
CREATE SCHEMA tenant_acme_corp;
CREATE TABLE tenant_acme_corp.users (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255)
);
-- Search path switching based on tenant
SET search_path TO tenant_acme_corp, public;
Pros:
- Strong data isolation
- Per-tenant customization possible
- Easier to migrate individual tenants
- Can offer "dedicated database" feel
Cons:
- More complex connection management
- Schema migrations across many schemas
- Higher memory usage (each schema has overhead)
Pattern 3: Database-per-Tenant
Best for: Enterprise SaaS with compliance requirements (HIPAA, SOC2)
Implementation:
- Each tenant gets completely separate database
- Connection pooling per database
- Full isolation at infrastructure level
Pros:
- Maximum data isolation
- Independent scaling per tenant
- Easy per-tenant backup/restore
- Compliance-friendly
Cons:
- Most expensive (database connections)
- Complex monitoring across databases
- Harder to implement cross-tenant features
- Operational complexity
Database Design for SaaS
Tenant Table Structure
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
plan VARCHAR(50) NOT NULL DEFAULT 'free',
status VARCHAR(50) NOT NULL DEFAULT 'active',
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP -- Soft delete
);
-- Indexes for common queries
CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_plan ON tenants(plan);
User Management
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
role VARCHAR(50) NOT NULL DEFAULT 'member',
status VARCHAR(50) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Unique email per tenant
UNIQUE(tenant_id, email)
);
-- Always query with tenant_id
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_users_tenant_role ON users(tenant_id, role);
Soft Deletes for Data Safety
-- Add deleted_at to all tenant tables
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP;
-- View for active records only
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;
-- Query pattern
SELECT * FROM users
WHERE tenant_id = ?
AND deleted_at IS NULL
AND status = 'active';
Authentication & Authorization
JWT with Tenant Context
// lib/auth.ts
import jwt from 'jsonwebtoken';
export function createToken(user: User, tenant: Tenant) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
tenantId: tenant.id,
tenantSlug: tenant.slug,
plan: tenant.plan,
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
}
// Middleware to verify and extract tenant
export async function authMiddleware(request: Request) {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) throw new Error('No token provided');
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
// Set tenant context for database queries
await db.query('SET app.current_tenant = $1', [decoded.tenantId]);
return decoded;
}
Role-Based Access Control (RBAC)
// Define permissions
const PERMISSIONS = {
user: ['read:own', 'update:own'],
admin: ['read:all', 'update:all', 'create:all', 'delete:own'],
owner: ['read:all', 'update:all', 'create:all', 'delete:all', 'manage:billing'],
} as const;
// Permission checking
export function can(user: User, action: string, resource: string) {
const role = user.role as keyof typeof PERMISSIONS;
const permissions = PERMISSIONS[role] || [];
return permissions.includes(action) ||
permissions.includes(`${action}:all`) ||
(resource.ownerId === user.id && permissions.includes(`${action}:own`));
}
API Design for Multi-Tenancy
URL Structure Options
Option 1: Subdomain-based
acme-corp.yoursaas.com/api/users
other-tenant.yoursaas.com/api/users
Option 2: Path-based
yoursaas.com/api/acme-corp/users
yoursaas.com/api/other-tenant/users
Option 3: Header-based (API key)
GET /api/users
X-Tenant-ID: acme-corp
Authorization: Bearer <token>
Express.js Implementation
// middleware/tenant.ts
export function tenantMiddleware(req, res, next) {
// Extract tenant from subdomain
const subdomain = req.headers.host?.split('.')[0];
// Or from header
const tenantId = req.headers['x-tenant-id'] || subdomain;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant required' });
}
// Fetch tenant and validate
const tenant = await getTenantBySlug(tenantId);
if (!tenant || tenant.status !== 'active') {
return res.status(404).json({ error: 'Tenant not found' });
}
// Attach to request
req.tenant = tenant;
// Set database context
await db.setTenantContext(tenant.id);
next();
}
Scaling Strategies
Read Replicas
// lib/db.ts
const pool = new Pool({
host: process.env.DB_HOST,
// Read from replicas
read: [
{ host: process.env.DB_REPLICA_1 },
{ host: process.env.DB_REPLICA_2 },
],
// Write to primary
write: { host: process.env.DB_HOST },
});
// Route queries automatically
export async function query(sql: string, params: any[], options?: { read?: boolean }) {
if (options?.read || isSelectQuery(sql)) {
return pool.read.query(sql, params);
}
return pool.write.query(sql, params);
}
Caching Layer
// Cache tenant-specific data
async function getTenantConfig(tenantId: string) {
const cacheKey = `tenant:config:${tenantId}`;
// Try cache first
let config = await redis.get(cacheKey);
if (config) return JSON.parse(config);
// Fetch from database
config = await db.query('SELECT settings FROM tenants WHERE id = $1', [tenantId]);
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(config));
return config;
}
// Invalidate on update
async function updateTenantConfig(tenantId: string, settings: any) {
await db.query('UPDATE tenants SET settings = $1 WHERE id = $2', [settings, tenantId]);
await redis.del(`tenant:config:${tenantId}`);
}
Feature Flags per Tenant
// lib/features.ts
const FEATURES = {
basic: ['dashboard', 'reports'],
pro: ['dashboard', 'reports', 'api', 'integrations'],
enterprise: ['dashboard', 'reports', 'api', 'integrations', 'ss', 'sla'],
};
export function hasFeature(tenant: Tenant, feature: string) {
const plan = tenant.plan as keyof typeof FEATURES;
const allowedFeatures = FEATURES[plan] || [];
// Check plan features
if (allowedFeatures.includes(feature)) return true;
// Check custom overrides in tenant settings
const customFeatures = tenant.settings?.features || [];
return customFeatures.includes(feature);
}
// Usage in code
if (hasFeature(req.tenant, 'api')) {
// Show API documentation
}
Billing & Usage Tracking
Event-Driven Architecture
// Track usage events
interface UsageEvent {
tenantId: string;
userId: string;
event: string;
quantity: number;
metadata?: Record<string, any>;
timestamp: Date;
}
// Publish events
async function trackUsage(event: UsageEvent) {
// Send to queue for processing
await messageQueue.publish('usage-events', event);
// Real-time counter update
await redis.incrby(`usage:${event.tenantId}:${event.event}:${getCurrentMonth()}`, event.quantity);
}
// Consume and aggregate
async function processUsageEvents() {
await messageQueue.subscribe('usage-events', async (event) => {
// Store in time-series database
await timeseriesDB.insert({
tenant_id: event.tenantId,
event: event.event,
quantity: event.quantity,
timestamp: event.timestamp,
});
// Check limits
const usage = await getCurrentUsage(event.tenantId, event.event);
const limit = await getPlanLimit(event.tenantId, event.event);
if (usage > limit * 0.9) {
await sendWarningEmail(event.tenantId, event.event, usage, limit);
}
if (usage >= limit) {
await enforceLimit(event.tenantId, event.event);
}
});
}
Deployment Architecture
Docker Compose for Development
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/saas
- REDIS_URL=redis://redis:6379
- JWT_SECRET=dev-secret
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: saas
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Production Kubernetes
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: saas-app
spec:
replicas: 3
selector:
matchLabels:
app: saas
template:
metadata:
labels:
app: saas
spec:
containers:
- name: app
image: your-registry/saas:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Security Best Practices
Data Encryption
// Encrypt sensitive tenant data
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
export function encrypt(text: string, key: string) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(ALGORITHM, key);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
};
}
// Store encrypted in database
const encryptedApiKey = encrypt(apiKey, process.env.ENCRYPTION_KEY);
await db.query(
'UPDATE tenants SET api_key = $1, api_key_iv = $2 WHERE id = $3',
[encryptedApiKey.encrypted, encryptedApiKey.iv, tenantId]
);
API Rate Limiting per Tenant
// middleware/rate-limit.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const limiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:', // tenant-specific prefix added dynamically
}),
max: async (req) => {
// Different limits per plan
const tenant = req.tenant;
const limits = {
free: 100,
pro: 1000,
enterprise: 10000,
};
return limits[tenant.plan] || 100;
},
windowMs: 15 * 60 * 1000, // 15 minutes
keyGenerator: (req) => `${req.tenant.id}:${req.ip}`,
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded',
upgradeUrl: '/billing/upgrade',
});
},
});
Testing Multi-Tenant Applications
Integration Tests
// tests/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
const tenantA = { id: 'tenant-a', token: generateToken('tenant-a') };
const tenantB = { id: 'tenant-b', token: generateToken('tenant-b') };
beforeEach(async () => {
// Seed data for both tenants
await seedTenantData(tenantA.id);
await seedTenantData(tenantB.id);
});
it('should not allow cross-tenant data access', async () => {
// Create resource as tenant A
const resource = await createResource(tenantA.token, { name: 'Test' });
// Try to access as tenant B
const response = await request(app)
.get(`/api/resources/${resource.id}`)
.set('Authorization', `Bearer ${tenantB.token}`);
expect(response.status).toBe(404);
});
it('should scope queries to current tenant', async () => {
// Get resources as tenant A
const responseA = await request(app)
.get('/api/resources')
.set('Authorization', `Bearer ${tenantA.token}`);
// Should only see tenant A's data
responseA.body.forEach((r: any) => {
expect(r.tenantId).toBe(tenantA.id);
});
});
});
Conclusion
SaaS architecture is about making decisions that don't paint you into a corner. The patterns in this guide provide a foundation that scales from your first customer to your thousandth.
Key takeaways:
- Start simple (shared database), evolve as needed
- Tenant context is king—every query, every request
- Security is non-negotiable—RLS, encryption, validation
- Plan for scale—read replicas, caching, async processing
- Test isolation—prevent data leakage at all costs
Whether you're building the next Salesforce or a niche B2B tool, these architectural patterns will serve you well.
Need help architecting your SaaS? Let's discuss your requirements.
Frequently Asked Questions
What is the best database architecture for SaaS applications?
The best approach depends on your scale: 1) Shared Database with Tenant ID columns (best for startups, easiest to manage), 2) Schema-per-tenant (good for data isolation, medium complexity), 3) Database-per-tenant (maximum isolation, highest complexity/cost). Most successful SaaS start with shared database and migrate as they grow.
How do you handle authentication in multi-tenant SaaS?
Use JWT tokens with tenant context. Store tenant identifier in the JWT payload. Validate tenant access on every request. Implement row-level security (RLS) in PostgreSQL or equivalent in other databases. Consider auth providers like Auth0, Clerk, or Supabase Auth that support multi-tenancy out of the box.
What are the biggest scaling challenges for SaaS?
The top challenges are: 1) Database performance under concurrent load (solution: connection pooling, read replicas, caching), 2) Noisy neighbor problems (solution: resource quotas, rate limiting), 3) Data migration between tenants (solution: async background jobs), 4) Feature flags per tenant (solution: configuration management), 5) Billing and usage tracking (solution: event-driven architecture).