Multi-tenancy is one decision with three options, and the right answer depends on a question most teams skip: what happens when isolation fails?
The three models
Row-level tenancy — one schema, a tenant_id column on every table, a predicate on every
query. Cheapest to operate, easiest to get wrong: isolation is a WHERE clause that every query,
every report, every ad-hoc script must remember. Hibernate filters help, but native queries and
analysts bypass them. If a leak is an incident report, this can be fine. If a leak ends the
company, it isn't.
Schema-per-tenant — one database, one schema per tenant, the connection bound to a schema per request. Isolation is structural: a query physically cannot see another tenant's tables. Costs: migrations run N times, and connection pooling needs thought.
Database-per-tenant — maximum isolation, per-tenant backup and restore, and an operational burden that grows linearly with sales. Right for dozens of large enterprise customers; wrong for thousands of small ones.
We run schema-per-tenant for enterprise B2B: strong enough isolation to survive a security review, cheap enough to onboard a tenant without provisioning infrastructure.
The mechanics
Three pieces. A filter resolves the tenant (header, subdomain, or JWT claim) into a ThreadLocal context. A routing datasource consults it:
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.require(); // throws if absent — never a default schema
}
}Note require(), not get(). Fail closed. A request with no resolved tenant should be an
exception, never a fallback to some default schema. A default schema is where cross-tenant bugs
go to hide.
Third, the tenant id goes into every cache key and every search query. Isolation that stops at the database leaks through Redis.
The bug that almost shipped
Everything above worked. Then a scheduled job and an @Async notification handler ran against…
no tenant at all. ThreadLocals don't propagate to pooled executor threads:
@Async
public void sendWarrantyReminder(EquipmentId id) {
// TenantContext is EMPTY here — this thread came from a pool,
// not from the request that scheduled the work.
}The fix is a TaskDecorator that captures the context at submission time and restores it on the
worker thread:
public class TenantAwareTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
String tenant = TenantContext.require(); // capture on the submitting thread
return () -> {
TenantContext.set(tenant);
try { task.run(); }
finally { TenantContext.clear(); } // pooled threads are reused — always clear
};
}
}The finally block matters as much as the propagation: a stale tenant on a reused pool thread
is a cross-tenant bug, just a probabilistic one. Every executor in the application must be
wrapped — which is exactly the kind of "must remember everywhere" rule that deserves to be
packaged, so I extracted it into a starter where the
auto-configuration wraps executors for you.
Migrations at N schemas
Schema-per-tenant means Flyway runs once per schema. Two rules keep this sane: migrations must be fast (batch backfills separately), and migration state must be observable per tenant — "which tenants are on V42" is a dashboard, not a mystery. Tenant onboarding is then just "create schema, run migrations," which is the same code path as deploy day. One path, well tested.
How to choose
Ask, in order: What's the blast radius of a leak? How many tenants at what size? Can operations handle per-tenant infrastructure? Row-level for many small tenants and survivable leaks; schema-per-tenant for enterprise B2B in the tens-to-hundreds; database-per-tenant when customers demand their own backups or their own region. Then make the isolation structural, fail closed, and assume every ThreadLocal you own is a bug you haven't met yet.