
Multi-tenancy is the backbone of successful SaaS. Get it right, and scaling from 10 to 10,000 tenants is a database config change. Get it wrong, and every new feature requires rewriting the data access layer. We have built three multi-tenant SaaS applications with Spring Boot, and the patterns that worked are simpler than you would expect.
Part of our production SaaS playbook — pairs well with Self-Hosted CI/CD on a Home Rack (the pipeline that ships this) and ForkMyFolio Backend in Spring Boot.
TL;DR
- Three isolation models — database per tenant, schema per tenant, and row-level security. RLS on PostgreSQL is our default: 3-5% query overhead buys database-enforced isolation and huge cost savings.
- The tenant context lives in exactly two places — a ThreadLocal set by a servlet filter, and a connection interceptor that stamps every pooled connection. Nothing else in the service layer knows tenancy exists.
- Tenant comes from the JWT, never from the client — no header, no query param, no request body. This is what makes tenant impersonation impossible.
- Every index leads with tenant_id — RLS injects a tenant predicate into every query; a non-tenant-leading index turns it into a sequential scan.
- Isolation is a test, not a hope — a two-tenant isolation test runs on every deployment and fails the build if RLS breaks.
What You’ll Learn
-
Isolation Models
Database-per-tenant vs schema-per-tenant vs row-level security — and when each wins.
-
Request Lifecycle
The five touchpoints between an inbound JWT and a filtered row.
-
RLS + Hibernate Code
Working Spring Boot patterns: context holder, interceptor, schema-per-tenant config.
-
Performance
Tenant-first indexing, query plans, connection pooling, and the 3-5% overhead.
Why Multi-Tenancy Is an Architectural Decision, Not a Feature
You cannot bolt multi-tenancy on later. The isolation model you pick in month one determines your index design, your connection pooling strategy, your migration tooling, your backup story, and your compliance posture for the product’s entire life. Teams that treat it as “just add a tenant_id column” end up auditing every repository method a year later, hunting for the one query that forgot the filter.
The good news: once you commit to a small number of patterns — a context holder, a connection interceptor, a database policy, and a naming discipline — tenancy disappears from your codebase. Developers write normal repositories. The database does the filtering. A single integration test proves the isolation holds.
This guide covers the three isolation models and how to choose between them, the full request lifecycle with the code at each hop, working Spring Boot implementations for both row-level security and schema-per-tenant, JWT-based tenant authentication, indexing rules that keep RLS fast, migration strategy if you start shared and outgrow it, and the pitfalls we have hit in production.
The Three Isolation Models
Every multi-tenant system falls into one of three data isolation strategies. The Spring Boot application layer is nearly identical across all three — the difference is what the database does for you:

