Spring Boot’s multi-tenancy, especially with schema and database strategies, is far more complex than just telling your app to talk to different databases.

Let’s see it in action. Imagine a simple TenantAwareDataSource that dynamically switches connection details based on a TenantIdentifier from the request context.

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

public class MultiTenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant(); // Assumes TenantContext handles thread-local storage
    }

    // In a real app, this would be populated dynamically or via configuration
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
    }
}

// TenantContext (simplified)
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 clearTenant() {
        currentTenant.remove();
    }
}

And here’s how you’d configure it in Spring Boot, pointing to different databases or schemas:

import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.lookup.DataSourceLookup;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("app.datasource.default")
    public DataSourceProperties defaultDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    public DataSource defaultDataSource(DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.tenant1")
    public DataSourceProperties tenant1DataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    public DataSource tenant1DataSource(DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.tenant2")
    public DataSourceProperties tenant2DataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    public DataSource tenant2DataSource(DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }

    @Bean
    public DataSource multiTenantDataSource(
        DataSource defaultDataSource,
        DataSource tenant1DataSource,
        DataSource tenant2DataSource
    ) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("default", defaultDataSource); // For shared data or initial setup
        targetDataSources.put("tenant1", tenant1DataSource);
        targetDataSources.put("tenant2", tenant2DataSource);

        MultiTenantRoutingDataSource routingDataSource = new MultiTenantRoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(defaultDataSource); // Fallback
        routingDataSource.afterPropertiesSet();
        return routingDataSource;
    }

    // You'll need a mechanism to set the TenantContext, e.g., in a Filter or Interceptor
    // Example for a Servlet Filter:
    // @Bean
    // public Filter tenantFilter() {
    //     return new OncePerRequestFilter() {
    //         @Override
    //         protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    //             String tenantId = request.getHeader("X-Tenant-ID"); // Or derive from URL, subdomain, etc.
    //             if (tenantId != null) {
    //                 TenantContext.setCurrentTenant(tenantId);
    //             } else {
    //                 TenantContext.setCurrentTenant("default"); // Or throw an error
    //             }
    //             try {
    //                 filterChain.doFilter(request, response);
    //             } finally {
    //                 TenantContext.clearTenant();
    //             }
    //         }
    //     };
    // }
}

This setup allows you to define distinct DataSource beans for each tenant (or a default one) and then use AbstractRoutingDataSource to pick the correct one based on a TenantContext. The TenantContext is crucial; it’s where you’ll store the current tenant identifier, typically using ThreadLocal, so that any part of your application (repositories, services) can access it without explicit passing.

The core problem this solves is isolating tenant data. Without it, all tenants would share the same database tables, leading to data leakage and complex query logic to filter by tenant_id. With database-per-tenant, each tenant gets its own entirely separate database instance. With schema-per-tenant (often within the same database instance), each tenant gets its own set of tables prefixed or namespaced within a dedicated schema.

Internally, when a request comes in, a filter or interceptor extracts the tenant identifier. This identifier is then set into the TenantContext. When Spring Data JPA or JDBC starts a query, the AbstractRoutingDataSource consults TenantContext to determine which underlying DataSource to use for that specific thread. This means your JPA entities and repositories don’t need to know about multi-tenancy; they just operate as if they are talking to a single, dedicated database.

The exact levers you control are:

  1. Tenant Identification Strategy: How do you know which tenant is making the request? This could be a subdomain (tenant1.myapp.com), a request header (X-Tenant-ID: tenant1), a JWT claim, or even part of the URL path (/tenant1/api/resource).
  2. DataSource Routing: Using AbstractRoutingDataSource or a custom implementation to select the correct DataSource bean.
  3. Tenant Data Storage: Database-per-tenant (separate DB instances), schema-per-tenant (separate schemas in one DB instance), or column-per-tenant (a tenant_id column in every table). The example above primarily illustrates database/schema-per-tenant.
  4. Tenant Provisioning/Deprovisioning: How do you create new tenant databases/schemas and clean them up? This often involves Liquibase or Flyway, potentially with custom extensions to manage multiple contexts.
  5. Hibernate/JPA Integration: Ensuring that Hibernate’s session factory is aware of the multi-tenancy strategy if you’re using schema-per-tenant or database-per-tenant and need separate SessionFactory instances or configurations.

A common, often overlooked, detail is managing the TenantContext lifecycle. If you’re using asynchronous processing (e.g., @Async methods, CompletableFuture, message queues), the ThreadLocal in TenantContext won’t automatically propagate. You need to explicitly pass the tenant identifier to the asynchronous task or use libraries like TransmittableThreadLocal to ensure the context is carried over.

The next hurdle is managing migrations and schema evolution across all these tenant data stores.

Want structured learning?

Take the full Spring-boot course →