Back to Blog
Multi-tenancySaaSArchitectureDatabase

Multi-Tenancy Patterns for SaaS Applications

Build applications that serve multiple customers. From database strategies to data isolation to tenant-aware queries.

B
Bootspring Team
Engineering
September 20, 2023
6 min read

SaaS applications serve multiple customers (tenants) from the same codebase. The challenge is keeping data isolated while sharing infrastructure efficiently.

Multi-Tenancy Strategies#

Shared Database, Shared Schema: - All tenants in same tables - Tenant ID column for filtering - Simplest, least isolated - Best for: Most SaaS apps Shared Database, Separate Schemas: - Each tenant has own schema - Better isolation - More complex migrations - Best for: Regulated industries Separate Databases: - Each tenant has own database - Complete isolation - Highest overhead - Best for: Enterprise customers

Shared Schema Implementation#

1// schema.prisma 2model Tenant { 3 id String @id @default(cuid()) 4 name String 5 subdomain String @unique 6 plan String @default("free") 7 users User[] 8 projects Project[] 9 createdAt DateTime @default(now()) 10} 11 12model User { 13 id String @id @default(cuid()) 14 email String 15 tenant Tenant @relation(fields: [tenantId], references: [id]) 16 tenantId String 17 role String @default("member") 18 19 @@unique([email, tenantId]) 20 @@index([tenantId]) 21} 22 23model Project { 24 id String @id @default(cuid()) 25 name String 26 tenant Tenant @relation(fields: [tenantId], references: [id]) 27 tenantId String 28 29 @@index([tenantId]) 30}

Tenant Context#

1import { AsyncLocalStorage } from 'async_hooks'; 2 3interface TenantContext { 4 tenantId: string; 5 tenant: Tenant; 6} 7 8const tenantStorage = new AsyncLocalStorage<TenantContext>(); 9 10// Get current tenant 11export function getCurrentTenant(): TenantContext { 12 const context = tenantStorage.getStore(); 13 if (!context) { 14 throw new Error('No tenant context'); 15 } 16 return context; 17} 18 19// Run with tenant context 20export function withTenant<T>( 21 context: TenantContext, 22 fn: () => T 23): T { 24 return tenantStorage.run(context, fn); 25} 26 27// Middleware to set tenant context 28async function tenantMiddleware(req: Request, res: Response, next: NextFunction) { 29 // Get tenant from subdomain 30 const subdomain = getSubdomain(req.hostname); 31 32 const tenant = await prisma.tenant.findUnique({ 33 where: { subdomain }, 34 }); 35 36 if (!tenant) { 37 return res.status(404).json({ error: 'Tenant not found' }); 38 } 39 40 withTenant({ tenantId: tenant.id, tenant }, () => { 41 next(); 42 }); 43} 44 45function getSubdomain(hostname: string): string { 46 const parts = hostname.split('.'); 47 if (parts.length >= 3) { 48 return parts[0]; 49 } 50 return 'default'; 51}

Tenant-Scoped Queries#

1// Prisma extension for automatic tenant filtering 2import { Prisma } from '@prisma/client'; 3 4const prismaWithTenant = prisma.$extends({ 5 query: { 6 $allModels: { 7 async findMany({ args, query }) { 8 const tenant = getCurrentTenant(); 9 args.where = { ...args.where, tenantId: tenant.tenantId }; 10 return query(args); 11 }, 12 async findFirst({ args, query }) { 13 const tenant = getCurrentTenant(); 14 args.where = { ...args.where, tenantId: tenant.tenantId }; 15 return query(args); 16 }, 17 async findUnique({ args, query }) { 18 const result = await query(args); 19 if (result && (result as any).tenantId !== getCurrentTenant().tenantId) { 20 return null; 21 } 22 return result; 23 }, 24 async create({ args, query }) { 25 const tenant = getCurrentTenant(); 26 args.data = { ...args.data, tenantId: tenant.tenantId }; 27 return query(args); 28 }, 29 async update({ args, query }) { 30 const tenant = getCurrentTenant(); 31 args.where = { ...args.where, tenantId: tenant.tenantId }; 32 return query(args); 33 }, 34 async delete({ args, query }) { 35 const tenant = getCurrentTenant(); 36 args.where = { ...args.where, tenantId: tenant.tenantId }; 37 return query(args); 38 }, 39 }, 40 }, 41}); 42 43// Usage - tenant filtering is automatic 44const projects = await prismaWithTenant.project.findMany(); 45// Equivalent to: WHERE tenantId = 'current-tenant-id'

Row-Level Security (PostgreSQL)#