| Approach | Isolation | Cost | Complexity | Best For |
|---|---|---|---|---|
| Database per tenant | Maximum | High | Low per tenant, high fleet-wide | Enterprise, compliance-heavy, POPIA data residency |
| Schema per tenant | Strong | Medium | Medium | Mid-market, moderate tenant count (tens to low hundreds) |
| Row-level security | Enforced at DB | Low | Medium-high | SMB, high scale, cost-sensitive |
How to Actually Choose
- Start with RLS if you’re unsure. It scales to thousands of tenants on a single database, migration tooling runs once instead of N times, and backups are one restore job. This is our default for every new build.
- Choose schema-per-tenant when customers need to export “their” database, when you have under ~200 tenants, or when regulatory scope differs per customer in ways a column cannot express.
- Choose database-per-tenant only when a contract demands it — dedicated infrastructure, data residency, or per-tenant restore requirements. Budget for the operational tax: N schema migrations, N monitoring targets, N backup schedules.
One important property of this decision: RLS and schema-per-tenant are not a one-way door. Because both live behind the same application-facing tenant context, you can run small tenants on shared RLS and migrate an enterprise customer to a dedicated schema or database later — the service layer code does not change. Only the connection configuration does.
Lesson: The Isolation Guarantee Must Not Depend on Application Code
Any isolation model that requires every developer to remember to filter by tenant will eventually leak. That is the core reason we prefer database-enforced isolation — an RLS policy or a separate schema fails closed even when (especially when) someone writes a raw SQL query at 2am. Application code should set context; the database should enforce isolation.
Schema-per-Tenant with Hibernate
If you pick schema isolation, Hibernate supports it natively through two cooperating beans: one that resolves the current tenant for the session factory, and one that maps the tenant identifier to a physical schema name.
@Component
public class CurrentTenantIdentifierResolverImpl
implements CurrentTenantIdentifierResolver<String> {
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContext.getTenant();
// Fallback keeps startup/migrations from failing
return tenant != null ? "tenant_" + tenant : "public";
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
@Component
public class MultiTenantConnectionProviderImpl
implements MultiTenantConnectionProvider<String> {
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource().getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = getAnyConnection();
connection.setSchema("tenant_" + tenantIdentifier);
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection)
throws SQLException {
connection.setSchema("public");
connection.close();
}
}
With these registered, every SessionFactory opening pins the schema for the request. Repositories stay completely unaware:
@Service
public class InvoiceService {
private final InvoiceRepository repo; // plain Spring Data repository
public List<Invoice> findOverdue() {
// No tenant argument — schema was set when the connection was acquired
return repo.findByStatusAndDueDateBefore(
InvoiceStatus.OVERDUE, LocalDate.now());
}
}
Provisioning a New Tenant
public void provisionTenant(String tenantId) {
String schema = "tenant_" + tenantId;
jdbc.execute("CREATE SCHEMA " + schema);
// Hibernate validates the schema exists; flyway migrates it
flyway.setSchemas(schema).migrate();
}
Lesson: Schema Count Is a Real Ceiling
PostgreSQL handles a few hundred schemas comfortably. Past that, migration time, catalog bloat, and connection setup overhead grow linearly — N schemas × M migrations. If your growth curve passes a few hundred tenants, start on RLS or plan an explicit split: noisy/large tenants get dedicated schemas, the long tail stays shared.
Row-Level Security Implementation
RLS policies filter rows based on a session variable. The application sets this variable on every connection, and PostgreSQL applies the policy to every query — including queries the application never parameterised.
PostgreSQL Setup
ALTER TABLE organisations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON organisations
USING (tenant_id = current_setting('app.current_tenant')::uuid);
SET app.current_tenant = 'org_abc123';
Every query automatically filters by the current tenant. Even a developer debugging with raw SQL cannot accidentally leak data — PostgreSQL enforces the filter at the storage layer. For tables that also have a tenant_id foreign key pattern, apply the same policy shape to each; we automate this with a migration function that iterates over tables in a whitelist.
Spring Boot Integration
A JDBC interceptor sets the tenant context before every query:
@Component
public class TenantConnectionInterceptor implements Interceptor {
@Override
public void beforeConnectionAcquisition(Connection connection) {
UUID tenantId = TenantContext.getTenant();
if (tenantId != null) {
try (var stmt = connection.prepareStatement(
"SET app.current_tenant = ?")) {
stmt.setString(1, tenantId.toString());
stmt.execute();
}
}
}
}
This runs before every acquisition — not just the first. Connection pooling means connections are reused across requests and tenants, so the tenant context must be stamped on every checkout. Miss this and Tenant B inherits Tenant A’s session variable: the classic cross-tenant leak, and exactly the class of bug the isolation test at the end of this article catches.
Performance Considerations
The tenant filter adds 3-5% overhead to every query. Most of that disappears with composite indexes (covered in the performance section):
-- Without tenant index: sequential scan
CREATE INDEX idx_organisations_name ON organisations(name);
-- With tenant index: index scan
CREATE INDEX idx_organisations_tenant_name ON organisations(tenant_id, name);
Every query plan then starts with the tenant filter. The index covers both the tenant lookup and the application predicate, eliminating the overhead entirely for indexed queries.
The Request Lifecycle
Here is the part people get fuzzy on: where exactly does the tenant context live, and who is responsible for it? Five touchpoints, in order, every single request:

1. The Filter Establishes Context
@Component
@Order(1)
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
try {
String tenant = extractTenantFromJwt(req); // signed token, verified
TenantContext.set(UUID.fromString(tenant));
chain.doFilter(req, res);
} finally {
TenantContext.clear(); // ThreadLocal cleanup — non-negotiable
}
}
}
The finally block matters as much as the setter. A pooled Tomcat worker that keeps a stale ThreadLocal will serve the previous tenant’s context to the next request that lands on it. Clear it, always.
2. The Context Holder Carries It
@Component
public class TenantContext {
private static final ThreadLocal<UUID> CURRENT = new ThreadLocal<>();
public static void set(UUID tenantId) { CURRENT.set(tenantId); }
public static UUID get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}
3. Services Never Mention It
@Service
public class OrganisationService {
private final OrganisationRepository repo;
public List<Organisation> findAll() {
return repo.findAll(); // tenant filter applied by the database
}
public Organisation save(Organisation org) {
org.setTenantId(TenantContext.getTenant());
return repo.save(org);
}
}
Developers do not think about tenant filtering. The database handles it. The only place tenant logic lives is the filter and the connection interceptor — two points of control you can read in one screen.
4–5. Interceptor and Database Enforce
The interceptor stamps app.current_tenant on connection checkout; the RLS policy rewrites the WHERE clause at execution. Neither step trusts the application to have done its part.
Cross-Tenant Access Is Explicit
For queries that span tenants (admin dashboards, analytics, billing), bypass RLS with a superadmin role — deliberately and visibly:
@PreAuthorize("hasRole('SUPERADMIN')")
public List<CrossTenantReport> generateReport() {
return reportRepository.findAll();
}
This is the exception, not the rule. We flag every BYPASSRLS grant in code review; in production, membership in that role should be near-zero.
Security and Authentication
OAuth2 Multi-Tenant
The JWT token encodes the tenant context. The application never trusts client-provided tenant IDs:
{
"sub": "user_abc123",
"tenant_id": "org_xyz789",
"roles": ["admin", "editor"],
"org_name": "Acme Corp"
}
The tenant ID comes from the signed token — never from a query parameter, path variable, or header. This is what prevents tenant impersonation: rewriting X-Tenant: other-org does nothing, because nothing in the pipeline reads it.
Tenant-Specific Roles
Roles are tenant-scoped: a user can be an admin in one organisation and a viewer in another. The JWT carries role-per-tenant claims and the filter resolves the role matching the active tenant:
{
"sub": "user_abc123",
"tenants": [
{ "id": "org_1", "role": "admin" },
{ "id": "org_2", "role": "viewer" }
]
}
Lesson: Deny by Default on Tenant Resolution
If the token has no tenant_id, or the tenant no longer exists (suspended, deleted), the filter must reject the request — not fall back to a default tenant, not proceed unfiltered. A silent fallback means an unscoped connection, and an unscoped connection with RLS disabled for a role means every row is readable. Fail closed, every time.
Database Optimisation
The Tenant-First Index Rule
RLS injects a tenant predicate into every single query. If your index does not lead with tenant_id, the planner cannot use it to satisfy that predicate — and a filter it cannot serve is a filter applied to every row in the table:

-- Bad: status-first index cannot use the RLS filter
CREATE INDEX idx_orders_status ON orders(status);
-- Good: tenant-first composite serves both predicates
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);
The practical version of this rule: every index on a tenant table starts with tenant_id, with exactly one exception — global uniqueness constraints like UNIQUE(email) across tenants. Those you handle with partial indexes or a dedicated lookup table.
Connection Pooling
HikariCP with a pool size of 20 handles most workloads. Two multi-tenant-specific adjustments:
- Stamp on every checkout — as covered above, never assume a pooled connection retains state from a previous use.
- Watch for tenant hogging — one tenant running bulk exports can exhaust the pool for everyone. For noisy-tenant risk, add per-tenant rate limiting at the API gateway, or tier enterprise customers onto a dedicated pool with their own datasource.
Redis and Caching
Cache keys must be tenant-prefixed. A cache key of invoice:123 shared across tenants is a data leak wearing a performance hat — invoice:{tenantId}:123 is the only acceptable shape. Same rule applies to Redis-backed session stores, rate-limit counters, and computed report caches.
Testing Multi-Tenant Systems
Integration tests must verify tenant isolation — this is the test that catches a broken RLS policy or a missing interceptor stamp before your customers do:
@Test
void shouldNotReturnDataFromOtherTenants() {
TenantContext.set(TENANT_A);
List<Order> ordersA = orderService.findAll();
TenantContext.set(TENANT_B);
List<Order> ordersB = orderService.findAll();
assertThat(ordersA).noneMatch(o -> ordersB.contains(o));
}
@Test
void shouldRejectRequestWithoutTenant() {
TenantContext.clear();
assertThatThrownBy(() -> orderService.findAll())
.isInstanceOf(TenantNotFoundException.class);
}
A minimal multi-tenant test suite covers four cases:
| Test | Catches |
|---|---|
| Tenant A vs Tenant B no overlap | Broken or missing RLS policy |
| Missing tenant → rejected | Unscoped connection fallback |
| Connection reuse across tenants | Stale session variable on pooled connection |
| Cross-tenant endpoint → 403 for normal role | Authorization gap on admin APIs |
Run all four with every deployment. If isolation breaks, they catch it before users do.
Starting Shared, Scaling to Dedicated
Most products start with everything in one database and later face an enterprise customer who needs isolation. Because the application only ever sees a tenant context, the migration is a connection-layer change rather than a rewrite:
| Stage | Model | Trigger to move on |
|---|---|---|
| 1. Early | RLS on one database | — |
| 2. Growth | RLS, tenant-per-row scale, read replica | Query volume or backup windows hurt |
| 3. Split | Large tenants → dedicated schemas | A tenant’s data volume distorts others’ performance |
| 4. Enterprise | Per-tenant database for contract customers | Compliance or contractual requirement |
Implementation-wise, step 4 means routing the datasource by tenant — a lookup table mapping tenant → JDBC URL, consulted when the connection is acquired. The filter, context holder, service layer, and tests are unchanged. That is the payoff for keeping tenancy out of business logic in the first place.
Pitfalls That Cost Real Time
| Pitfall | Symptom | Fix |
|---|---|---|
| Trusting a client-supplied tenant header | Users can read other tenants’ data with a curl command | Tenant from signed JWT only |
| Setting the session variable only on connection creation | Intermittent cross-tenant reads under load | Stamp on every pool checkout |
| ThreadLocal never cleared | Wrong tenant on the next request to that worker | finally { clear(); } in the filter |
| Index without leading tenant_id | Queries slow down as data grows, plans show seq scans | Tenant-first composite indexes |
| Un-prefixed Redis/cache keys | One tenant sees another’s cached response | {entity}:{tenantId}:{id} everywhere |
| Background jobs without tenant context | Job runs unscoped — or not at all under RLS | Explicit tenant context per job run |
| Schema migrations on N schemas by hand | Drift between tenants, half-migrated estate | Automated per-schema migration runner |
| No isolation test in CI | Regression ships silently | Four-test suite on every deployment |
Lesson: Background Jobs Are the Silent Leak
Every scheduled job, async worker, and message listener runs outside the filter chain — so it has no ThreadLocal tenant, and therefore either fails under RLS or runs unscoped if someone “fixed” it by disabling the policy. Batch jobs must iterate tenants explicitly and set context per unit of work. This is the single most common source of multi-tenant bugs we have been called in to debug.
Conclusion
Multi-tenant Spring Boot is not complicated — it is disciplined. PostgreSQL RLS handles isolation. A ThreadLocal propagates context. Composite indexes handle performance. JWT claims encode tenant scope. Schema routing handles the enterprise tier. The patterns are simple; the hard part is applying them consistently, especially in the places that run outside the request cycle.
If you are building a multi-tenant SaaS and need architecture guidance, let us talk. We have built three production multi-tenant systems and learned the patterns — and the pitfalls — firsthand.
Need Help Building Your Multi-Tenant SaaS?
We design and deploy multi-tenant architectures for South African businesses. Whether you are starting from scratch or refactoring an existing monolith, we can help you build something that scales.
Related Reading
- ForkMyFolio Backend in Spring Boot — Modern multi-user platform architecture in practice
- Vue 3 vs React for Enterprise SaaS — Choosing the frontend half of your SaaS stack
- OnTheGoRentals: Architecture & Domain Design — Domain-driven design for a production SaaS
- Platform / Enterprise Services — Multi-tenant SaaS builds with RBAC and data isolation