Designing multi-tenant SaaS forces you to make hard trade-offs early—choices about isolation, performance, and operational overhead that ripple through your entire system. I've built products across three isolation models, made wrong calls, and lived with the consequences. Here's what I learned from shipping real multi-tenant platforms.
Isolation levels — The fundamental trade-off
When you're designing multi-tenant SaaS, the first decision is how much you separate tenant data. This isn't a technical detail—it shapes your entire architecture, operational complexity, and ability to scale.
Shared tables with a tenant_id column is the cheapest model. All tenants live in the same tables; you just filter by tenant_id. A ride-hailing platform I worked on used this for the first eighteen months—it let us ship fast and keep infrastructure costs near zero. The catch: one developer forgetting a WHERE clause in a migration, cron job, or reporting query exposes everyone's data. We caught a bug in our analytics ETL that was summing metrics across all tenants before we deployed. That never happened again.
// Shared table model - vulnerable if filters are forgotten
async function getRideHistory(userId: string, tenantId: string) {
// Developer must remember tenantId filter EVERYWHERE
return db.query(
'SELECT * FROM rides WHERE user_id = $1 AND tenant_id = $2',
[userId, tenantId]
);
}
// One forgotten filter in a report query:
async function getAverageRidePrice() {
// BUG: no tenant_id filter - exposes all tenants' data
return db.query('SELECT AVG(price) FROM rides');
}
Schema per tenant raises the isolation wall. Each tenant gets their own PostgreSQL schema (or equivalent). We used this for a SaaS CRM where data sensitivity mattered. Cross-tenant queries became impossible by design—the schema name is literally different. But migrations are harder: you're running ALTER TABLE against dozens or hundreds of schemas, and rollbacks get messy. We built a custom migration runner that applied changes sequentially and logged failures per tenant. One schema fell behind after a timeout during a deploy at 2 AM. Not fun.
Database per tenant is the fortress. Each customer gets their own PostgreSQL instance (or RDS cluster). Zero chance of cross-tenant queries. Perfect for regulated industries or paranoid enterprise customers. The trade-off: operational burden explodes. Backups, patches, monitoring, connection pooling—you're managing N databases now. A trading platform I helped scale went this route for premium customers. Our ops team needed dashboards and automation just to track which databases existed and whether they were healthy.
| Model | Cost | Isolation | Operational burden | When to use |
|---|---|---|---|---|
| Shared tables + tenant_id | Lowest | Weakest (discipline required) | Low | Early stage, low-sensitivity data |
| Schema per tenant | Low-medium | Strong | Medium (migrations) | Growing SaaS, moderate sensitivity |
| Database per tenant | High | Strongest | High (operations) | Enterprise, compliance-driven |
My recommendation: start with shared tables if you're building an MVP. Move to schema per tenant once you have paying customers and want to sleep at night. Offer database per tenant as a premium feature for enterprises who'll pay for it.
Resolve tenant once — Middleware sets context for the whole request
Once you've chosen an isolation model, the second hard rule is: figure out which tenant you're serving exactly once, at the request boundary. Don't sprinkle tenant resolution throughout your business logic.
In NestJS, I use middleware or a decorator to extract the tenant ID from a header or JWT claim, validate it, and attach it to the request context. Everything downstream trusts that context.
// NestJS middleware - resolve tenant once
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Extract from header, JWT, or subdomain
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new UnauthorizedException('Missing tenant ID');
}
// Validate the tenant exists and user has access
// This is where you'd check auth and permissions
req['tenantId'] = tenantId;
req['tenant'] = { id: tenantId }; // enriched context if needed
next();
}
}
// Controller - use the resolved context
@Controller('orders')
export class OrderController {
@Get()
async getOrders(@Req() req: Request) {
// tenantId is already resolved and validated
const tenantId = req['tenantId'];
return this.orderService.listByTenant(tenantId);
}
}
The benefit: every request handler, service, and query automatically knows which tenant to serve. You can't accidentally miss it because it's not an optional parameter—it's part of the request context from the entry point.
For Next.js apps, I do the same in API route middleware or a shared handler wrapper. For React, the tenant context lives in Redux or React Context, set once on app load from an initial API call or JWT decode.
Filter automatically — Global query filters prevent human error
Even with tenant resolution in middleware, developers are human. They'll write queries, sometimes forget the WHERE clause, and ship bugs. The safest pattern is to make filtering automatic at the data layer.
In NestJS with TypeORM, I use query scope or interceptors. The idea: before any query hits the database, inject the tenant_id filter automatically.
// TypeORM with a custom repository that auto-filters by tenant
export class TenantAwareRepository extends Repository {
async findByTenant(tenantId: string, filters?: any) {
let query = this.createQueryBuilder('entity')
.where('entity.tenant_id = :tenantId', { tenantId });
if (filters?.userId) {
query = query.andWhere('entity.user_id = :userId', { userId: filters.userId });
}
return query.getMany();
}
}
// Service - can't forget tenant filter because it's enforced at the repository
@Injectable()
export class OrderService {
constructor(private orderRepo: TenantAwareRepository) {}
async getOrdersByUser(tenantId: string, userId: string) {
// findByTenant enforces tenant_id automatically
return this.orderRepo.findByTenant(tenantId, { userId });
}
}
For PostgreSQL with raw queries, some teams use row-level security (RLS) policies. You define a policy on each table that filters by tenant_id at the database level—no query can bypass it. This is bulletproof but requires careful setup and can complicate migrations.
// PostgreSQL RLS - enforced at the database
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Before each query, set the tenant context:
-- SET app.current_tenant_id = 'some-uuid';
-- SELECT * FROM orders; -- automatically filtered
The cost: a bit of boilerplate in your data layer. The benefit: a developer can't accidentally expose data even if they try.
Offer isolation as a plan — Enterprise customers pay for stronger boundaries
Here's a monetization insight: isolation is a feature. Enterprise customers will pay premium prices for dedicated databases or schemas. Build it into your pricing model early.
A travel SaaS I worked on offered three tiers: Starter (shared tables), Professional (schema per tenant), Enterprise (database per tenant). Enterprise customers paid 3–5x more, but ops and support costs were justified because we could offer SLAs, custom backups, and faster restores.
To implement this cleanly, store the isolation level in your tenant configuration:
// Tenant configuration - determines isolation level
interface TenantConfig {
id: string;
name: string;
plan: 'starter' | 'professional' | 'enterprise';
isolationLevel: 'shared' | 'schema' | 'database';
databaseHost?: string; // populated only for 'database' tier
schemaName?: string; // populated for 'schema' and 'database' tiers
}
// Connection resolver - routes to the right database
export async function getTenantConnection(tenantId: string) {
const tenant = await loadTenantConfig(tenantId);
if (tenant.isolationLevel === 'database') {
// Enterprise: dedicated RDS instance
return getConnection(tenant.databaseHost);
}
if (tenant.isolationLevel === 'schema') {
// Professional: shared RDS, different schema
const conn = getConnection('shared-rds-host');
await conn.query(`SET search_path TO ${tenant.schemaName}`);
return conn;
}
// Starter: shared everything, just filter by tenant_id
return getConnection('shared-rds-host');
}
The middleware from earlier now checks the tenant config and routes the connection accordingly. Your business logic doesn't need to care—it just works.
Common mistakes
- Forgetting to test cross-tenant queries. Add tests that verify one tenant cannot see another's data. I've caught bugs during load testing where concurrent queries from different tenants mixed results.
- Choosing isolation model based on gut feeling instead of actual requirements. Talk to your first enterprise customers. Some don't care about database isolation; they care about audit logs and data residency. Isolation might not be your bottleneck.
- Assuming you can migrate tenants between isolation levels later. You can, but it's expensive. If a Starter customer wants to upgrade to Enterprise with a dedicated database, you're moving their data. Plan for this early.
- Not automating tenant provisioning. If you're offering schema or database per tenant, automate the creation, setup, and teardown. Manual scripts become production incidents.
- Skipping monitoring per tenant. With schema or database per tenant, you need per-tenant dashboards for performance and costs. One customer's runaway query shouldn't affect others.
Key takeaways
- Start with shared tables if you're early and your data isn't sensitive. Graduate to schema per tenant once you have paying customers.
- Resolve which tenant you're serving exactly once, at the request boundary. Middleware or a decorator, not scattered through business logic.
- Enforce tenant filtering automatically at the data layer—through custom repositories, query scopes, or database policies. Don't rely on developers to remember.
- Treat isolation strength as a feature. Offer it as a paid tier so enterprise customers get the boundaries they want and you offset the operational cost.
- Test cross-tenant isolation rigorously. Write tests that confirm tenant A cannot see tenant B's data, even in edge cases like concurrent requests or async jobs.






Comments
Loading comments…