Multi-tenancy: isolation versus cost
Three models, and why the cheapest one is the hardest to get right.
Open this lesson in the learning hubKey points
- Three models, in increasing isolation: shared schema with a tenant column, schema per tenant in one database, and database per tenant.
- Shared schema is cheapest to run and hardest to make safe. Every query needs the tenant predicate, and one missing WHERE clause is a cross-tenant data leak - the most serious bug this class of system produces.
- Do not rely on developers remembering. Enforce it structurally: row-level security in the database, or a Hibernate filter applied globally, so a query without the predicate cannot be written by accident.
- Database per tenant gives real isolation and per-tenant backup and restore, but migrations must now run across hundreds of databases, and connection pooling becomes a genuine problem.
- Noisy neighbours are the shared-model failure mode: one large tenant consumes the shared resource and every other tenant degrades. Per-tenant rate limits and query timeouts are what contain it.
- Most systems end up hybrid - shared schema for the long tail, dedicated infrastructure for the largest customers who are also the ones contractually demanding isolation.
Example
-- Shared schema, enforced by the DATABASE rather than by discipline.
-- Postgres row-level security: a query with no tenant predicate returns
-- nothing rather than everything, which is the correct failure direction.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- The application sets it once per transaction, from the authenticated
-- principal - never from a request parameter a caller could forge.
SET LOCAL app.tenant_id = 'a1b2c3d4-...';
---
/* The three models, side by side:
*
* SHARED SCHEMA SCHEMA/TENANT DATABASE/TENANT
* cost per tenant lowest medium highest
* blast radius all tenants one schema one tenant
* noisy neighbour severe moderate none
* migration effort one run N schemas N databases
* restore one tenant hard moderate trivial
* connection pooling simple simple hard
*
* The honest summary: shared schema scales operationally and fails
* catastrophically when it fails. Database per tenant is the opposite.
*/
// Per-tenant limits are what stop one customer degrading the rest.
@Component
class TenantRateLimiter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
boolean tryConsume(String tenantId) {
return buckets.computeIfAbsent(tenantId, id -> Bucket.builder()
.addLimit(Bandwidth.simple(1000, Duration.ofMinutes(1)))
.build()).tryConsume(1);
}
}
Enforce tenant isolation in the database, not in application discipline - a missing WHERE clause must return nothing, not everything.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Microservices course, and every lesson in it is listed on the Microservices contents page.