Build a Multi‑Tenant SaaS with Spring Boot: Dynamic DataSource & Schema Switching
This tutorial walks through creating a SaaS application with Spring Boot that supports multiple tenants by using independent or shared databases with separate schemas, covering tenant identification, dynamic DataSource routing, Hibernate multi‑tenancy configuration, and a complete code example including Maven setup, entity definitions, interceptors, and a login test.
1. Overview
The author, who has been working on SaaS platforms since 2014, shares hard‑core technical details on building a SaaS system with Spring Boot, focusing on the implementation rather than business operations.
2. SaaS Application Scenarios
Deploying the same application for multiple customers normally requires separate servers and databases for each tenant, leading to high maintenance costs. Multi‑tenant architecture solves this by routing requests to the appropriate database based on the tenant identifier.
3. Tenant Identification and Routing
A dedicated table stores tenant information such as database name, URL, username, and password. Common identification methods include sub‑domains, request parameters, request headers (e.g., JWT), and session storage.
4. Project Construction
Add the following Maven dependencies to enable Spring Boot, Lombok, JPA, Freemarker, and MySQL support:
<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-freemarker</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 Spring configuration (application.yml) to define the master datasource and logging settings.
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: debugFreemarker is used as the view rendering engine; the una.master.datasource configuration stores tenant information in the master database.
5. Master DataSource Configuration
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class UnaSaasApplication {
public static void main(String[] args) {
SpringApplication.run(UnaSaasApplication.class, args);
}
}The project structure is illustrated below:
6. Tenant DataSource Entity
@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;
}The repository extends JpaRepository and provides a method to find a tenant by its name.
public interface MasterTenantRepository extends JpaRepository<MasterTenant, String> {
@Query("select p from MasterTenant p where p.tenant = :tenant")
MasterTenant findByTenant(@Param("tenant") String tenant);
}7. Tenant Service
public interface MasterTenantService {
MasterTenant findByTenant(String tenant);
}8. Master DataSource Properties
@Getter
@Setter
@Configuration
@ConfigurationProperties("una.master.datasource")
public class MasterDatabaseProperties {
private String url;
private String password;
private String username;
private String driverClassName;
private long connectionTimeout;
private int maxPoolSize;
private long idleTimeout;
private int minIdle;
private String poolName;
@Override
public String toString() {
return "MasterDatabaseProperties [url=" + url + ", username=" + username + ", password=" + password + ", driverClassName=" + driverClassName + ", connectionTimeout=" + connectionTimeout + ", maxPoolSize=" + maxPoolSize + ", idleTimeout=" + idleTimeout + ", minIdle=" + minIdle + ", poolName=" + poolName + "]";
}
}9. Master DataSource Bean
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = {"com.ramostear.una.saas.master.model", "com.ramostear.una.saas.master.repository"},
entityManagerFactoryRef = "masterEntityManagerFactory",
transactionManagerRef = "masterTransactionManager")
@Slf4j
public class MasterDatabaseConfig {
@Autowired
private MasterDatabaseProperties masterDatabaseProperties;
@Bean(name = "masterDatasource")
public DataSource masterDatasource(){
log.info("Setting up masterDatasource with :{}", masterDatabaseProperties.toString());
HikariDataSource datasource = new HikariDataSource();
datasource.setUsername(masterDatabaseProperties.getUsername());
datasource.setPassword(masterDatabaseProperties.getPassword());
datasource.setJdbcUrl(masterDatabaseProperties.getUrl());
datasource.setDriverClassName(masterDatabaseProperties.getDriverClassName());
datasource.setPoolName(masterDatabaseProperties.getPoolName());
datasource.setMaximumPoolSize(masterDatabaseProperties.getMaxPoolSize());
datasource.setMinimumIdle(masterDatabaseProperties.getMinIdle());
datasource.setConnectionTimeout(masterDatabaseProperties.getConnectionTimeout());
datasource.setIdleTimeout(masterDatabaseProperties.getIdleTimeout());
log.info("Setup of masterDatasource successfully.");
return datasource;
}
@Primary
@Bean(name = "masterEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory(){
LocalContainerEntityManagerFactoryBean lb = new LocalContainerEntityManagerFactoryBean();
lb.setDataSource(masterDatasource());
lb.setPackagesToScan(new String[]{MasterTenant.class.getPackage().getName(), MasterTenantRepository.class.getPackage().getName()});
lb.setPersistenceUnitName("master-database-persistence-unit");
JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
lb.setJpaVendorAdapter(vendorAdapter);
lb.setJpaProperties(hibernateProperties());
log.info("Setup of masterEntityManagerFactory successfully.");
return lb;
}
@Bean(name = "masterTransactionManager")
public JpaTransactionManager masterTransactionManager(@Qualifier("masterEntityManagerFactory") EntityManagerFactory emf){
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(emf);
log.info("Setup of masterTransactionManager successfully.");
return transactionManager;
}
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor(){
return new PersistenceExceptionTranslationPostProcessor();
}
private Properties hibernateProperties(){
Properties properties = new Properties();
properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
properties.put(Environment.SHOW_SQL, true);
properties.put(Environment.FORMAT_SQL, true);
properties.put(Environment.HBM2DDL_AUTO, "update");
return properties;
}
}10. Tenant Business Module
Define a User entity, repository, and service similar to a regular Spring Boot project.
@Entity
@Table(name = "USER")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements Serializable {
@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
public interface UserRepository extends JpaRepository<User, String>, JpaSpecificationExecutor<User> {
User findByUsername(String username);
}
@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);
}
}Twitter's Snowflake algorithm is used to generate unique IDs.
11. Tenant Interceptor
@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 so users can log in before a tenant is set.
12. Tenant Context Holder
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 datasource switching.
13. Tenant Identifier Resolver
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; }
}14. Multi‑Tenant Connection Provider
@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);
}
}This class queries the master tenant table to obtain datasource information for each tenant.
15. Tenant DataSource Configuration
@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;
}
}This configuration mirrors the master datasource setup but adds the tenant identifier resolver and multi‑tenant connection provider.
16. Login Controller and Test
@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 tenant databases, then start the service and access http://localhost:8080/login.html. Enter the tenant name, username, and password to verify that the request is routed to the correct tenant schema.
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.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
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.
