and schema. Data isolation is enforced via PostgreSQL Row-Level Security policies and a tenant_id column on all tenant-scoped tables.
- Tenant Context Propagation: A typed
TenantContext object is injected into the request lifecycle and propagated to all service layers, ensuring no query executes without explicit tenant scoping.
2. Step-by-Step Implementation
Step A: Project Structure
src/
βββ apps/
β βββ api/ # Entry point, middleware, routes
βββ packages/
β βββ db/ # Drizzle schema, migrations, RLS policies
β βββ auth/ # JWT handling, session management
β βββ shared/ # Types, utilities, error handling
βββ modules/
β βββ billing/ # Stripe integration, subscriptions
β βββ tenant/ # Tenant CRUD, domain mapping
β βββ core/ # Business logic
Step B: Tenant Resolution Middleware
The middleware extracts the tenant identifier from the subdomain or X-Tenant-ID header and attaches it to the request context.
// apps/api/middleware/tenant-resolver.ts
import { Request, Response, NextFunction } from 'express';
import { tenantService } from '@modules/tenant';
import { ForbiddenError, NotFoundError } from '@packages/shared/errors';
export interface TenantContext {
tenantId: string;
tier: 'free' | 'pro' | 'enterprise';
features: string[];
}
declare global {
namespace Express {
interface Request {
tenantContext: TenantContext;
}
}
}
export const resolveTenant = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const identifier = req.headers['x-tenant-id'] as string
|| req.hostname.split('.')[0];
if (!identifier) {
throw new NotFoundError('Tenant identifier missing');
}
const tenant = await tenantService.findByIdentifier(identifier);
if (!tenant || !tenant.active) {
throw new ForbiddenError('Tenant inactive or not found');
}
req.tenantContext = {
tenantId: tenant.id,
tier: tenant.tier,
features: tenant.features,
};
next();
} catch (error) {
next(error);
}
};
Step C: Database Schema with RLS
Using Drizzle ORM, we define the schema. The critical component is the RLS policy definition, which ensures the database rejects any query lacking the correct tenant_id.
// packages/db/schema.ts
import { pgTable, uuid, varchar, text } from 'drizzle-orm/pg-core';
import { drizzle } from 'drizzle-orm/node-postgres';
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
config: text('config'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// RLS Policy SQL (Applied via migration)
/*
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
*/
Step D: Type-Safe Query Execution
The database client sets the session variable before executing queries. This guarantees RLS activation.
// packages/db/client.ts
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { TenantContext } from '@apps/api/middleware/tenant-resolver';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);
export async function withTenantContext<T>(
ctx: TenantContext,
fn: () => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query(
`SET app.current_tenant_id = '${ctx.tenantId}'`
);
return await fn();
} finally {
client.release();
}
}
Step E: Service Layer Integration
Services wrap database operations in the context handler.
// modules/core/services/project.service.ts
import { db, withTenantContext } from '@packages/db/client';
import { projects } from '@packages/db/schema';
import { eq } from 'drizzle-orm';
import { TenantContext } from '@apps/api/middleware/tenant-resolver';
export const projectService = {
async list(ctx: TenantContext) {
return withTenantContext(ctx, async () => {
return db.select().from(projects).orderBy(projects.createdAt);
});
},
async create(ctx: TenantContext, data: { name: string }) {
return withTenantContext(ctx, async () => {
return db.insert(projects).values({
tenantId: ctx.tenantId,
name: data.name,
}).returning();
});
}
};
3. Billing and Entitlements
SaaS requires a robust billing loop. Integrate Stripe with a webhook-first approach.
- Webhook Handler: Validates signatures, parses events, and updates the local tenant state.
- Entitlement Check: Before executing expensive operations, verify limits against the tenant's subscription.
// modules/billing/services/entitlement.service.ts
export const checkLimit = (ctx: TenantContext, resource: string) => {
const limits = {
free: { projects: 3, apiCalls: 1000 },
pro: { projects: 50, apiCalls: 50000 },
};
const limit = limits[ctx.tier]?.[resource];
if (!limit) throw new Error('Tier not configured');
// Implement usage counting logic here
const currentUsage = await getCurrentUsage(ctx.tenantId, resource);
if (currentUsage >= limit) {
throw new PaymentRequiredError(`Limit exceeded for ${resource}`);
}
};
Pitfall Guide
-
Implicit Tenant Filtering:
- Mistake: Relying on developers to add
where tenantId = ... in every query.
- Remediation: Use PostgreSQL RLS. It moves isolation to the database layer, making it impossible to bypass via application code errors.
-
The Noisy Neighbor Effect:
- Mistake: A single tenant runs a heavy query or consumes excessive memory, degrading performance for all tenants.
- Remediation: Implement per-tenant rate limiting, resource quotas, and query timeouts. Use
pg_cron or background workers with concurrency limits.
-
Schema Migration Downtime:
- Mistake: Running
ALTER TABLE locks blocks all tenants during deployments.
- Remediation: Use online migration strategies. Add columns as nullable, backfill data in batches, then enforce constraints. Never lock production tables during peak hours.
-
Webhook Reliability:
- Mistake: Assuming Stripe webhooks arrive instantly and only once.
- Remediation: Webhooks can be delayed, duplicated, or lost. Implement idempotency keys in your webhook handler. Use a message queue (e.g., BullMQ) to process events asynchronously with retry logic.
-
Super Admin Security:
- Mistake: Super admin access is too broad, allowing accidental data leaks across tenants.
- Remediation: Implement strict RBAC for super admins. Require MFA. Log all super admin actions. Provide "impersonation" tools that operate within the tenant context rather than bypassing it.
-
Observability Silos:
- Mistake: Metrics are aggregated globally, hiding per-tenant issues.
- Remediation: Tag all logs, traces, and metrics with
tenant_id. Build dashboards that allow filtering by tenant to diagnose specific customer issues.
-
Data Portability Ignorance:
- Mistake: Failing to provide data export mechanisms, leading to compliance violations and customer lock-in complaints.
- Remediation: Implement a
tenant.exportData() function early. Support CSV/JSON exports and ensure GDPR "right to be forgotten" workflows are automated.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| B2C SaaS, High Volume | Shared Schema + RLS | Maximizes query performance; isolation handled by DB; cost-effective. | Low |
| B2B Enterprise, Compliance | Hybrid (RLS) + Audit Logs | Meets isolation requirements via RLS; audit logs satisfy compliance. | Medium |
| Regulated Industry (HIPAA/Finance) | Separate Database per Tenant | Strict physical isolation required by regulators; simplifies audit scope. | High |
| Marketplace / Multi-sided | Shared Schema + Role-based Access | Complex relationships between users; RLS handles visibility rules. | Low |
Configuration Template
Docker Compose for Local SaaS Development
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: saas_user
POSTGRES_PASSWORD: saas_pass
POSTGRES_DB: saas_prod
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-rls.sql:/docker-entrypoint-initdb.d/init-rls.sql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
stripe-cli:
image: stripe/stripe-cli
command: listen --forward-to http://host.docker.internal:3000/webhooks/stripe
environment:
STRIPE_API_KEY: ${STRIPE_SECRET_KEY}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
pg_data:
Quick Start Guide
- Initialize Infrastructure:
Run
docker compose up -d to start PostgreSQL with RLS policies pre-loaded and Redis.
- Seed Database:
Execute
npx drizzle-kit push to apply schema. Run npx tsx scripts/seed-tenant.ts to create a default tenant and admin user.
- Start API:
Run
npm run dev. The API will start on localhost:3000.
- Verify Isolation:
Send a request with
X-Tenant-ID: tenant_123. Query data. Change the header to tenant_456 and verify that previous data is inaccessible. Check PostgreSQL logs to confirm RLS policies are active.
- Test Billing Flow:
Use
stripe-cli to trigger test events. Verify that webhook handlers update tenant status and that entitlement checks block access when limits are reached.
This architecture provides a resilient, scalable foundation for SaaS development, balancing velocity with the rigorous requirements of multi-tenancy. By enforcing isolation at the database layer and maintaining a modular structure, teams can iterate rapidly while ensuring security and compliance.