Recently, I encountered a critical challenge while developing a SaaS platform that needed to securely serve multiple customers from a single codebase. How can we maintain strict data separation while optimizing resource usage? This question led me down the path of architecting a multi-tenant solution using NestJS, Prisma, and PostgreSQL’s Row-Level Security. Let’s walk through this approach together.
Multi-tenancy requires careful consideration of data isolation strategies. We evaluated three primary models: database-per-tenant (complete isolation but heavy overhead), shared database (simple but risky), and schema-per-tenant – our chosen approach. Why did we pick schemas? They balance security with operational efficiency. Each tenant gets their own PostgreSQL schema while sharing the same database instance.
// Schema switching implementation
async function setTenantContext(tenantId: string) {
await prisma.$executeRaw`SET search_path TO ${Prisma.raw(`tenant_${tenantId}`)}`;
}
Our project setup begins with a standard NestJS application enhanced with Prisma. The directory structure organizes tenant-specific logic, with key modules for authentication, tenant management, and user operations. Ever wonder how we handle varying tenant configurations? Environment variables control database pooling, JWT settings, and connection limits.
# Environment configuration example
DATABASE_URL="postgresql://user:pass@host:5432/maindb?schema=public"
MAX_CONNECTION_POOL=50
JWT_SECRET="your_secure_key"
For database modeling, Prisma’s schema definition includes both global and tenant-specific elements. The public schema holds our tenant registry, while individual tenant schemas contain their isolated data. How do we prevent accidental data leaks? Row-Level Security acts as our safety net.
-- RLS policy example
CREATE POLICY tenant_isolation_policy ON tenant_data.orders
FOR ALL TO application_user
USING (tenant_id = current_setting('app.current_tenant')::UUID);
Implementing RLS requires careful PostgreSQL configuration. We create database roles that restrict access based on session variables. Each API request sets the current tenant context before querying. What happens if a query misses the tenant context? RLS blocks all access by default – a crucial failsafe.
In our NestJS services, tenant awareness permeates every data operation. We use interceptors to automatically set the Prisma context:
// Tenant interceptor
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const tenantId = request.user.tenantId;
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET app.current_tenant = ${tenantId}`;
return next.handle();
});
}
}
Guards complement this by validating tenant status before processing requests. The onboarding flow handles new tenant provisioning through a dedicated endpoint that:
- Creates new PostgreSQL schema
- Runs baseline migrations
- Registers tenant in central catalog
- Configures RLS policies
For cross-tenant analytics, we use materialized views in the public schema. Aggregated data refreshes on a schedule, ensuring live tenant databases remain untouched. How do we maintain performance at scale? Connection pooling and Redis caching prove essential.
// Analytics example with Redis
async getCrossTenantMetrics() {
const cacheKey = 'global_metrics';
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const results = await prisma.public.analyticsView.findMany();
await redis.set(cacheKey, JSON.stringify(results), 'EX', 3600);
return results;
}
Testing requires special attention. We implement end-to-end tests that:
- Spin up temporary tenant schemas
- Validate RLS enforcement
- Verify isolation between tenants
- Check onboarding error handling
Deployment uses Kubernetes for orchestration, with probes checking tenant database health. Monitoring includes:
- Per-tenant query performance
- Schema growth alerts
- Connection pool saturation
- RLS policy violation attempts
Common pitfalls we encountered include:
- Forgetting to reset tenant context after operations
- Pool exhaustion from unclosed connections
- Caching without tenant segmentation
- Missing RLS on new tables
Performance optimization focuses on:
- Connection reuse with pgBouncer
- Tenant-specific index tuning
- Query batching
- Read replicas for analytics
The schema-per-tenant approach with RLS has served our SaaS platform well, handling over 500 tenants on modest infrastructure. What surprised me most? PostgreSQL’s RLS added negligible overhead while providing enterprise-grade security.
I’ve shared our key learnings, but every application has unique requirements. What challenges are you facing with multi-tenancy? Share your experiences below – I’d love to hear different approaches. If this breakdown helped you, consider sharing it with others who might benefit. Your thoughts and questions in the comments help everyone learn!