Multi-Tenant Architecture
Isolation Strategies
| Strategy |
Isolation |
Complexity |
Cost |
| Database per tenant |
Highest |
High |
High |
| Schema per tenant |
High |
Medium |
Medium |
| Shared schema (tenant_id column) |
Medium |
Low |
Low |
| Row-level security (RLS) |
Medium-High |
Medium |
Low |
Shared Schema with Tenant ID (most common)
// Middleware: resolve tenant from subdomain or header
function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const host = req.hostname; // acme.myapp.com
const subdomain = host.split('.')[0];
const tenant = await tenantRepo.findBySubdomain(subdomain);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
req.tenantId = tenant.id;
next();
}
// Always filter by tenant
app.get('/api/products', async (req, res) => {
const products = await db.product.findMany({
where: { tenantId: req.tenantId },
});
res.json(products);
});
Prisma with Tenant Scoping
// Extension to auto-apply tenant filter
const prisma = new PrismaClient().$extends({
query: {
$allOperations({ args, query, operation }) {
if (['findMany', 'findFirst', 'count', 'updateMany', 'deleteMany'].includes(operation)) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
}
if (['create', 'createMany'].includes(operation)) {
args.data = { ...args.data, tenantId: getCurrentTenantId() };
}
return query(args);
},
},
});
PostgreSQL Row-Level Security
-- Enable RLS
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Policy: users see only their tenant's data
CREATE POLICY tenant_isolation ON products
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set tenant context per request
SET app.tenant_id = 'tenant-uuid-here';
SELECT * FROM products; -- auto-filtered
// Set tenant context on each request
pool.on('connect', async (client) => {
// Set after getting connection from pool
});
async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query(`SET app.tenant_id = $1`, [tenantId]);
return await fn();
} finally {
await client.query('RESET app.tenant_id');
client.release();
}
}
Schema-Per-Tenant
// Dynamic schema selection
function getTenantSchema(tenantId: string): string {
return `tenant_${tenantId.replace(/-/g, '_')}`;
}
async function createTenantSchema(tenantId: string) {
const schema = getTenantSchema(tenantId);
await db.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
await db.query(`SET search_path TO ${schema}`);
await runMigrations(); // Apply schema migrations
}
Tenant Resolution Strategies
| Strategy |
Example |
Best For |
| Subdomain |
acme.myapp.com |
B2B SaaS |
| Path prefix |
myapp.com/acme/... |
Simpler setup |
| Custom header |
X-Tenant-ID: acme |
API-first |
| JWT claim |
{ tenantId: "acme" } |
Authenticated APIs |
Anti-Patterns
| Anti-Pattern |
Fix |
| No tenant filter on queries |
Use middleware or ORM extension to auto-apply |
| Tenant ID from client without validation |
Derive from auth token or subdomain |
| No tenant data isolation testing |
Write tests that verify cross-tenant isolation |
| Shared cache without tenant prefix |
Prefix all cache keys with tenant ID |
| No tenant-aware rate limiting |
Rate limit per tenant, not globally |
Production Checklist