I’ve been thinking a lot about building software that scales gracefully while keeping customer data completely isolated. The challenge of creating a single application that securely serves multiple organizations is something I’ve faced repeatedly in my career. Today, I want to share a practical approach to building multi-tenant SaaS applications that balances security, performance, and maintainability.
Why does this matter? Because when you’re building for multiple customers, data isolation isn’t just a feature—it’s a fundamental requirement. I’ve seen teams struggle with complex application-level filtering when database-level solutions exist that are both simpler and more secure.
Let me show you how PostgreSQL’s Row-Level Security can transform your multi-tenant architecture.
The foundation starts with our database schema. We need to design tables that naturally support tenant isolation while maintaining flexibility for future features.
model Tenant {
id String @id @default(cuid())
name String @unique
slug String @unique
createdAt DateTime @default(now())
users User[]
projects Project[]
}
model User {
id String @id @default(cuid())
email String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([email, tenantId])
}
Notice how every user belongs to a specific tenant? This relationship becomes the backbone of our security model. But how do we ensure users can only access their own tenant’s data?
PostgreSQL’s Row-Level Security policies handle this elegantly. Instead of relying on application code to filter results, we push security to the database level.
-- Enable RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create policy for tenant isolation
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
The magic happens with current_setting('app.current_tenant')
. This lets us set a session-specific value that PostgreSQL uses to filter every query. But how do we ensure this value is always set correctly?
In our NestJS application, we use middleware to automatically set the tenant context for each request.
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.extractTenantId(req);
// Set tenant context for database queries
req['tenantId'] = tenantId;
next();
}
private extractTenantId(req: Request): string {
// Extract from subdomain, header, or JWT token
return req.headers['x-tenant-id'] as string;
}
}
This middleware runs before any route handler, ensuring our tenant context is always available. But what about database connections? We need to configure Prisma to use our tenant context.
@Injectable()
export class PrismaService extends PrismaClient {
constructor(private config: ConfigService) {
super();
}
async enableSharding(tenantId: string) {
await this.$executeRaw`SET app.current_tenant = ${tenantId}`;
}
}
Now, every database operation automatically respects tenant boundaries. When a user from Tenant A queries their projects, they only see Tenant A’s projects—even if we forget to add a WHERE clause.
But what happens during tenant onboarding? We need a clean way to create new tenant contexts without manual database intervention.
@Controller('tenants')
export class TenantsController {
constructor(private tenantsService: TenantsService) {}
@Post()
async createTenant(@Body() createTenantDto: CreateTenantDto) {
return this.tenantsService.create(createTenantDto);
}
}
@Injectable()
export class TenantsService {
async create(createTenantDto: CreateTenantDto) {
return this.prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
name: createTenantDto.name,
slug: createTenantDto.slug,
},
});
// Create default admin user
await tx.user.create({
data: {
email: createTenantDto.adminEmail,
tenantId: tenant.id,
role: 'ADMIN',
},
});
return tenant;
});
}
}
This transactional approach ensures we never end up with incomplete tenant setups. But have you considered what happens when tenants need custom fields or configurations?
The beauty of this architecture is its flexibility. We can extend our base models with tenant-specific settings using JSON fields.
model Tenant {
id String @id @default(cuid())
name String @unique
settings Json @default("{}")
}
model Project {
id String @id @default(cuid())
name String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
customFields Json @default("{}")
}
JSON fields let tenants customize their experience without requiring schema changes. But how do we maintain performance with this approach?
Indexing strategic columns and using partial indexes can help significantly.
-- Index tenant ID for faster lookups
CREATE INDEX CONCURRENTLY idx_users_tenant_id
ON users(tenant_id) WHERE tenant_id IS NOT NULL;
-- Partial index for active tenants
CREATE INDEX CONCURRENTLY idx_tenants_active
ON tenants(id) WHERE status = 'ACTIVE';
Testing becomes crucial in multi-tenant environments. We need to verify that data never leaks between tenants.
describe('Multi-tenant Security', () => {
it('should not leak data between tenants', async () => {
const [tenantA, tenantB] = await createTestTenants();
// Create projects in both tenants
await createProject(tenantA.id, { name: 'Tenant A Project' });
await createProject(tenantB.id, { name: 'Tenant B Project' });
// Query as Tenant A
const tenantAProjects = await getProjects(tenantA.id);
expect(tenantAProjects).toHaveLength(1);
expect(tenantAProjects[0].name).toBe('Tenant A Project');
});
});
This test validates that our RLS policies work correctly. But what about edge cases, like when a user belongs to multiple tenants?
We handle this by ensuring each request context is explicitly set to a single tenant. Users authenticate per-tenant, maintaining clear boundaries.
Performance monitoring becomes essential at scale. We need to track query performance across different tenant sizes.
@Injectable()
export class QueryLogger implements Prisma.Middleware {
async use(params: Prisma.MiddlewareParams, next: (params: Prisma.MiddlewareParams) => Promise<any>) {
const start = Date.now();
const result = await next(params);
const duration = Date.now() - start;
// Log slow queries per tenant
if (duration > 1000) {
this.logger.warn(`Slow query for tenant ${params.args?.where?.tenantId}`, {
model: params.model,
action: params.action,
duration,
});
}
return result;
}
}
This middleware helps identify performance issues specific to certain tenants or query patterns.
Deployment considerations change with multi-tenancy. We need strategies for zero-downtime migrations and tenant-specific backups.
-- Tenant-aware backup strategy
pg_dump --table='projects' --where="tenant_id='tenant-123'"
But what about database connection pooling? We need to ensure connection reuse while maintaining tenant isolation.
The solution involves setting the tenant context at the connection level, either through connection pooling with context or application-level connection management.
This approach has served me well in production environments, handling thousands of tenants with varying data sizes. The key insight is trusting the database with security while keeping business logic in the application.
What challenges have you faced with multi-tenant architectures? I’d love to hear about your experiences and solutions.
If this approach resonates with you, consider sharing it with others who might benefit. Your thoughts and questions in the comments help everyone learn—what aspects of multi-tenancy would you like me to cover next?