System Design

Multi-Tenancy Architecture: Database, Application, and Infrastructure Patterns

Production multi-tenancy: database isolation models (shared schema, shared database, separate database), tenant routing, data partitioning strategies, cross-tenant query prevention, Spring Boot tenant context propagation, and the trade-offs at each isolation level.

Sachin Sarawgi·May 24, 2025·8 min read
#multi-tenancy#saas#system design#database#spring boot#architecture#isolation

Multi-tenancy is the architecture pattern where a single deployed instance of a software system serves multiple customers (tenants), with each tenant's data logically or physically isolated from others. It's the foundation of SaaS products. The isolation model you choose is a fundamental architectural decision — it determines your security posture, operational complexity, cost structure, and scalability ceiling.

The Three Isolation Models

Model 1: Shared Schema (Row-Level Isolation)
┌────────────────────────────────────────────┐
│  Single database, single schema            │
│  Every table has a tenant_id column        │
│  tenant A rows: tenant_id='A'              │
│  tenant B rows: tenant_id='B'              │
│  All tenants share tables                  │
└────────────────────────────────────────────┘
Cost: Lowest    Security: Lowest    Scale: Highest

Model 2: Shared Database, Separate Schemas
┌────────────────────────────────────────────┐
│  Single database server                    │
│  Schema per tenant: tenant_a.orders        │
│                     tenant_b.orders        │
│  No shared tables (except system tables)   │
└────────────────────────────────────────────┘
Cost: Medium    Security: Medium    Scale: Medium

Model 3: Separate Database (Database per Tenant)
┌──────────┐   ┌──────────┐   ┌──────────┐
│ Tenant A │   │ Tenant B │   │ Tenant C │
│    DB    │   │    DB    │   │    DB    │
└──────────┘   └──────────┘   └──────────┘
Cost: Highest   Security: Highest  Scale: Limited

Most SaaS products start with shared schema (simplest) and migrate toward separate databases as they land larger enterprise customers who demand data isolation guarantees.

Model 1: Shared Schema with Row-Level Security

PostgreSQL Row-Level Security (RLS):

-- Enable RLS on every tenant-scoped table:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Create policy: users can only see rows matching their tenant_id
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- Set tenant context per connection/transaction:
SET app.current_tenant = '550e8400-e29b-41d4-a716-446655440000';

-- Now ALL queries on orders are automatically filtered:
SELECT * FROM orders;
-- Equivalent to: SELECT * FROM orders WHERE tenant_id = '550e8400-...'
-- Even if a query accidentally omits the tenant_id filter — RLS enforces it

RLS is a defense-in-depth layer. Even if application code has a bug that omits the tenant filter, RLS prevents cross-tenant data leakage at the database level.

Spring Boot implementation:

// Tenant context holder (thread-local):
public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();

    public static void setCurrentTenant(String tenantId) {
        currentTenant.set(tenantId);
    }

    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    public static void clear() {
        currentTenant.remove();
    }
}

// Interceptor: extract tenant from request and set context
@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) {
        // Option 1: Tenant from subdomain (acme.app.com → tenant=acme)
        String host = request.getServerName();
        String tenantId = host.split("\\.")[0];

        // Option 2: Tenant from JWT claim (more reliable)
        String jwt = extractJwt(request);
        String tenantId2 = jwtService.getTenantId(jwt);

        // Option 3: Tenant from request header (for internal APIs)
        String tenantId3 = request.getHeader("X-Tenant-ID");

        TenantContext.setCurrentTenant(tenantId2);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        TenantContext.clear();  // CRITICAL: clean up thread-local or risk tenant leakage
    }
}

// Hibernate multi-tenancy — pass tenant_id to every query automatically:
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenantId = TenantContext.getCurrentTenant();
        return tenantId != null ? tenantId : "default";
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

// Entity: tenant_id on every table:
@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {

    @Id
    private UUID id;

    @Column(name = "tenant_id", nullable = false, updatable = false)
    private UUID tenantId;

    // ... other fields
}

The critical risk in shared schema: accidental cross-tenant queries

