Multi-Tenancy Architecture in Spring Boot
This guide explains the four most common multi-tenancy approaches, when to use each, and how to set each one up in a Spring Boot + PostgreSQL system.
Git link: https://github.com/mnafshin/multi-tenancy
Quick Comparison
| Approach | Isolation Level | Complexity | Best For |
|---|---|---|---|
Shared database, shared schema (tenant_id column) |
Logical | Low | Fastest start, many small tenants |
| Shared database, schema per tenant | Stronger logical/namespace | Medium | Moderate tenant count with stricter separation |
| Database per tenant | Physical | High | Compliance-heavy or premium isolation |
| Hybrid (app filter + DB enforcement) | Defense in depth | Medium-High | Teams that want layered protection |
1) Shared Database, Shared Schema
How it works
All tenants use the same tables. Every tenant-scoped row carries a tenant_id. The application always filters by tenant_id.
Setup
Add
tenant_idto tenant-owned tables.Create composite indexes with
tenant_idas the first column.Resolve current tenant from request context (header/JWT/subdomain).
Enforce tenant filters in repository/service layer.
Add tests that prove tenant A cannot read tenant B data.
Example schema
CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
customer_id TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_tenant_status ON orders (tenant_id, status);
Spring setup pattern
public List<Order> listOrders(String tenantId, String status) {
return orderRepository.findByTenantIdAndStatus(tenantId, status);
}
Notes
Easiest to implement and operate.
Biggest risk is developer mistakes (missing tenant filter in one query).
2) Shared Database, Schema per Tenant
How it works
All tenants share one PostgreSQL instance, but each tenant has its own schema (tenant_a.orders, tenant_b.orders, ...).
Setup
Provision one schema per tenant.
Keep schema structure identical across tenants.
Switch schema per request using
SET search_path.Run migrations for each tenant schema.
Add tests that verify data in schema A is invisible from schema B.
Example provisioning
CREATE SCHEMA tenant_a;
CREATE SCHEMA tenant_b;
CREATE TABLE tenant_a.orders (
id UUID PRIMARY KEY,
customer_id TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL
);
CREATE TABLE tenant_b.orders (
id UUID PRIMARY KEY,
customer_id TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL
);
Spring setup pattern
// Pseudocode before each request/transaction
jdbcTemplate.execute("SET search_path TO " + tenantSchema + ", public");
Notes
Better isolation than shared-schema.
Migrations and onboarding/offboarding tenants are more operationally complex.
3) Database per Tenant
How it works
Each tenant gets an independent database (or server). Application routes connections by tenant id.
Setup
Create database per tenant (
orders_tenant_a,orders_tenant_b, ...).Maintain a tenant registry mapping
tenant_id -> JDBC URL/credentials.Route at
DataSourcelayer (e.g.,AbstractRoutingDataSource).Apply migrations per tenant database.
Add tests proving each database contains only its own data.
Spring setup pattern
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getTenantId();
}
}
Notes
Strongest isolation and easiest compliance narrative.
Highest cost and operational overhead (pooling, backups, migrations per DB).
4) Hybrid: Application Filtering + Database Enforcement
How it works
Use app-level tenant checks and repository filtering, plus DB-level enforcement (for example PostgreSQL RLS). This reduces the blast radius of one-layer mistakes.
Setup
Keep explicit
tenant_idfiltering in app queries.Add RLS policy in PostgreSQL.
Set tenant context in DB session (
SET app.tenant_id = ...).Restrict direct table access to non-admin roles used by app.
Test both normal flow and bypass attempts.
Example PostgreSQL RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_orders ON orders
USING (tenant_id = current_setting('app.tenant_id')::text);
Notes
Most secure in practice for shared infrastructure.
Requires disciplined session handling and more observability.
Choosing an Approach
Start with shared schema if you need speed and have strong test/lint guardrails.
Move to schema per tenant when you need stronger isolation without full DB sprawl.
Use database per tenant for strict compliance, noisy-neighbor protection, or premium tenants.
Use hybrid when you want defense-in-depth while keeping shared infrastructure economics.
Integration Test Coverage in This Repository
The following integration tests validate isolation behavior for each model:
SharedSchemaIsolationIntegrationTestSchemaPerTenantIsolationIntegrationTestDatabasePerTenantIsolationIntegrationTestHybridIsolationIntegrationTest
They run against PostgreSQL via Testcontainers and can be executed with:
./gradlew test