Modern SaaS platforms are expected to serve multiple customers while maintaining strong data isolation, scalability, and cost efficiency. This is where Spring Boot Multitenancy becomes a core architectural concern. Choosing the right SaaS Database Architecture and implementing efficient Tenant Routing are critical decisions that directly affect performance, security, and maintainability.
This article provides a production-ready, enterprise-focused guide on implementing Spring Boot Multitenancy by schema and database using custom connection management. The solution is designed for software engineers working in companies who need a clean, extensible, and testable approach that can be deployed directly into real SaaS systems.
What Is Multitenancy in SaaS Applications
Multitenancy is a software architecture where a single application instance serves multiple tenants. Each tenant represents a customer or organization, and their data must be isolated from others.
Common SaaS database strategies include shared database with shared schema, shared database with separate schema, and separate database per tenant. This guide focuses on schema-based and database-based multitenancy, which are the most widely adopted approaches in enterprise SaaS platforms.
Why Use Spring Boot Multitenancy
Spring Boot provides seamless integration with Hibernate and JPA, clean abstractions for DataSource and transaction management, and strong testing support. These features make it an ideal framework for building scalable and secure SaaS Database Architecture with robust Tenant Routing.
Architecture Overview
The final architecture consists of a tenant context holder, request filter, tenant identifier resolver, custom routing DataSource, Hibernate multitenancy configuration, REST APIs, and automated test cases. Each incoming request dynamically routes database connections based on the tenant identifier.
Step 1: Create the Spring Boot Project
Create a new Spring Boot project using Maven with Java 17, Spring Web, Spring Data JPA, and H2 database. This setup mirrors enterprise usage while keeping the solution self-contained and executable.
Step 2: Add Maven Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Step 3: Define the Tenant Context
package com.example.multitenancy.tenant;
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
Step 4: Capture Tenant Information from Requests
package com.example.multitenancy.filter;
import com.example.multitenancy.tenant.TenantContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId != null && !tenantId.isEmpty()) {
TenantContext.setTenantId(tenantId);
}
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
Step 5: Implement Hibernate Tenant Identifier Resolver
package com.example.multitenancy.config;
import com.example.multitenancy.tenant.TenantContext;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
private static final String DEFAULT_TENANT = "tenant1";
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContext.getTenantId();
return tenant != null ? tenant : DEFAULT_TENANT;
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
Step 6: Configure Custom Routing DataSource
package com.example.multitenancy.config;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return com.example.multitenancy.tenant.TenantContext.getTenantId();
}
}
Step 7: Configure DataSources and EntityManager
package com.example.multitenancy.config;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class MultiTenantConfig {
@Bean
public DataSource dataSource() {
TenantRoutingDataSource dataSource = new TenantRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("tenant1",
org.springframework.boot.jdbc.DataSourceBuilder.create()
.url("jdbc:h2:mem:tenant1;DB_CLOSE_DELAY=-1")
.username("sa")
.password("")
.driverClassName("org.h2.Driver")
.build());
targetDataSources.put("tenant2",
org.springframework.boot.jdbc.DataSourceBuilder.create()
.url("jdbc:h2:mem:tenant2;DB_CLOSE_DELAY=-1")
.username("sa")
.password("")
.driverClassName("org.h2.Driver")
.build());
dataSource.setTargetDataSources(targetDataSources);
dataSource.setDefaultTargetDataSource(targetDataSources.get("tenant1"));
return dataSource;
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder,
DataSource dataSource,
TenantIdentifierResolver resolver) {
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.multiTenancy", "DATABASE");
properties.put("hibernate.tenant_identifier_resolver", resolver);
properties.put("hibernate.hbm2ddl.auto", "update");
return builder
.dataSource(dataSource)
.packages("com.example.multitenancy")
.properties(properties)
.build();
}
}
Step 8: Create a Sample Entity
package com.example.multitenancy.model;
import jakarta.persistence.*;
@Entity
public class Customer {
@Id
@GeneratedValue
private Long id;
private String name;
public Customer() {
}
public Customer(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
Step 9: Repository and REST Controller
package com.example.multitenancy.repository;
import com.example.multitenancy.model.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
package com.example.multitenancy.controller;
import com.example.multitenancy.model.Customer;
import com.example.multitenancy.repository.CustomerRepository;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerRepository repository;
public CustomerController(CustomerRepository repository) {
this.repository = repository;
}
@PostMapping
public Customer save(@RequestParam String name) {
return repository.save(new Customer(name));
}
@GetMapping
public List<Customer> findAll() {
return repository.findAll();
}
}
Step 10: How to Test the Application
Start the Spring Boot application and send HTTP requests with different tenant headers. For tenant1, use header X-Tenant-ID: tenant1 and create or fetch customers. Repeat the same for tenant2. Each tenant will see only its own data, proving correct Tenant Routing and SaaS Database Architecture isolation.
Step 11: Automated Test Case
package com.example.multitenancy;
import com.example.multitenancy.tenant.TenantContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class TenantContextTest {
@AfterEach
void cleanup() {
TenantContext.clear();
}
@Test
void testTenantContextStorage() {
TenantContext.setTenantId("tenant1");
assertThat(TenantContext.getTenantId()).isEqualTo("tenant1");
}
}
Conclusion
Implementing Spring Boot Multitenancy using schema or database strategies with custom connection management is a proven approach for building scalable SaaS platforms. This production-ready solution demonstrates clean architecture, reliable Tenant Routing, and a flexible SaaS Database Architecture that can be directly applied to enterprise Spring Boot applications.