// VULNERABLE: Developer forgets to add tenant filter
public List<Order> findRecentOrders() {
    return em.createQuery("FROM Order WHERE created_at > :date", Order.class)
        .setParameter("date", LocalDate.now().minusDays(7))
        .getResultList();
    // Returns orders from ALL tenants — data breach
}

// SAFE: Always include tenant_id
public List<Order> findRecentOrders() {
    String tenantId = TenantContext.getCurrentTenant();
    return em.createQuery(
        "FROM Order WHERE tenant_id = :tenantId AND created_at > :date", Order.class)
        .setParameter("tenantId", UUID.fromString(tenantId))
        .setParameter("date", LocalDate.now().minusDays(7))
        .getResultList();
}

// SAFER: Use Hibernate @Filter applied globally (can't forget it):
// Session.enableFilter("tenantFilter").setParameter("tenantId", tenantId)
// This adds the filter condition to ALL queries for the session

Model 2: Schema-Per-Tenant

// Hibernate connection pool routing:
@Configuration
public class MultiTenantDataSourceConfig implements MultiTenantConnectionProvider {

    @Autowired
    private DataSource dataSource;

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        Connection connection = dataSource.getConnection();
        // Switch schema to tenant's schema:
        connection.createStatement().execute(
            "SET search_path TO tenant_" + sanitize(tenantIdentifier) + ", public"
        );
        return connection;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
        connection.createStatement().execute("SET search_path TO public");
        connection.close();
    }

    private String sanitize(String tenantId) {
        // CRITICAL: Sanitize to prevent SQL injection via tenant ID
        if (!tenantId.matches("^[a-zA-Z0-9_-]+$")) {
            throw new SecurityException("Invalid tenant ID");
        }
        return tenantId;
    }
}

// Schema provisioning (when a new tenant signs up):
@Service
public class TenantProvisioningService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional
    public void provisionTenant(String tenantId) {
        String schema = "tenant_" + sanitize(tenantId);

        // Create schema:
        jdbcTemplate.execute("CREATE SCHEMA IF NOT EXISTS " + schema);

        // Run migrations for this schema:
        Flyway.configure()
            .dataSource(dataSource)
            .schemas(schema)
            .locations("classpath:db/migration")
            .load()
            .migrate();

        log.info("Provisioned schema for tenant: {}", tenantId);
    }
}

Schema-per-tenant allows schema customization per tenant (enterprise customers often want custom fields). However, running migrations across thousands of schemas becomes a management challenge — a migration that takes 1 second per schema takes 17 minutes across 1,000 tenants.

Model 3: Database-Per-Tenant with Dynamic Routing

// Connection pool per tenant (HikariCP):
@Service
public class TenantDataSourceService {

    private final Map<String, DataSource> dataSources = new ConcurrentHashMap<>();
    private final TenantConfigRepository tenantConfigRepository;

    public DataSource getDataSource(String tenantId) {
        return dataSources.computeIfAbsent(tenantId, this::createDataSource);
    }

    private DataSource createDataSource(String tenantId) {
        TenantConfig config = tenantConfigRepository.findById(tenantId)
            .orElseThrow(() -> new TenantNotFoundException(tenantId));

        HikariConfig hikariConfig = new HikariConfig();
        hikariConfig.setJdbcUrl(config.getDatabaseUrl());
        hikariConfig.setUsername(config.getDbUser());
        hikariConfig.setPassword(config.getDbPassword());
        hikariConfig.setMaximumPoolSize(5);     // Small pool per tenant
        hikariConfig.setMinimumIdle(1);
        hikariConfig.setConnectionTimeout(5000);
        hikariConfig.setPoolName("tenant-" + tenantId);

        return new HikariDataSource(hikariConfig);
    }
}

// Spring AbstractRoutingDataSource:
@Component
public class TenantAwareDataSource extends AbstractRoutingDataSource {

    @Autowired
    private TenantDataSourceService tenantDataSourceService;

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }

    @Override
    protected DataSource determineTargetDataSource() {
        String tenantId = TenantContext.getCurrentTenant();
        return tenantDataSourceService.getDataSource(tenantId);
    }
}