1-- Enable RLS 2ALTER TABLE projects ENABLE ROW LEVEL SECURITY; 3 4-- Create policy 5CREATE POLICY tenant_isolation ON projects 6 USING (tenant_id = current_setting('app.current_tenant_id')); 7 8-- Set tenant before queries 9SET app.current_tenant_id = 'tenant-123'; 10 11-- Now all queries are automatically filtered 12SELECT * FROM projects; -- Only returns tenant-123's projects
1// Set RLS context in Prisma 2async function executeWithTenant<T>( 3 tenantId: string, 4 fn: () => Promise<T> 5): Promise<T> { 6 await prisma.$executeRaw`SET app.current_tenant_id = ${tenantId}`; 7 try { 8 return await fn(); 9 } finally { 10 await prisma.$executeRaw`RESET app.current_tenant_id`; 11 } 12}

Tenant-Aware Caching#

1class TenantCache { 2 constructor(private redis: Redis) {} 3 4 private getKey(key: string): string { 5 const { tenantId } = getCurrentTenant(); 6 return `tenant:${tenantId}:${key}`; 7 } 8 9 async get<T>(key: string): Promise<T | null> { 10 const data = await this.redis.get(this.getKey(key)); 11 return data ? JSON.parse(data) : null; 12 } 13 14 async set(key: string, value: any, ttl?: number): Promise<void> { 15 const fullKey = this.getKey(key); 16 if (ttl) { 17 await this.redis.setex(fullKey, ttl, JSON.stringify(value)); 18 } else { 19 await this.redis.set(fullKey, JSON.stringify(value)); 20 } 21 } 22 23 async invalidate(pattern: string): Promise<void> { 24 const { tenantId } = getCurrentTenant(); 25 const keys = await this.redis.keys(`tenant:${tenantId}:${pattern}`); 26 if (keys.length) { 27 await this.redis.del(...keys); 28 } 29 } 30}

Tenant Provisioning#

1async function provisionTenant(data: CreateTenantInput): Promise<Tenant> { 2 // Create tenant 3 const tenant = await prisma.tenant.create({ 4 data: { 5 name: data.name, 6 subdomain: data.subdomain, 7 plan: 'trial', 8 }, 9 }); 10 11 // Create admin user 12 await prisma.user.create({ 13 data: { 14 email: data.adminEmail, 15 tenantId: tenant.id, 16 role: 'admin', 17 }, 18 }); 19 20 // Initialize tenant resources 21 await initializeTenantResources(tenant.id); 22 23 // Send welcome email 24 await sendWelcomeEmail(data.adminEmail, tenant); 25 26 return tenant; 27} 28 29async function initializeTenantResources(tenantId: string): Promise<void> { 30 // Create default settings 31 await prisma.tenantSettings.create({ 32 data: { 33 tenantId, 34 timezone: 'UTC', 35 language: 'en', 36 }, 37 }); 38 39 // Set up storage bucket 40 await createTenantBucket(tenantId); 41 42 // Initialize any other tenant-specific resources 43}

Tenant Limits#

1interface TenantLimits { 2 maxUsers: number; 3 maxProjects: number; 4 maxStorage: number; // MB 5} 6 7const planLimits: Record<string, TenantLimits> = { 8 free: { maxUsers: 5, maxProjects: 3, maxStorage: 100 }, 9 starter: { maxUsers: 20, maxProjects: 10, maxStorage: 1000 }, 10 pro: { maxUsers: 100, maxProjects: 50, maxStorage: 10000 }, 11 enterprise: { maxUsers: Infinity, maxProjects: Infinity, maxStorage: Infinity }, 12}; 13 14async function checkLimit(resource: keyof TenantLimits): Promise<void> { 15 const { tenant, tenantId } = getCurrentTenant(); 16 const limits = planLimits[tenant.plan]; 17 18 let current: number; 19 20 switch (resource) { 21 case 'maxUsers': 22 current = await prisma.user.count({ where: { tenantId } }); 23 break; 24 case 'maxProjects': 25 current = await prisma.project.count({ where: { tenantId } }); 26 break; 27 case 'maxStorage': 28 current = await calculateStorageUsage(tenantId); 29 break; 30 } 31 32 if (current >= limits[resource]) { 33 throw new LimitExceededError(resource, limits[resource]); 34 } 35} 36 37// Usage 38app.post('/projects', authenticate, async (req, res) => { 39 await checkLimit('maxProjects'); 40 const project = await prisma.project.create({ data: req.body }); 41 res.json(project); 42});

Best Practices#

Data Isolation: ✓ Always filter by tenant ID ✓ Use database-level RLS when possible ✓ Audit cross-tenant access ✓ Test isolation thoroughly Performance: ✓ Index tenant_id columns ✓ Partition large tables by tenant ✓ Implement tenant-aware caching ✓ Monitor per-tenant usage Operations: ✓ Automate tenant provisioning ✓ Plan for tenant deletion ✓ Support data export ✓ Implement tenant backups

Conclusion#

Multi-tenancy requires careful attention to data isolation and performance. Start with shared schema for simplicity, use row-level security for protection, and implement tenant context throughout your application.

Always test that one tenant cannot access another tenant's data.

Share this article

Help spread the word about Bootspring