I’ve been thinking a lot about scalable SaaS architectures lately. What separates successful platforms from struggling ones? Often it’s how they handle multiple customers securely and efficiently. Today, I’ll share practical steps for building multi-tenant applications using NestJS, Prisma, and PostgreSQL. Follow along to implement robust tenant isolation while maintaining developer sanity.
When designing multi-tenant systems, we face fundamental choices. The shared database approach adds tenant IDs to every table. It’s simple but offers weak isolation. How confident would you feel storing sensitive data this way? Instead, we’ll use database-per-tenant architecture. Each customer gets their own PostgreSQL database. This provides strong security boundaries and customization options. The trade-off? More complex connection management. Let’s solve that.
We start by setting up our NestJS project:
nest new saas-platform
cd saas-platform
npm install @nestjs/config prisma @prisma/client
npx prisma init
Our folder structure organizes concerns clearly. Key directories include tenant-manager
for connection logic and modules
for business features. Configuration handles environment variables:
// src/config/config.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
masterDbUrl: process.env.MASTER_DB_URL,
tenantDbTemplate: process.env.TENANT_DB_URL.replace('{tenant}', ''),
});
The master database stores tenant metadata. We define its schema with Prisma:
// prisma/schema.prisma
model Tenant {
id String @id @default(cuid())
name String
subdomain String @unique
dbUrl String
status TenantStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum TenantStatus { ACTIVE SUSPENDED PENDING }
Tenant databases share a common schema template:
// prisma/tenant-template.prisma
model User {
id String @id @default(cuid())
email String @unique
tenantId String
createdAt DateTime @default(now())
}
The magic happens in our connection manager. We create a service that caches Prisma clients:
// src/tenant-manager/tenant.service.ts
@Injectable()
export class TenantService {
private clients: { [key: string]: PrismaClient } = {};
async getClient(tenantId: string): Promise<PrismaClient> {
if (!this.clients[tenantId]) {
const tenant = await this.masterDb.tenant.findUnique({
where: { id: tenantId },
});
this.clients[tenantId] = new PrismaClient({
datasources: { db: { url: tenant.dbUrl } },
});
}
return this.clients[tenantId];
}
}
Authentication must be tenant-aware. We modify Passport strategies to validate tenant context:
// src/auth/tenant.strategy.ts
@Injectable()
export class TenantStrategy extends PassportStrategy(Strategy) {
constructor(private tenantService: TenantService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: { sub: string; tenantId: string }) {
const prisma = await this.tenantService.getClient(payload.tenantId);
return prisma.user.findUnique({ where: { id: payload.sub } });
}
}
Request isolation is critical. We create middleware that resolves the tenant early:
// src/common/middleware/tenant.middleware.ts
export class TenantMiddleware implements NestMiddleware {
constructor(private tenantService: TenantService) {}
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) throw new UnauthorizedException('Tenant not identified');
req.prisma = await this.tenantService.getClient(tenantId);
next();
}
}
Database migrations need special handling. We script tenant provisioning:
// scripts/provision-tenant.ts
async function createTenant(name: string, subdomain: string) {
const dbUrl = `${config.tenantDbTemplate}${subdomain}`;
// Create physical database
await masterDb.$executeRaw`CREATE DATABASE ${subdomain}`;
// Migrate tenant schema
const tenantPrisma = new PrismaClient({
datasources: { db: { url: dbUrl } },
});
await tenantPrisma.$connect();
await tenantPrisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${subdomain}`;
await tenantPrisma.$disconnect();
// Store tenant record
return masterDb.tenant.create({
data: { name, subdomain, dbUrl },
});
}
Performance requires smart pooling. We integrate Redis for caching tenant configurations:
// src/tenant-manager/tenant.service.ts
async getClient(tenantId: string): Promise<PrismaClient> {
const cached = await redis.get(`tenant:${tenantId}`);
if (cached) return new PrismaClient(JSON.parse(cached));
// ...fetch from DB and cache
}
Security demands constant vigilance. We implement row-level security in PostgreSQL:
-- Enable RLS on tenant tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Policy ensuring data isolation
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant'));
Testing strategies must simulate multi-tenant environments. We use Jest to verify isolation:
// test/tenant-isolation.e2e-spec.ts
it('prevents data leakage between tenants', async () => {
const tenantARes = await testServer(tenantA)
.get('/users')
.set('x-tenant-id', 'tenantA');
const tenantBRes = await testServer(tenantB)
.get('/users')
.set('x-tenant-id', 'tenantB');
expect(tenantARes.body).not.toEqual(tenantBRes.body);
});
Deployment considerations include connection limits. We use PgBouncer for pooling and set up monitoring with Prometheus. Alert rules watch for tenant-specific performance degradation.
Building multi-tenant systems is challenging but rewarding. Each piece must work in concert to achieve secure isolation without sacrificing developer experience. What questions do you have about scaling this further? Share your thoughts below - let’s keep learning together. If this helped you, please share it with others facing similar challenges.