# 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

1.  Add `tenant_id` to tenant-owned tables.
    
2.  Create composite indexes with `tenant_id` as the first column.
    
3.  Resolve current tenant from request context (header/JWT/subdomain).
    
4.  Enforce tenant filters in repository/service layer.
    
5.  Add tests that prove tenant A cannot read tenant B data.
    

### Example schema

```sql
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

```java
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

1.  Provision one schema per tenant.
    
2.  Keep schema structure identical across tenants.
    
3.  Switch schema per request using `SET search_path`.
    
4.  Run migrations for each tenant schema.
    
5.  Add tests that verify data in schema A is invisible from schema B.
    

### Example provisioning

```sql
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

```java
// 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

1.  Create database per tenant (`orders_tenant_a`, `orders_tenant_b`, ...).
    
2.  Maintain a tenant registry mapping `tenant_id -> JDBC URL/credentials`.
    
3.  Route at `DataSource` layer (e.g., `AbstractRoutingDataSource`).
    
4.  Apply migrations per tenant database.
    
5.  Add tests proving each database contains only its own data.
    

### Spring setup pattern

```java
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

1.  Keep explicit `tenant_id` filtering in app queries.
    
2.  Add RLS policy in PostgreSQL.
    
3.  Set tenant context in DB session (`SET app.tenant_id = ...`).
    
4.  Restrict direct table access to non-admin roles used by app.
    
5.  Test both normal flow and bypass attempts.
    

### Example PostgreSQL RLS

```sql
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:

*   `SharedSchemaIsolationIntegrationTest`
    
*   `SchemaPerTenantIsolationIntegrationTest`
    
*   `DatabasePerTenantIsolationIntegrationTest`
    
*   `HybridIsolationIntegrationTest`
    

They run against PostgreSQL via Testcontainers and can be executed with:

```bash
./gradlew test
```
