Skip to content
Mahesh Kadambala
All articles

Backend3 min read

Multi-Tenancy in Spring Boot: Choosing Your Isolation Level

Row-level, schema-per-tenant, or database-per-tenant? A decision framework from running schema-per-tenant in production, plus the async ThreadLocal bug that almost shipped.

Spring BootJavaPostgreSQLMulti-Tenancy

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.

Related articles

Architecture3 min read

Building an Enterprise Search Engine on the JVM

How we replaced unindexed SQL filtering with a custom query DSL compiled to tenant-scoped Elasticsearch queries — the grammar, the AST, the trade-offs, and what I'd do differently.

Distributed Systems3 min read

The Transactional Outbox, Properly

Dual-writes between your database and Kafka will lose events — quietly, rarely, and at the worst time. Here's the outbox pattern as actually implemented, including the parts the diagrams skip.