Skip to main content

Command Palette

Search for a command to run...

Multi-Tenancy Architecture in Spring Boot

Published
4 min read

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

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

  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

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

  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

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

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:

./gradlew test