// Architecture
Designing a Multi-Tenant SaaS Architecture
Database per tenant, schema per tenant or a shared schema? The trade-offs, and how we made tenant isolation automatic in a white-label SaaS.
Md Nayeem HossainJun 30, 20254 min read// Architecture
Database per tenant, schema per tenant or a shared schema? The trade-offs, and how we made tenant isolation automatic in a white-label SaaS.
Md Nayeem HossainJun 30, 20254 min readWhen we built AffsFlow, every customer needed their own fully branded affiliate network: their own subdomain, their own users, their own settings, and absolutely no way to see another customer's data. That is multi-tenancy, and the decisions you make about it in the first month shape the product for years.
This post covers the trade-offs I weighed and the patterns that held up in production.
Each tenant gets a separate database. Isolation is as strong as it gets, noisy neighbours are contained, and per-tenant backup and restore is easy.
The cost is operational: thousands of databases to migrate, monitor and connect to. Connection pooling becomes hard, and cross-tenant analytics require a separate pipeline. This model suits a small number of large, high-value customers, often with compliance requirements.
One database, one schema per tenant. It is a middle ground, but migrations still run once per tenant and schema counts grow without bound. In my experience it combines many of the costs of both other models.
Every table has a tenant_id column and every query filters by it. It is the cheapest to operate and scales to a very large number of small tenants. The risk is obvious: one missing WHERE tenant_id = ... leaks data across customers.
For AffsFlow we chose the shared schema, with layered defenses against that risk.
Relying on every developer to remember a filter in every query is not a strategy. Push tenant scoping into the infrastructure so it happens by default.
Resolve the tenant once, at the edge of the request:
1@Injectable()2export class TenantMiddleware implements NestMiddleware {3 constructor(private readonly tenants: TenantService) {}4 5 async use(req: Request, _res: Response, next: NextFunction) {6 const host = req.hostname; // acme.affsflow.com or a custom domain7 const tenant = await this.tenants.resolveByHost(host);8 if (!tenant) throw new NotFoundException("Unknown network");9 tenantContext.run({ tenantId: tenant.id }, next);10 }11}Scope data access through a repository that reads the context:
1export class TenantRepository<T extends { tenantId: string }> {2 constructor(private readonly repo: Repository<T>) {}3 4 find(where: FindOptionsWhere<T> = {}) {5 return this.repo.find({ where: { ...where, tenantId: currentTenantId() } as FindOptionsWhere<T> });6 }7}Application code then cannot query without a tenant, because the raw repository is not exposed.
PostgreSQL row-level security adds a guarantee at the database itself. Even a buggy raw query cannot read another tenant's rows:
1ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;2 3CREATE POLICY tenant_isolation ON campaigns4 USING (tenant_id = current_setting('app.tenant_id')::uuid);Set app.tenant_id at the start of each transaction from the request context. Combine it with a database role that is not the table owner, since owners bypass RLS by default.
With a shared schema, tenant_id should lead most indexes, because nearly every query filters on it:
1CREATE INDEX campaigns_tenant_created ON campaigns (tenant_id, created_at DESC);Unique constraints must also include the tenant. Two networks can each have a campaign with the slug summer-sale:
1ALTER TABLE campaigns ADD CONSTRAINT campaigns_tenant_slug UNIQUE (tenant_id, slug);In a shared system, one tenant running a huge export can slow everyone down. Protections we added:
tenant_id.Branded networks meant tenants could bring their own domain. That needs automated certificate provisioning, a domain verification step (a DNS TXT record), and tenant resolution by host. Keep a cache of host-to-tenant mappings, because this lookup happens on every request.
Write tests that specifically try to break isolation: create two tenants, log in as one, and attempt to read, update and delete the other's resources through every endpoint. Run them in CI. Isolation bugs are the most damaging bugs a SaaS can ship, so they deserve dedicated tests.
For most SaaS products, a shared schema with a tenant ID is the right starting point — as long as tenant scoping is automatic, enforced again by row-level security, reflected in every index and unique key, and protected by tests. Start shared, design so a large tenant can be moved out later, and make the safe path the default path.