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