How to Build a Multi‑Tenant SaaS Application with Spring Boot and Dynamic Data Sources
This tutorial walks through designing and implementing a Spring Boot‑based SaaS platform that supports multiple tenants by dynamically switching databases or schemas, covering architecture overview, tenant identification, data‑source routing, Maven setup, entity and repository definitions, interceptor configuration, and a simple login test.
1. Overview
The author has been working with SaaS (Software as a Service) and multi‑tenant architectures since 2014, initially using OSGi for a financial management platform. Over five years the technology stack evolved, and many readers now ask how to implement a multi‑tenant system with Spring Boot, so this article shares the core technical implementation.
2. Understanding Multi‑Tenant Scenarios
When a single application must be sold to N customers, the naive approach creates N web servers and N databases, leading to massive maintenance overhead. A multi‑tenant design lets the same application serve many customers by routing requests to the appropriate database based on the tenant identity, but raises the question of how to identify the tenant and switch data sources automatically.
3. Maintaining, Identifying, and Routing Tenant Data Sources
A dedicated table stores tenant information such as database name, URL, username and password. Common tenant‑identification methods include:
Using a unique sub‑domain (e.g., tenantone.example.com) to extract the tenant key.
Passing a tenantId parameter in the request URL (e.g., ?tenantId=tenant1).
Including tenant information in request headers, such as a JWT claim.
Storing the tenant identifier in the user session after login.
After obtaining the tenant identifier, the application must retrieve the corresponding data‑source configuration and set it for the current request.
4. Project Build
The demo uses Spring Boot 2.1.5. Add the following dependencies to pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
</dependencies>Add the following configuration to application.yml (only the relevant part is shown):
spring:
freemarker:
cache: false
template-loader-path:
- classpath:/templates/
suffix: .html
resources:
static-locations:
- classpath:/static/
devtools:
restart:
enabled: true
jpa:
database: mysql
show-sql: true
generate-ddl: false
hibernate:
ddl-auto: none
una:
master:
datasource:
url: jdbc:mysql://localhost:3306/master_tenant?useSSL=false
username: root
password: root
driverClassName: com.mysql.jdbc.Driver
maxPoolSize: 10
idleTimeout: 300000
minIdle: 10
poolName: master-database-connection-pool
logging:
level:
root: warn
org.springframework.web: debug
org.hibernate: debugBecause Freemarker is used as the view engine, its related configuration must be provided. The una.master.datasource block stores the master database connection information.
5. Implement Tenant Data‑Source Query Module
Define an entity that stores tenant data‑source details:
@Data
@Entity
@Table(name = "MASTER_TENANT")
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MasterTenant implements Serializable {
@Id
@Column(name="ID")
private String id;
@Column(name = "TENANT")
@NotEmpty(message = "Tenant identifier must be provided")
private String tenant;
@Column(name = "URL")
@Size(max = 256)
@NotEmpty(message = "Tenant jdbc url must be provided")
private String url;
@Column(name = "USERNAME")
@Size(min = 4, max = 30, message = "db username length must between 4 and 30")
@NotEmpty(message = "Tenant db username must be provided")
private String username;
@Column(name = "PASSWORD")
@Size(min = 4, max = 30)
@NotEmpty(message = "Tenant db password must be provided")
private String password;
@Version
private int version = 0;
}Create a JPA repository to access the master tenant table:
package com.ramostear.una.saas.master.repository;
import com.ramostear.una.saas.master.model.MasterTenant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface MasterTenantRepository extends JpaRepository<MasterTenant, String> {
@Query("select p from MasterTenant p where p.tenant = :tenant")
MasterTenant findByTenant(@Param("tenant") String tenant);
}Service interface for tenant lookup:
package com.ramostear.una.saas.master.service;
import com.ramostear.una.saas.master.model.MasterTenant;
public interface MasterTenantService {
/**
* Using custom tenant name query
* @param tenant tenant name
* @return masterTenant
*/
MasterTenant findByTenant(String tenant);
}6. Implement Tenant Business Module
Define a user entity for the tenant database:
@Entity
@Table(name = "USER")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements Serializable {
private static final long serialVersionUID = -156890917814957041L;
@Id
@Column(name = "ID")
private String id;
@Column(name = "USERNAME")
private String username;
@Column(name = "PASSWORD")
@Size(min = 6, max = 22, message = "User password must be provided and length between 6 and 22.")
private String password;
@Column(name = "TENANT")
private String tenant;
}Repository for user CRUD:
@Repository
public interface UserRepository extends JpaRepository<User, String>, JpaSpecificationExecutor<User> {
User findByUsername(String username);
}Service implementation (uses a Twitter‑style snowflake ID generator):
@Service("userService")
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
private static TwitterIdentifier identifier = new TwitterIdentifier();
@Override
public void save(User user) {
user.setId(identifier.generalIdentifier());
user.setTenant(TenantContextHolder.getTenant());
userRepository.save(user);
}
@Override
public User findById(String userId) {
return userRepository.findById(userId).orElse(null);
}
@Override
public User findByUsername(String username) {
System.out.println(TenantContextHolder.getTenant());
return userRepository.findByUsername(username);
}
}The Twitter snowflake algorithm is used to generate unique IDs.
7. Configure Interceptor
Interceptor extracts the tenant identifier from request parameters and stores it in a thread‑local holder. If the tenant is missing, the request is redirected to /login.html:
/**
* @author Tan Chaohong (ramostear)
*/
@Slf4j
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenant = request.getParameter("tenant");
if (StringUtils.isBlank(tenant)) {
response.sendRedirect("/login.html");
return false;
} else {
TenantContextHolder.setTenant(tenant);
return true;
}
}
}
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TenantInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login.html");
super.addInterceptors(registry);
}
}/login.html is excluded from interception so that users can reach the login page.
8. Maintain Tenant Identifier Information
A simple ThreadLocal holder stores, retrieves, and clears the current tenant identifier:
public class TenantContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenant(String tenant) { CONTEXT.set(tenant); }
public static String getTenant() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}This class is the key to dynamic data‑source switching.
9. Dynamic Data‑Source Switching
Resolver that reads the tenant identifier from TenantContextHolder and supplies a default when none is found:
package com.ramostear.una.saas.tenant.config;
import com.ramostear.una.saas.context.TenantContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
private static final String DEFAULT_TENANT = "tenant_1";
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContextHolder.getTenant();
return StringUtils.isNotBlank(tenant) ? tenant : DEFAULT_TENANT;
}
@Override
public boolean validateExistingCurrentSessions() { return true; }
}Connection provider that loads tenant data‑source information from the master tenant table and caches it in a map:
@Slf4j
@Configuration
public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
private static final long serialVersionUID = -7522287771874314380L;
@Autowired
private MasterTenantRepository masterTenantRepository;
private Map<String, DataSource> dataSources = new TreeMap<>();
@Override
protected DataSource selectAnyDataSource() {
if (dataSources.isEmpty()) {
masterTenantRepository.findAll().forEach(mt ->
dataSources.put(mt.getTenant(), DataSourceUtils.wrapperDataSource(mt)));
}
return dataSources.values().iterator().next();
}
@Override
protected DataSource selectDataSource(String tenant) {
if (!dataSources.containsKey(tenant)) {
masterTenantRepository.findAll().forEach(mt ->
dataSources.put(mt.getTenant(), DataSourceUtils.wrapperDataSource(mt)));
}
return dataSources.get(tenant);
}
}10. Tenant Data‑Source Configuration
The tenant module defines its own JPA vendor adapter, transaction manager, multi‑tenant connection provider, tenant identifier resolver, and entity manager factory. The configuration mirrors the master data‑source setup but injects the multi‑tenant components:
@Slf4j
@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = {"com.ramostear.una.saas.tenant.model", "com.ramostear.una.saas.tenant.repository"})
@EnableJpaRepositories(
basePackages = {"com.ramostear.una.saas.tenant.repository", "com.ramostear.una.saas.tenant.service"},
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager"
)
public class TenantDataSourceConfig {
@Bean("jpaVendorAdapter")
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Bean(name = "tenantTransactionManager")
public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager tm = new JpaTransactionManager();
tm.setEntityManagerFactory(entityManagerFactory);
return tm;
}
@Bean(name = "datasourceBasedMultiTenantConnectionProvider")
@ConditionalOnBean(name = "masterEntityManagerFactory")
public MultiTenantConnectionProvider multiTenantConnectionProvider() {
return new DataSourceBasedMultiTenantConnectionProviderImpl();
}
@Bean(name = "currentTenantIdentifierResolver")
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
return new CurrentTenantIdentifierResolverImpl();
}
@Bean(name = "tenantEntityManagerFactory")
@ConditionalOnBean(name = "datasourceBasedMultiTenantConnectionProvider")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
@Qualifier("datasourceBasedMultiTenantConnectionProvider") MultiTenantConnectionProvider connectionProvider,
@Qualifier("currentTenantIdentifierResolver") CurrentTenantIdentifierResolver tenantIdentifierResolver) {
LocalContainerEntityManagerFactoryBean bean = new LocalContainerEntityManagerFactoryBean();
bean.setPackagesToScan(new String[]{
User.class.getPackage().getName(),
UserRepository.class.getPackage().getName(),
UserService.class.getPackage().getName()
});
bean.setJpaVendorAdapter(jpaVendorAdapter());
bean.setPersistenceUnitName("tenant-database-persistence-unit");
Map<String, Object> props = new HashMap<>();
props.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
props.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, connectionProvider);
props.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifierResolver);
props.put(Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
props.put(Environment.SHOW_SQL, true);
props.put(Environment.FORMAT_SQL, true);
props.put(Environment.HBM2DDL_AUTO, "update");
bean.setJpaPropertyMap(props);
return bean;
}
}The only difference from the master configuration is the injection of the tenant identifier resolver and the multi‑tenant connection provider, which tell Hibernate how to obtain the correct connection before executing SQL.
11. Application Test
A simple login controller demonstrates the multi‑tenant behavior. It does not use encryption or a security framework; it only validates username/password against the tenant‑specific user table.
/**
* @author Tan Chaohong (ramostear)
*/
@Controller
public class LoginController {
@Autowired
private UserService userService;
@GetMapping("/login.html")
public String login() {
return "/login";
}
@PostMapping("/login")
public String login(@RequestParam(name = "username") String username,
@RequestParam(name = "password") String password,
ModelMap model) {
System.out.println("tenant:" + TenantContextHolder.getTenant());
User user = userService.findByUsername(username);
if (user != null && user.getPassword().equals(password)) {
model.put("user", user);
return "/index";
}
return "/login";
}
}Before running the application, create the master database and its MASTER_TENANT table, then create separate databases (or schemas) for each tenant with a USER table. Start the application and open http://localhost:8080/login.html. Provide the tenant name, username, and password to verify that the request is routed to the correct tenant database. Add more tenants and users to test isolation.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
ITFLY8 Architecture Home
ITFLY8 Architecture Home - focused on architecture knowledge sharing and exchange, covering project management and product design. Includes large-scale distributed website architecture (high performance, high availability, caching, message queues...), design patterns, architecture patterns, big data, project management (SCRUM, PMP, Prince2), product design, and more.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
