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.

ITFLY8 Architecture Home
ITFLY8 Architecture Home
ITFLY8 Architecture Home
How to Build a Multi‑Tenant SaaS Application with Spring Boot and Dynamic Data Sources

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: debug
Because 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.

SaaS architecture diagram
SaaS architecture diagram
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 Bootmulti-tenantSaaSHibernatedynamic-datasource
ITFLY8 Architecture Home
Written by

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.

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.