Appearance
11.24 Multi-Tenant SaaS Platform
Imagine you are building an apartment complex. You could build a separate house for every family (single-tenant), or you could build a large high-rise where families share the foundation, plumbing, and roof, but have their own locked doors and private living spaces (multi-tenant).
In software, a multi-tenant SaaS platform serves multiple customers (tenants) using a single instance of the application and underlying infrastructure. This maximizes resource utilization and simplifies updates, but introduces massive complexity around keeping tenant data separate, preventing one tenant from hogging all the resources, and charging them accurately.
Requirements
The product here is an order-management service sold to 5,000 business customers (tenants). Most are small shops; a few are national retailers. They all share one deployment.
Functional. Sign up a tenant and have it live at once. Serve every API call in the context of exactly one tenant. Per-tier quotas (free, pro, enterprise). Per-tenant configuration: branding, webhooks, SSO. Metering of usage for billing. A dedicated, isolated deployment for the few customers whose contracts demand it.
Non-functional, with numbers.
- Zero cross-tenant reads. Not "rare": zero. One leak is a breach notification to every affected customer.
- One tenant's spike may raise other tenants' p99 latency by at most 20%.
- A new tenant is live within one minute of signing up.
- A single tenant's data can be restored to a point in time within 4 hours, because "we deleted our product catalogue by mistake" is a support ticket that arrives every month.
Out of scope today: the billing provider integration itself, and the admin console.
The clarifying questions, and what each answer changes
"Do any customers need a dedicated database, or their data in a particular country, by contract?" If yes, the design must support several isolation models side by side, and the routing layer must know where each tenant lives. That is the hybrid model at the end of this page.
"How uneven are the tenants?" In almost every B2B product, a handful of tenants generate most of the data and load. The answer decides whether the shard key is simply tenant_id or whether large tenants need shards of their own.
"Can one tenant's data be restored without restoring everyone's?" In the shared-table model, a database backup contains every tenant, so restoring it would roll back 4,999 innocent customers. A "yes" forces a per-tenant export or point-in-time extraction path to be built deliberately.
"Is billing per seat or per usage?" Per seat needs a nightly count. Per usage needs every request metered, which is a data pipeline in its own right.
"Do tenants need their own custom fields?" If yes, a JSONB column for custom attributes beats a schema per tenant, because 5,000 schemas multiply every migration by 5,000.
Estimation
Request rate. 50 million API calls a day ÷ 100,000 seconds ≈ 500 a second on average, and about 2,000 a second at peak. What that forces: a modest fleet of stateless API servers; the load is not the hard part.
Skew, which is the hard part. The largest tenant sends about 4% of all traffic: 20 requests a second on average. Its nightly catalogue import runs at about 50 times that, around 1,000 a second, which is half the whole system's peak coming from one customer. What that forces: per-tenant rate limits are not optional. Without them, one customer's import is everybody's outage. This single number is the "noisy neighbour" section below, quantified.
Data size. About 10 TB in total, split very unevenly: the top 50 tenants hold about 5 TB (around 100 GB each) and the other 4,950 hold the other 5 TB (about 1 GB each). What that forces: 10 TB is uncomfortable on one PostgreSQL primary, so tenants are spread across several database shards by tenant_id, with a directory table saying which shard holds which tenant. The biggest tenants are placed on shards of their own, and the directory makes moving a tenant a data copy plus one row update.
Why one database per tenant does not scale to 5,000. Suppose 20 application servers each keep a pool of 10 connections per database. With one database per tenant that is 5,000 × 20 × 10 = 1,000,000 database connections. A PostgreSQL server runs one operating-system process per connection and copes with hundreds to a few thousand of them. What that forces: the silo model is reserved for the few tenants who pay for it. Everyone else shares tables, which is why row-level security later on this page is mandatory rather than nice to have.
Metering. One usage record of about 100 bytes per call is 5 GB a day raw. Rolled up to tenant × hour × about 5 metrics, it is 5,000 × 24 × 5 = 600,000 rows a day. What that forces: usage events go to a stream and are aggregated hourly; nobody computes an invoice by scanning raw request logs.
API
http
POST /admin/tenants
{ "name": "Kolkata Kitchenware", "tier": "pro", "region": "ap-south-1" }
→ 201 Created
{ "tenantId": "t_5a1c", "isolation": "pool", "shard": "shard-07",
"apiKey": "mk_live_3hV…" } (shown once, never again)http
GET /api/v1/orders?limit=50&cursor=eyJpZCI6Im9fOTkifQ
X-Api-Key: mk_live_3hV…
→ 200 OK
RateLimit-Remaining: 1187
{ "items": [ … ], "nextCursor": "eyJpZCI6Im9fMTQ5In0" }
→ 429 Too Many Requests
Retry-After: 2
{ "error": { "code": "TENANT_RATE_LIMITED",
"message": "Limit is 1,200 requests per minute on the pro tier.",
"requestId": "req_9c2" } }http
PUT /api/v1/tenant/config
If-Match: "v14"
{ "webhookUrl": "https://kk.example/hooks", "brandColour": "#c2410c" }
→ 200 OK ETag: "v15"
→ 412 Precondition Failed (someone else changed it since v14)
GET /api/v1/tenant/usage?from=2026-10-01&to=2026-10-18 → hourly totalsThe tenant comes from the credential, never from the request. There is no tenantId in the URL or body of tenant-facing calls. If there were, the first bug or the first curious customer would change it and read someone else's orders. The middleware resolves the tenant from the API key (or a JWT claim), and nothing downstream accepts a tenant from anywhere else.
429 names the limit. A customer whose integration is being throttled needs to know which limit, and Retry-After tells their client when to come back instead of hammering.
Config updates use If-Match, because two admins at the same customer editing settings at once would otherwise silently overwrite each other (9.6.3).
Data model
tenants
id UUID PRIMARY KEY
tier TEXT NOT NULL -- free | pro | enterprise
isolation TEXT NOT NULL -- pool | silo
shard TEXT NOT NULL -- where its rows live
region TEXT NOT NULL
api_keys
key_hash BYTEA PRIMARY KEY -- SHA-256 of the key; the key itself is never stored
tenant_id UUID NOT NULL
created_at, revoked_at
orders -- every tenant-owned table looks like this
tenant_id UUID NOT NULL
id UUID NOT NULL
…
PRIMARY KEY (tenant_id, id) -- tenant_id first, always
tenant_config
tenant_id UUID, version INT
config JSONB NOT NULL
PRIMARY KEY (tenant_id, version)
usage_hourly
tenant_id, hour TIMESTAMPTZ, metric TEXT, value BIGINT
PRIMARY KEY (tenant_id, hour, metric)Access patterns:
| Query | Frequency | Returns |
|---|---|---|
| Resolve an API key to a tenant | every request (cached) | one row |
| A tenant's orders, newest first | 2,000/s peak | one page |
| A tenant's config | every request (cached) | one row |
| Usage for an invoice period | monthly per tenant | ~3,600 rows |
| Which shard holds tenant X | every request (cached) | one row |
tenant_id leads every primary key and every index. Every query is tenant-scoped, so this is the partition key both for sharding and for row-level security, and it makes "export everything for tenant X" a range scan rather than a search.
API keys are stored as hashes, like passwords (8.4.1). A leaked database backup then contains no usable keys. Because keys are long and random, a plain SHA-256 is enough here; the slow password hashes exist for short, guessable secrets.
Data Isolation: The Core of Multi-Tenancy
The most critical decision in a multi-tenant system is how to store data. If Tenant A sees Tenant B's data, your company is dead. You have three primary models for data isolation.
1. The Silo Model (Database per Tenant)
Every tenant gets their own physical or logical database.
- Pros: Perfect isolation. Restoring a backup for a single tenant is trivial. Easy to meet strict compliance requirements (like HIPAA).
- Cons: Expensive. Hard to manage at scale. Running migrations across 10,000 databases is a nightmare.
2. The Bridge Model (Schema per Tenant)
Tenants share the same database server, but each gets their own schema (a distinct set of tables).
- Pros: Better resource utilization than Silos. Still provides strong logical isolation.
- Cons: Connection pooling gets complicated. Some databases limit the number of schemas you can efficiently manage.
3. The Pool Model (Shared Schema)
All tenants share the same database and the same tables. Every row has a tenant_id column.
- Pros: Maximum efficiency. Easiest to maintain and scale horizontally. Running migrations happens once.
- Cons: Highest risk of data leakage. A single missing
WHERE tenant_id = ?clause causes a catastrophic breach. Restoring data for one tenant is extremely difficult.
Implementing Row-Level Security (RLS)
If you use the Pool model, you must use database-level enforcement, not just application logic. Modern databases like PostgreSQL support Row-Level Security (RLS). You set a configuration variable on the database connection, and Postgres automatically filters rows based on policies, saving you from the dreaded missing WHERE clause.
Context Injection in Code
How does the application know which tenant is making the request? You extract it from the subdomain (e.g., acme.saas.com), an API key, or a JWT token, and inject it into the request context via middleware.
Here is a typical pipeline extracting the tenant and setting up a data access object (DAO):
typescript
import { Request, Response, NextFunction } from 'express';
import { createHash } from 'node:crypto';
import { db } from './database';
const sha256 = (s: string) => createHash('sha256').update(s).digest();
interface TenantContext {
tenantId: string;
tier: 'free' | 'pro' | 'enterprise';
}
// Extend Express Request to hold our context
declare global {
namespace Express {
interface Request {
tenant: TenantContext;
}
}
}
// 1. Middleware to resolve tenant from JWT or API Key
export const tenantResolver = async (req: Request, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).send('Missing API Key');
}
// Look up the tenant by the key's hash; only hashes are stored (in reality, cache this)
const tenant = await db.query(
'SELECT t.id, t.tier FROM api_keys k JOIN tenants t ON t.id = k.tenant_id WHERE k.key_hash = $1 AND k.revoked_at IS NULL',
[sha256(apiKey)]);
if (!tenant) {
return res.status(403).send('Invalid Tenant');
}
req.tenant = {
tenantId: tenant.id,
tier: tenant.tier
};
next();
};
// 2. Using the context in a route
app.get('/api/v1/orders', tenantResolver, async (req: Request, res: Response) => {
const { tenantId } = req.tenant;
// BAD: Relying purely on application logic (Pool Model)
// const orders = await db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
// GOOD: Using connection-bound context for Row-Level Security
const client = await db.getClient();
try {
await client.query('BEGIN'); // (a)
await client.query("SELECT set_config('app.current_tenant', $1, true)", [tenantId]); // (b)
const { rows } = await client.query('SELECT * FROM orders'); // (c)
await client.query('COMMIT');
res.json(rows);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
});The three marked lines are where a well-meant version usually breaks.
- (a) The setting must live inside a transaction. A tenant setting that outlives the request is dangerous, because the connection pool hands this same connection to the next request, which may belong to a different tenant. A transaction-local setting disappears at
COMMITorROLLBACK, so it cannot leak. But a transaction-local setting made outside a transaction does nothing at all: PostgreSQL only warns, and the next query runs with no tenant set. - (b) Use
set_config, notSET. It is tempting to writeSET LOCAL app.current_tenant = $1, butSETis not an ordinary query and does not accept bind parameters, so that line fails. Building theSETstring by hand would open an SQL-injection hole on the most sensitive value in the system.set_config(name, value, true)is a normal function call that takes a parameter safely, and thetruemakes it transaction-local. - (c) No
WHERE tenant_idhere, on purpose. The policy adds it. That is the point of RLS: a developer who forgets the clause gets the current tenant's rows, not everyone's.
Two more conditions make RLS actually hold. The policy itself reads the setting, for example USING (tenant_id = current_setting('app.current_tenant')::uuid). And the application must connect as a role that does not own the table, or the table must have FORCE ROW LEVEL SECURITY, because PostgreSQL skips policies for a table's owner by default. Many teams discover this only when a penetration test reads another tenant's orders. The same caveats appear in 10.25.
Every claim above, run against a real database. The scenarios below were executed on PostgreSQL 18.6 with the policy shown above, three orders (a lamp and a desk for tenant A, a chair for tenant B), and the application connected as a role that can read the table but does not own it, exactly as recommended.
| What the code does | Result |
|---|---|
SET LOCAL app.current_tenant = $1 with a bind parameter | syntax error at or near "$1" |
SET LOCAL outside a transaction, then query | error: invalid input syntax for type uuid: "" |
BEGIN, set_config(…, true) for tenant A, query, COMMIT | lamp, desk |
Same connection after that COMMIT, no tenant set | same uuid error |
Session-level set_config(…, false) for A, then the next request forgets to set its tenant | lamp, desk (tenant A's rows leak) |
Connected as the table owner, tenant B set, no FORCE | lamp, desk, chair (every tenant) |
Same, after ALTER TABLE orders FORCE ROW LEVEL SECURITY | chair |
Two rows deserve a sentence each. The uuid errors are good news: with no tenant set, current_setting returns an empty string, the cast to uuid fails, and the query is refused instead of returning everyone's data. The policy fails closed, which is what you want; resist the temptation to "fix" the error with a default value, because a default tenant is a leak waiting to happen. The fifth row is the pooled-connection leak from point (a), observed rather than predicted: a session-level setting stays on the connection, and the next request inherits another tenant's identity without any error at all.
The Noisy Neighbor Problem & Quotas
In a multi-tenant system, one tenant doing a massive data export can consume all CPU and memory, degrading performance for everyone else. This is the "noisy neighbor" problem.
To solve this, you must implement Quotas and Rate Limiting at multiple layers:
- Ingress/API Layer: Throttle requests per second per tenant using Redis token buckets.
- Compute/Worker Layer: Give each tenant a dedicated queue, and use round-robin processing so a tenant with 1,000,000 jobs doesn't starve a tenant with 5 jobs.
- Storage Layer: Track database capacity per tenant and block writes if they exceed their storage tier.
Per-Tenant Configuration and Scaling
Tenants will want customization. They want their own logos, their own webhook endpoints, and their own SSO integrations.
Never hardcode if (tenant == 'AcmeCorp') in your code. Instead, use a centralized configuration store (often a highly cached JSON blob in the database or Redis) that the application reads at runtime.
For Enterprise tenants who pay a premium, you might need hybrid isolation. You run 99% of your customers in the Pool model, but for your biggest client, you spin up a dedicated Silo architecture. Your deployment pipelines and routing layer must be smart enough to route enterprise.saas.com to their isolated infrastructure, while routing everyone else to the shared cluster.
Billing Boundaries
You cannot bill what you cannot measure. Every piece of infrastructure must emit metrics tagged with tenant_id.
- Storage: Run nightly cron jobs to calculate gigabytes used per
tenant_id. - Compute: API gateways must log the
tenant_idfor every request to bill by API call. - Network: Tag egress traffic with tenant IDs if bandwidth is a billing dimension.
These metrics are piped into a time-series database and aggregated to send to Stripe or your billing provider.
Scale and failure
| What breaks | Blast radius | How you find out | What keeps it running | Recovery |
|---|---|---|---|---|
| A query without a tenant filter | potentially every tenant | RLS refuses it with an error | row-level security, failing closed | fix the code path; nothing leaked |
| Tenant setting leaks on a pooled connection | the next request's tenant | only by testing for it | transaction-local set_config | no damage if the rule is followed |
| One tenant's import surge | everyone's latency | per-tenant request rate and p99 | per-tenant rate limits; fair queues | the import slows; others are unaffected |
| One shard overloaded by a growing tenant | tenants on that shard | per-shard CPU and p99 | the directory table | move the tenant to its own shard |
| A silo tenant's migration fails | that tenant only | migration orchestrator status | per-database migration tracking | retry or roll back that one database |
| A customer deletes their own data | one tenant | support ticket | per-tenant point-in-time extraction | restore that tenant's rows only |
What the interviewer will push on
- "How do you prevent data leakage in a shared database?" Do not just say "I will put
WHERE tenant_id = ?everywhere." That is error-prone. Mention Database Row-Level Security (RLS) or an ORM-level global scope that automatically injects the tenant ID into every query. - "A tenant just uploaded 50 million records and brought down the database for everyone. How do you fix it?" Discuss isolation at the asynchronous worker level. Explain that bulk uploads should go to an S3 bucket, trigger an event, and be processed by a background queue. Mention fair-share queuing (e.g., pulling one job per tenant round-robin) rather than strict FIFO.
- "How do you handle schema migrations in the Silo model for 10,000 tenants?" You cannot run them sequentially in a single thread. You need a migration orchestrator that fans out the migrations asynchronously, tracks success/failure per database, and handles rollbacks if a specific tenant's database fails to migrate.
Recall
- Silo vs Bridge vs Pool: The three data isolation models trading off perfect isolation for maximum efficiency.
- Context Injection: Identifying the tenant at the API edge and passing that identity down to the database layer.
- Row-Level Security (RLS): Database-native feature to enforce tenant data isolation.
- Noisy Neighbor: When one tenant consumes shared resources, hurting others. Fixed via rate limits and fair-share queuing.
- Telemetry Tagging: Every log, metric, and billing event must include the
tenant_id.
Quiz
- Why is relying entirely on application-level
WHERE tenant_id = ?clauses risky in the Pool model? - What is the main operational drawback of the Silo (database-per-tenant) model?
- How does fair-share queuing help mitigate the noisy neighbor problem in background processing?
Answers:
- A single missed clause due to developer error will result in cross-tenant data leakage.
- Managing schema migrations, backups, and infrastructure costs across thousands of separate databases becomes exponentially difficult and expensive.
- Instead of processing jobs strict First-In-First-Out (where one tenant's massive backlog stalls everyone else), it pulls jobs from multiple tenants in a round-robin fashion, ensuring small tasks from other tenants complete quickly.