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.

Programmer DD
Programmer DD
Programmer DD
Build a Multi‑Tenant SaaS with Spring Boot: Dynamic DataSource & Schema Switching

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: debug
Freemarker 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:

Project Structure
Project Structure

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.

Login Page
Login Page
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.

JavaSpring Bootmulti-tenantSaaSHibernatedynamic-datasource
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

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.