Database-per-tenant operational challenges:

  • 1,000 tenants = 1,000 database connection pools = potential for many idle connections
  • Schema migrations must run against all tenant databases (usually via a migration runner that loops over all tenants)
  • Monitoring 1,000 separate databases requires aggregated observability
  • Cost scales linearly with tenant count (no sharing)

Tenant Onboarding and Lifecycle

@Service
public class TenantLifecycleService {

    // Asynchronous provisioning (tenant creation shouldn't block the signup response):
    @Async
    public CompletableFuture<Void> provisionNewTenant(TenantSignupRequest request) {
        String tenantId = UUID.randomUUID().toString();

        // 1. Create database record for tenant:
        Tenant tenant = tenantRepository.save(new Tenant(tenantId, request.getCompanyName()));

        // 2. Provision infrastructure (database/schema):
        tenantProvisioningService.provision(tenantId);

        // 3. Seed default data (roles, settings, sample data):
        tenantSeedingService.seedDefaults(tenantId);

        // 4. Send welcome email:
        emailService.sendWelcome(tenant, request.getAdminEmail());

        // 5. Update provisioning status:
        tenant.setStatus(TenantStatus.ACTIVE);
        tenantRepository.save(tenant);

        return CompletableFuture.completedFuture(null);
    }

    @Transactional
    public void suspendTenant(String tenantId) {
        Tenant tenant = tenantRepository.findById(tenantId)
            .orElseThrow(() -> new TenantNotFoundException(tenantId));
        tenant.setStatus(TenantStatus.SUSPENDED);
        tenantRepository.save(tenant);

        // Revoke active sessions:
        sessionService.revokeAllForTenant(tenantId);
    }
}

Cross-Tenant Analytics (Admin Queries)

Admin queries (aggregate statistics across all tenants) require bypassing tenant isolation:

// Dedicated admin data source — separate connection with elevated permissions:
@Configuration
public class AdminDataSourceConfig {

    @Bean("adminDataSource")
    public DataSource adminDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(adminDbUrl);
        config.setUsername(adminDbUser);
        config.setPassword(adminDbPassword);
        return new HikariDataSource(config);
    }
}

@Repository
public class AdminAnalyticsRepository {

    @Autowired
    @Qualifier("adminDataSource")
    private DataSource adminDataSource;

    // Admin-only: revenue across all tenants
    public List<TenantRevenue> getRevenueByTenant(LocalDate startDate) {
        // In shared schema: query without tenant filter
        // In schema-per-tenant: UNION across all schemas
        // In db-per-tenant: federated query or aggregated via ETL pipeline
    }
}

// Protect with role-based access:
@PreAuthorize("hasRole('PLATFORM_ADMIN')")
@GetMapping("/admin/analytics/revenue")
public ResponseEntity<List<TenantRevenue>> getRevenue(@RequestParam LocalDate startDate) {
    return ResponseEntity.ok(adminAnalyticsRepository.getRevenueByTenant(startDate));
}

Choosing the Right Model

Factor Shared Schema Schema-per-Tenant DB-per-Tenant
Setup complexity Low Medium High
Per-tenant cost Lowest Low High
Data isolation Logical only Stronger Strongest
Enterprise compliance Difficult Possible Easy
Schema customization Hard Possible Easy
Migration complexity Low Medium High (per-DB)
Max tenant scale 100,000+ 10,000 ~1,000

Start with shared schema unless your target customers have strict data residency requirements on day one. Add schema-per-tenant for enterprise tiers where compliance demands it. Reserve database-per-tenant for your largest, highest-paying customers who need dedicated infrastructure guarantees.

The multi-tenancy model shapes every subsequent architectural decision — data backup, disaster recovery, schema evolution, performance isolation, and compliance reporting. Make this choice deliberately, with full awareness of where you want to be in 3-5 years, not just what's easiest to build today.

📚

Recommended Resources

System Design Interview — Alex XuBest Seller

Step-by-step guide to ace system design interviews with real-world examples.

View on Amazon
Grokking System Design on Educative

Interactive course teaching system design with visual diagrams and practice problems.

View Course
Designing Data-Intensive Applications

Martin Kleppmann's book is essential reading for any system design role.

View on Amazon

Found this useful? Share it: