I’ve been thinking about multi-tenant SaaS applications lately. Why? Because as cloud services grow, the need for scalable, isolated solutions becomes critical. When businesses trust you with their data, architectural decisions matter. Today I’ll show you how I build these systems using NestJS and Prisma with database-per-tenant isolation. Stick around – this approach might solve your next scalability challenge.
Multi-tenancy means serving multiple customers from one application instance. We’ll focus on database-per-tenant architecture where each client gets their own dedicated database. Why choose this? Complete data isolation tops the list. If one tenant’s database experiences issues, others remain unaffected. Customization becomes easier too. Need specific schema changes for a particular client? No problem. Compliance requirements like GDPR? Much simpler to manage.
Let me show you how we compare approaches:
// Database-per-Tenant (Our choice)
type TenantDBConfig = {
tenantId: string;
databaseUrl: string;
};
// Alternative: Shared Database
type SharedDBConfig = {
tenantId: string;
sharedUrl: string;
tenantColumn: string;
};
The trade-off? More moving parts. Connection management needs careful handling. Resource usage increases. But for security-critical applications, it’s worth it. Have you considered what happens when a tenant’s data grows unexpectedly?
Our setup begins with a standard NestJS structure. We’ll organize code by responsibility rather than features:
src/
├─ auth/ // Authentication
├─ tenants/ // Tenant management
├─ shared/ // Common utilities
├─ databases/ // Connection handling
└─ modules/ // Business logic
Here’s our environment configuration:
// src/config/database.ts
export default () => ({
masterDb: {
url: process.env.MASTER_DB_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE) || 10
},
tenantDbTemplate: `postgres://${process.env.TENANT_DB_USER}:${process.env.TENANT_DB_PASS}@${process.env.TENANT_DB_HOST}/<TENANT_ID>`
});
Now, the core challenge: dynamic connections. How do we efficiently manage hundreds of database connections? My solution involves connection pooling with Redis caching:
// src/databases/connection.service.ts
@Injectable()
export class ConnectionService {
private tenantConnections = new Map<string, PrismaClient>();
private redis = new Redis(process.env.REDIS_URL);
async getPrismaClient(tenantId: string): Promise<PrismaClient> {
if (this.tenantConnections.has(tenantId)) {
return this.tenantConnections.get(tenantId)!;
}
const cachedUrl = await this.redis.get(`tenant:${tenantId}:db_url`);
const dbUrl = cachedUrl || await this.fetchDbUrlFromMaster(tenantId);
const prisma = new PrismaClient({
datasources: { db: { url: dbUrl } }
});
await prisma.$connect();
this.tenantConnections.set(tenantId, prisma);
return prisma;
}
private async fetchDbUrlFromMaster(tenantId: string): Promise<string> {
const masterPrisma = new PrismaClient();
const tenant = await masterPrisma.tenant.findUnique({
where: { id: tenantId }
});
await this.redis.setex(`tenant:${tenantId}:db_url`, 3600, tenant!.databaseUrl);
return tenant!.databaseUrl;
}
}
Security comes next. How do we ensure tenant isolation? Through middleware that verifies access:
// src/shared/middleware/tenant.middleware.ts
export class TenantMiddleware implements NestMiddleware {
constructor(private connectionService: ConnectionService) {}
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'];
if (!tenantId) throw new ForbiddenException('Tenant not specified');
const prisma = await this.connectionService.getPrismaClient(tenantId as string);
req.tenantPrisma = prisma;
next();
}
}
Now in controllers, we access the tenant-specific Prisma client:
// src/modules/users/user.controller.ts
@Get('users')
async getUsers(@Req() req: Request) {
return req.tenantPrisma.user.findMany();
}
Automated tenant provisioning saves hours. When a new client signs up:
// src/tenants/tenant.service.ts
async createTenant(name: string): Promise<Tenant> {
const dbName = `tenant_${uuidv4().replace(/-/g, '')}`;
const dbUrl = this.configService.get('tenantDbTemplate').replace('<TENANT_ID>', dbName);
// Create new database
await this.masterPrisma.$executeRaw`CREATE DATABASE ${dbName}`;
// Run migrations on new DB
const tenantPrisma = new PrismaClient({ datasources: { db: { url: dbUrl } });
await tenantPrisma.$connect();
await tenantPrisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${name}`;
// Store in master DB
return this.masterPrisma.tenant.create({
data: { name, databaseUrl: dbUrl }
});
}
Testing requires special attention. We use Docker containers to spin up isolated databases:
// test/tenant.e2e.spec.ts
describe('Tenant Isolation', () => {
let tenantA: PrismaClient;
let tenantB: PrismaClient;
beforeAll(async () => {
tenantA = await createTestTenant('clientA');
tenantB = await createTestTenant('clientB');
});
it('should isolate data between tenants', async () => {
await tenantA.user.create({ data: { email: '[email protected]' } });
const users = await tenantB.user.findMany();
expect(users).toHaveLength(0);
});
});
Performance monitoring is non-negotiable. We track:
- Connection pool utilization
- Query execution times per tenant
- Database size growth
My preferred tools? Datadog for metrics, Winston for logging, and Prisma’s built-in query logging. Ever wondered why some queries slow down unexpectedly? Often it’s missing indexes or inefficient joins.
Common pitfalls I’ve encountered:
- Forgetting connection limits (use PgBouncer)
- Not cleaning up idle connections
- Hardcoding database credentials
- Skipping tenant validation in background jobs
The database-per-tenant approach shines when data sovereignty matters. Financial services? Healthcare applications? This architecture keeps compliance teams happy. Yes, it adds complexity, but the security benefits outweigh the costs.
What about cost optimization? Consider automatically hibernating unused tenant databases. Or tiered storage – premium tenants get SSDs while others use standard storage.
I’ve deployed this pattern in production handling over 200 tenants. The key? Automation. From provisioning to backups, manual processes won’t scale. Use infrastructure-as-code tools like Terraform to manage database clusters.
Remember to validate tenant access at every layer. A simple guard prevents data leaks:
// src/shared/guards/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const resourceTenantId = request.params.tenantId;
const userTenantId = request.user.tenantId;
if (resourceTenantId !== userTenantId) {
throw new ForbiddenException('Tenant mismatch');
}
return true;
}
}
Building multi-tenant systems challenges you to think about scalability from day one. With NestJS’s modular architecture and Prisma’s type safety, we create robust foundations. Start with isolation, enforce strict boundaries, and automate everything.
What questions do you have about this approach? Have you tried other multi-tenant patterns? Share your experiences below – I’d love to hear what works for your team. If this helped you, pass it along to someone building SaaS applications!