Spring Boot Multi-Tenancy: Why Data Source Routing Is the Easy Part

This article walks through a production-ready Spring Boot multi-tenancy implementation using database-per-tenant isolation, JWT-based tenant identification via Spring Security, AbstractRoutingDataSource for dynamic routing, and ThreadLocal context management, while exposing critical pitfalls in transaction timing, async task propagation, and dynamic tenant registration.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Spring Boot Multi-Tenancy: Why Data Source Routing Is the Easy Part

From Per-Customer Deployment to Multi-Tenancy

Initially, deploying a separate Spring Boot instance and database per customer worked for a few clients. But as customer count grew, operational overhead exploded: dozens of identical services differing only in configuration, turning a single codebase into dozens of systems to maintain.

Tenant Identification Must Not Trust the Client

Many demos use a request header like X-Tenant-Id to determine the tenant. This is unsafe because a malicious user can change the header to access another tenant's data. Instead, embed tenant_id in a signed JWT issued by the authentication server. The business service reads the tenant ID only from the validated JwtAuthenticationToken after Spring Security verifies the signature.

{ "sub": "user-1001", "tenant_id": "acme", "scope": "order.read order.write", "iss": "https://auth.example.com" }

Database-Per-Tenant Isolation with AbstractRoutingDataSource

Each tenant gets a dedicated database (e.g., saas_acme, saas_globex) with identical schema. Spring's AbstractRoutingDataSource selects the DataSource at connection time based on a lookup key.

A TenantContext using ThreadLocal holds the current tenant ID. No default tenant is provided; missing tenant throws an exception to avoid cross-tenant data leaks.

public final class TenantContext {
    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
    private TenantContext() {}
    public static void set(String tenantId) { CURRENT.set(tenantId); }
    public static String getRequired() {
        String tenantId = CURRENT.get();
        if (tenantId == null) throw new IllegalStateException("Tenant not found");
        return tenantId;
    }
    public static void clear() { CURRENT.remove(); }
}

The routing data source simply returns the current tenant ID:

public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getRequired();
    }
}

Data sources are built from configuration properties, each wrapped in a Hikari pool. setLenientFallback(false) ensures unknown tenants fail fast rather than falling back to a default DataSource.

@Configuration
public class DataSourceConfig {
    @Bean @Primary
    DataSource dataSource(TenantProperties properties) {
        Map<Object, Object> targets = new HashMap<>();
        properties.tenants().forEach((tenantId, db) -> {
            HikariConfig config = new HikariConfig();
            config.setPoolName("tenant-" + tenantId);
            config.setJdbcUrl(db.url());
            config.setUsername(db.username());
            config.setPassword(db.password());
            config.setMaximumPoolSize(10);
            targets.put(tenantId, new HikariDataSource(config));
        });
        TenantRoutingDataSource routing = new TenantRoutingDataSource();
        routing.setTargetDataSources(targets);
        routing.setLenientFallback(false);
        routing.afterPropertiesSet();
        return routing;
    }
}

TenantContext Must Be Set Before Transaction Starts

Setting the tenant inside a @Transactional method is too late because Spring may acquire a database connection during transaction initialization, before the method body executes. The solution: set the tenant in a Spring Security filter after JWT validation but before any service or transaction code runs.

@Configuration @EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, TenantContextFilter tenantContextFilter) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        http.addFilterAfter(tenantContextFilter, BearerTokenAuthenticationFilter.class);
        return http.build();
    }
}

The TenantContextFilter extracts the tenant ID from the validated JWT, sets it in TenantContext, and clears it in a finally block to prevent thread-local leakage across reused Tomcat threads.

@Component
public class TenantContextFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!(authentication instanceof JwtAuthenticationToken token)) {
            filterChain.doFilter(request, response);
            return;
        }
        String tenantId = token.getToken().getClaimAsString("tenant_id");
        if (tenantId == null || tenantId.isBlank()) {
            response.sendError(HttpStatus.FORBIDDEN.value(), "Tenant is missing");
            return;
        }
        try {
            TenantContext.set(tenantId);
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

Business Code Remains Unaware of Multi-Tenancy

With this setup, repositories, services, and controllers contain no tenant logic. The repository uses a plain SQL query; the service calls the repository; the controller exposes the endpoint. ACME and Globex users hit the same GET /orders endpoint but are routed to their respective databases automatically.

@Repository
public class OrderRepository {
    private final JdbcClient jdbcClient;
    public OrderRepository(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; }
    public List<Order> findAll() {
        return jdbcClient.sql("""
            SELECT id, order_no, product_name, amount, created_at
            FROM orders ORDER BY id DESC
            """).query(Order.class).list();
    }
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; }
    @Transactional(readOnly = true)
    public List<Order> findAll() { return orderRepository.findAll(); }
}

@RestController @RequestMapping("/orders")
public class OrderController {
    private final OrderService orderService;
    public OrderController(OrderService orderService) { this.orderService = orderService; }
    @GetMapping
    public List<Order> findAll() { return orderService.findAll(); }
}

Async and Scheduled Tasks Require Explicit Tenant Propagation

@Async

tasks run on a different thread pool, so the ThreadLocal tenant context is lost. The tenant ID must be explicitly passed and set in the new thread, ideally via a TaskDecorator to avoid boilerplate.

public void submitReport(String tenantId) {
    executor.execute(() -> {
        try {
            TenantContext.set(tenantId);
            generateReport();
        } finally {
            TenantContext.clear();
        }
    });
}

Scheduled tasks have no HTTP request or JWT, so they must explicitly iterate over all tenants:

for (String tenantId : tenantRegistry.all()) {
    try {
        TenantContext.set(tenantId);
        syncOneTenant();
    } finally {
        TenantContext.clear();
    }
}

Dynamic Tenant Registry Replaces Static Configuration

Hardcoding tenant data sources in application.yml becomes impractical at scale. A tenant_registry table stores each tenant's JDBC URL, credentials (encrypted), and status. Onboarding a new tenant involves creating the database, running Flyway migrations, inserting a registry row, and dynamically registering the DataSource without restarting the application.

CREATE TABLE tenant_registry (
    tenant_id VARCHAR(64) PRIMARY KEY,
    jdbc_url VARCHAR(500) NOT NULL,
    db_username VARCHAR(128) NOT NULL,
    db_password_ciphertext VARCHAR(1000) NOT NULL,
    status VARCHAR(20) NOT NULL
);

As the system matures, the multi-tenancy logic is split into separate components: TenantResolver, TenantContext, TenantRegistry, and DataSourceRegistry, each with a single responsibility.

Conclusion

The real challenges in multi-tenancy are not the data source routing itself ( AbstractRoutingDataSource is trivial), but the surrounding concerns: secure tenant identification via validated JWT, ensuring context is set before transactions, cleaning up thread-locals, propagating context to async/scheduled tasks, and enabling dynamic tenant onboarding. Overlooking any of these can lead to cross-tenant data leaks — the kind of incident where a customer sees another company's order and asks, "Why is this order here?"

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootJWTThreadLocalMulti-tenancySpring SecurityAbstractRoutingDataSourceDatabase Per TenantSaaS Architecture
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.