Multi-Tenancy in Spring Boot: Database, Schema, and Discriminator Strategies
Learn three multi-tenancy strategies in Spring Boot 3.2 — database-per-tenant, schema-per-tenant, and discriminator column.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Java 17+ installed on your machine
- ✓Spring Boot 3.2 project with spring-boot-starter-data-jpa
- ✓MySQL 8.0 or PostgreSQL 15 running locally
- ✓Basic understanding of JPA and Hibernate
• Multi-tenancy means a single app serves multiple clients (tenants) with isolated data.\n• Three main strategies: database-per-tenant, schema-per-tenant, and discriminator column.\n• Database-per-tenant offers strongest isolation but is hardest to scale.\n• Schema-per-tenant balances isolation and operational cost.\n• Discriminator column is simplest but least secure — use only for low-compliance scenarios.
Think of a bank with safety deposit boxes. Database-per-tenant is like each tenant renting their own vault room — total privacy but expensive. Schema-per-tenant is like each tenant having their own locked drawer within the same vault — good privacy, cheaper. Discriminator column is like all tenants sharing one big drawer with their names on envelopes — cheap but anyone could accidentally peek.
Multi-tenancy is the architectural pattern where a single instance of your application serves multiple customers — tenants — while keeping their data completely isolated. If you're building a SaaS platform for payment processing, HR management, or analytics dashboards, you'll face this problem sooner than later.
In the Spring Boot ecosystem, you have three battle-tested strategies: database-per-tenant, schema-per-tenant, and discriminator column (sometimes called shared table). Each comes with trade-offs in isolation, performance, and operational complexity. I've seen teams burn months on the wrong choice — picking discriminator for a fintech app (auditors will cry) or database-per-tenant for a 500-tenant system without connection pooling (hello, OOM).
This tutorial covers Spring Boot 3.2 with Hibernate 6.4, using realistic examples from a subscription billing domain. We'll walk through each strategy with working code, then dive into the production incidents that taught me how fragile these setups can be. By the end, you'll know exactly which strategy fits your use case and how to avoid the landmines I've stepped on.
Understanding Multi-Tenancy: The Three Strategies
Before we write code, you need to understand what each strategy actually does under the hood. I've seen developers pick database-per-tenant because 'it's the most secure' without realizing they just tripled their operational costs.
Database-per-tenant: Each tenant gets their own database. This gives you the strongest isolation — one tenant's corrupted data can't touch another's. But you also get connection pool explosion, backup nightmares (200 databases to back up), and schema migration hell. Use this for fintech, healthcare, or any scenario where compliance demands physical separation.
Schema-per-tenant: Single database, but each tenant has their own schema (namespace). In PostgreSQL, that's a schema; in MySQL, you simulate it with separate databases but same server. Isolation is strong — tenants share the same connection pool, so no pool explosion. Schema migrations run once per tenant, which is manageable with Flyway or Liquibase. This is my go-to for most B2B SaaS apps.
Discriminator column: All tenants share the same tables. A tenant_id column in every row separates data. This is the cheapest and fastest to implement, but you're one miswritten query away from a data leak. Use this only for internal tools or low-compliance scenarios where tenants trust each other.
The rest of this article builds a subscription billing system where each tenant has customers, invoices, and payment methods. We'll implement all three strategies so you can compare them side by side.
What the Official Docs Won't Tell You
Spring Boot's official documentation shows you a clean, academic example of multi-tenancy. What they don't show you is the production nightmare when your TenantContext filter silently swallows an NPE and every request defaults to tenant 'null'.
Here's the cold truth: ThreadLocal is your enemy and your savior. The official docs show you how to set up a TenantContext with ThreadLocal, but they don't tell you that thread pools (used by Tomcat, WebFlux, and @Async) will leak tenant IDs between requests if you don't clean up. I've debugged a production incident where User A's invoice showed up in User B's dashboard because the thread pool reused a thread that still had User A's tenant ID in its ThreadLocal.
The fix is brutal but necessary: use a OncePerRequestFilter that sets the tenant ID before the request and guarantees cleanup in a finally block. Not in a @PreDestroy or @PostConstruct — those run at bean lifecycle, not request lifecycle. Also, never use InheritableThreadLocal unless you want your async tasks to inherit the wrong tenant.
Another thing the docs gloss over: connection pool sizing. For database-per-tenant, HikariCP's default of 10 connections per datasource will kill your database if you have 100+ tenants. You need a shared pool with a routing datasource. I'll show you that in the next section.
Strategy 1: Database-Per-Tenant with Dynamic DataSources
This strategy creates a separate database for each tenant. The application holds a map of tenant IDs to DataSource objects. When a request comes in, we look up the tenant's datasource and use it for the entire Hibernate session.
When to use: PCI DSS, HIPAA, SOC 2 Type II — any compliance that requires physical data separation. Also useful when tenants have vastly different data volumes (one tenant has 10 rows, another has 10 billion rows).
How it works: We implement Hibernate's MultiTenantConnectionProvider and CurrentTenantIdentifierResolver. The connection provider returns a connection from the tenant-specific datasource. The identifier resolver tells Hibernate which tenant is active.
The catch: You need a way to provision new databases when a tenant signs up. I use Flyway with a per-tenant migration script that runs in a separate transaction. Also, never create datasources lazily in the request thread — you'll block the request for seconds while HikariCP initializes. Pre-warm datasources in a background thread when the tenant is created.
Here's the code for a dynamic datasource registry that creates datasources on tenant creation, not on first request.
Strategy 2: Schema-Per-Tenant with Single DataSource
This is the sweet spot for most B2B SaaS applications. You have one database, one connection pool, but each tenant has their own schema (namespace). In PostgreSQL, use CREATE SCHEMA IF NOT EXISTS tenant_123. In MySQL, you simulate this with separate databases on the same server, but the principle is the same.
Advantages: Single connection pool means no pool explosion. Schema migrations can be batched. Backups are simpler — one database dump covers all tenants.
How it works: Hibernate's MultiTenantConnectionProvider returns the same connection, but then uses Connection.setSchema() (PostgreSQL) or USE database_tenant_123 (MySQL) to switch the schema. The CurrentTenantIdentifierResolver still picks the tenant ID from the request.
The gotcha: Connection.setSchema() is supported in JDBC 4.1+ but not all drivers implement it correctly. MySQL's Connector/J ignores setSchema() — you have to execute USE database_tenant_123 as a raw SQL statement. Also, schema creation must be done outside the Hibernate session, because Hibernate's DDL auto-generation doesn't handle per-schema creation well.
I use Flyway with a custom callback that runs migrations for each schema. Here's the connection provider that uses schema switching.
Strategy 3: Discriminator Column (Shared Table)
This is the simplest strategy: all tenants share the same tables, and every row has a tenant_id column. You add a WHERE tenant_id = ? clause to every query. In Hibernate, you use a @Where annotation or a Hibernate filter to automatically append the condition.
When to use: Internal tools, multi-tenant admin panels, or scenarios where tenants are fully trusted (e.g., different departments in the same company). Never use this for external-facing SaaS where tenants are competitors.
The risk: One missing WHERE clause and Tenant A sees Tenant B's data. I've seen this happen in production because a developer used a native query and forgot to add the tenant filter. The fix is to enforce tenant filtering at the database level using Row-Level Security (RLS) in PostgreSQL.
How to implement: Create a Hibernate @FilterDef and @Filter on your entities. Enable the filter in your repository or service layer. For native queries, you're on your own — wrap them in a repository that appends the tenant condition.
Hibernate filters work, but they're not applied to @OneToMany or @ManyToMany collections by default unless you explicitly set @FilterJoinTable. Also, Hibernate filters don't work with JOIN FETCH in JPQL — you need to use @QueryHint or write the filter manually.
Here's the entity setup with Hibernate filter.
Handling Tenant Provisioning and Schema Migrations
When a new tenant signs up, you need to provision their storage — create a database, schema, or just insert a row. This is where most tutorials stop, but in production, this is where things break.
For database-per-tenant: You need to create a new database, run Flyway migrations against it, and register the datasource in your application. Do this asynchronously because creating a database and running migrations can take seconds. The tenant should see a 'provisioning' state until migrations complete.
For schema-per-tenant: Create the schema using CREATE SCHEMA IF NOT EXISTS tenant_123, then run Flyway migrations targeted at that schema. Flyway's schemas property can be set per migration.
For discriminator column: No provisioning needed — just insert the first record with the tenant's ID. But you still need to ensure the tenant exists in a tenants table for validation.
Here's a provisioning service that handles schema-per-tenant with Flyway. Note the use of a separate DataSource for migrations so you don't interfere with the main connection pool.
Testing Multi-Tenant Applications
Testing multi-tenancy is harder than you think. Unit tests with @DataJpaTest use a single in-memory database, so you can't test tenant isolation properly. Integration tests need to simulate multiple tenants hitting the same endpoints.
Unit tests: Mock the TenantContext to return a fixed tenant ID. Test that queries include the tenant filter. Use AssertJ to verify SQL logs.
Integration tests with Testcontainers: Spin up a PostgreSQL container, create two schemas (tenant_a, tenant_b), insert test data for both, then verify that Tenant A's API call doesn't return Tenant B's data. This catches the most common bug: missing tenant filter on a join.
Performance tests: Simulate 50 tenants with 10 concurrent requests each. Monitor connection pool usage and response times. If you see spikes when a new tenant is provisioned, your pre-warming logic is broken.
Here's a Testcontainers-based test that verifies tenant isolation for schema-per-tenant.
Production Debugging: Common Failures and Fixes
After years of running multi-tenant systems in production, here are the most common failures I've seen — and how to fix them fast.
1. Connection leaks: A developer forgets to close a JDBC connection in a native query. With database-per-tenant, each leak consumes a connection from that tenant's pool. Eventually, the pool is exhausted and that tenant's users see errors. Fix: use HikariCP's leakDetectionThreshold (set it to 5 seconds in development, 30 seconds in production). It logs a stack trace when a connection is held too long.
2. Schema drift: In schema-per-tenant, you run migrations for each schema. If one schema's migration fails and you don't retry, that tenant's schema becomes out of sync with the others. Fix: use Flyway's outOfOrder mode and run a daily reconciliation job that checks all schemas are at the same version.
3. ThreadLocal leaks in async code: When you use @Async or a TaskExecutor, the new thread doesn't inherit the ThreadLocal. The async method runs with a null tenant ID, which either fails or queries the default schema. Fix: use a custom TaskDecorator that copies the tenant ID from the parent thread to the worker thread.
Here's a TaskDecorator that propagates the tenant context to async threads.
SELECT 1. If a tenant's database is down, remove them from the routing table so the app doesn't hang.The Connection Pool That Ate Our Database
- Never create separate connection pools per tenant without a global cap.
- Monitor database connection counts in production — set alerts at 80% of max_connections.
- Use a connection pool that supports tenant routing, like HikariCP with a custom DataSource.
SELECT current_setting('app.tenant_id')SHOW VARIABLES LIKE 'app.tenant_id'| File | Command / Code | Purpose |
|---|---|---|
| TenantAwareEntity.java | @MappedSuperclass\npublic abstract class TenantAwareEntity {\n\n @Column(name... | Understanding Multi-Tenancy |
| TenantContextFilter.java | @Component\npublic class TenantContextFilter extends OncePerRequestFilter {\n\n ... | What the Official Docs Won't Tell You |
| TenantDataSourceProvider.java | @Component\npublic class TenantDataSourceProvider implements MultiTenantConnecti... | Strategy 1 |
| SchemaPerTenantConnectionProvider.java | @Component\npublic class SchemaPerTenantConnectionProvider implements MultiTenan... | Strategy 2 |
| Invoice.java | @Entity\n@Table(name = "invoices")\n@FilterDef(name = "tenantFilter",\n p... | Strategy 3 |
| TenantProvisioningService.java | @Service\npublic class TenantProvisioningService {\n\n private final DataSour... | Handling Tenant Provisioning and Schema Migrations |
| MultiTenantIsolationTest.java | @Testcontainers\n@SpringBootTest\nclass MultiTenantIsolationTest {\n\n @Conta... | Testing Multi-Tenant Applications |
| TenantAwareTaskDecorator.java | public class TenantAwareTaskDecorator implements TaskDecorator {\n\n @Overrid... | Production Debugging |
Key takeaways
Interview Questions on This Topic
Explain how ThreadLocal can cause data leaks in a multi-tenant Spring Boot app and how to prevent it.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't