Multi-tenancy Using Spring Boot - Shared Schema Subdomain Identifier Architecture

Multi-tenancy Using Spring Boot - Shared Schema Subdomain Identifier Architecture

Introduction :

Multi-tenancy means that multiple customers of an application are using the same codebase and server but with different databases. Despite the fact that they share resources, customers aren't aware of each other, and their data is kept totally separate and safe.

Use Cases:

Let's understand the use cases in a simpler way, let's assume we have developed an application that is configured with a domain geekyapp.com.

For example, we have three clients who use our application:

  • Fireclient
  • Waterclient
  • Airclient

Now, let's say every client wants to have their own schema which will be separate from another client because of privacy concerns of their data. In such cases, we can go for a multi-tenancy approach and provide them with different URLs like:

Technologies & Plugins used:

  • Spring Boot
  • FlyWay
  • JPA & Hibernate
  • MySQL

Code Source :

POJOs and Repositories source codes can be found below GitHub repos:

Let's dive into code and development part. We will develop two different services for this topic which are listed below:

Base-Service

It will be responsible for creating new schemas dynamically when a new client requests registration and also this service will provide all required details to client service through API calls.

Client-Service

It will be responsible for performing business operations for each client and it will invoke Base-Service for runtime database creation for new clients.

Base-Service

Dependencies

As you can find out we have used org.flywaydb dependencies, which provides us the option for creating new schemas for customers in runtime.

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-maven-plugin</artifactId>
            <version>8.5.7</version>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

DB Structure SQL File

Inside directory /mt-base-service/src/main/resources/db/migration we have placed V1_0__client.sql files which contains our DB schema structure. Flyway will use this SQL file for creating new tenant databases in runtime.

Note: We need to keep the naming convention of this file in the required format.

Here, V1_0__ will be the version of our schema structure, and client.sql contains any random name.

DROP TABLE IF EXISTS `product`;

CREATE TABLE `product` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Main Class

Here we have just created and returned a bean of FlywayMigrationStrategy.

@SpringBootApplication
public class MtBaseServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(MtBaseServiceApplication.class, args);
    }

    @Bean
    public FlywayMigrationStrategy flywayMigrationStrategy() {
        return flyway -> {
            // do nothing
        };
    }
}

BaseRestController.java

The /addsource/{tenant} mapping method will receive an OrgDTO as a request from client service which contains the required information for new tenant creation. Later, we are saving OrgDTO in the base database and also we are creating one Dsource object and saving it in the database for future needs.

The /getAll mapping method will return a response of all Dsource objects stored in database so that Client-Service can use these objects to create and initialize Spring Datasource objects while startup.

The fly.migrate(); function tells Fylway to create a database with below given configurations.

Flyway fly = Flyway.configure()                                               
       .configuration(flyway.getConfiguration())                 
       .schemas(tenant)   //here we are passing the tenant name which will be the schema name.                                   
       .defaultSchema(tenant)   //Flyway will consider the same as default schema.                                   
       .load();
@RestController
@RequestMapping("/base")
public class BaseRestController {

    @Autowired
    private DsourceRepository dsourceRepository;

    @Autowired
    private OrgEntityRepository orgEntityRepository;

    @Autowired                                                            
    private Flyway flyway;

    @CrossOrigin(origins = "*")
    @PostMapping("/addsource/{tenant}")
    public ResponseEntity<Object> addDSource(@RequestBody OrgDTO orgDTO, @PathVariable("tenant") String tenant) {

        Dsource dsource = new Dsource();
        dsource.setTenantId(tenant);
        dsource.setUsername("root");
        dsource.setPassword("WitchMYSQL");
        dsource.setUrl("jdbc:mysql://localhost:3306/"+tenant+"?useSSL=false");
        dsource.setDriverClassName("com.mysql.cj.jdbc.Driver");
        dsourceRepository.save(dsource);

        OrgEntity orgEntity = new OrgEntity();
        orgEntity.setEmail(orgDTO.getEmail());
        orgEntity.setOrgName(orgDTO.getOrgName());
        orgEntity.setFirstName(orgDTO.getFirstName());
        orgEntity.setLastName(orgDTO.getLastName());
        orgEntity.setInstanceName(orgDTO.getInstanceName());
        orgEntityRepository.save(orgEntity);

        Flyway fly = Flyway.configure()                                                
        .configuration(flyway.getConfiguration())                 
        .schemas(tenant)                                          
        .defaultSchema(tenant)                                    
        .load();

        fly.migrate();

        return new ResponseEntity<>("success", HttpStatus.OK);
    }

    @CrossOrigin(origins = "*")
    @GetMapping("/getAll")
    public List<Dsource> getAll() {
        return dsourceRepository.findAll();
    }
}

That's it, we are all set with Base-Service which will accept tenant creation requests from our Client-Service and it will dynamically create a DB schema for new tenants. Let's go ahead and develop our Client-Service.

Client-Service

Dependencies

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
            <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

Configurations - Important

CurrentTenantIdentifierResolverImpl.java

Here we are resolving the current tenant ID from TenantContextHolder which is a ThreadLocal component. The DEFAULT_TENANT_ID will be having a mandatory database name so that Spring Boot application can start without any issues, else because of Auto Configuration nature of Spring Boot application could get crashed while startup.

@Component
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

    private static final String DEFAULT_TENANT_ID = "test1";

    @Override
    public String resolveCurrentTenantIdentifier() {
                String tenant = TenantContextHolder.getTenant();
                return tenant!=null && !tenant.equalsIgnoreCase("") ? tenant : DEFAULT_TENANT_ID;
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }

}

DataSourceBasedMultiTenantConnectionProviderImpl.java

This component does its job by iteration through all data sources, if we will use method selectDataSource(String tenantIdentifier) it will return us the Datasource object by performing a lookup by using tenant ID.

@Component
public class DataSourceBasedMultiTenantConnectionProviderImpl
extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {

    private static final long serialVersionUID = 1L;

    @Autowired
    private Map<String, DataSource> dataSourcesMtApp;

    @Override
    protected DataSource selectAnyDataSource() {
        return this.dataSourcesMtApp.values().iterator().next();
    }

    @Override
    protected DataSource selectDataSource(String tenantIdentifier) {
        return this.dataSourcesMtApp.get(tenantIdentifier);
    }
}

MultiTenancyJpaConfiguration.java

In this component, we will invoke Base-Service's /getAll REST endpoint for getting all stored Dsource objects in DB. Also we are having some custom bean methods for Entity Manager and Transaction Manager.

Next, we are iterating each Dsource object received from Base-Service and we are creating Spring Datasource objects by using Dsource's information.

DataSourceBuilder<?> factory = DataSourceBuilder.create().url(source.getUrl())
                    .username(source.getUsername()).password(source.getPassword())
                    .driverClassName(source.getDriverClassName());
@Configuration
@EnableTransactionManagement
@ComponentScan("com.itdib")
@Lazy
public class MultiTenancyJpaConfiguration {

    @Autowired
    private JpaProperties jpaProperties;

    @Autowired
    private RestTemplate rs;

    @Primary
    @Bean(name = "dataSourcesMtApp")
    public Map<String, DataSource> dataSourcesMtApp() {

        HttpHeaders headers = new HttpHeaders();

        ResponseEntity<Dsource[]> response =
                rs.exchange(
                        "http://localhost:2021/base/getAll", HttpMethod.GET, new HttpEntity<Object>(headers),
                        Dsource[].class);

        Dsource[] dsList = response.getBody();

        Map<String, DataSource> result = new HashMap<>();
        for (Dsource source :dsList) {
            DataSourceBuilder<?> factory = DataSourceBuilder.create().url(source.getUrl())
                    .username(source.getUsername()).password(source.getPassword())
                    .driverClassName(source.getDriverClassName());

            HikariDataSource ds = (HikariDataSource)factory.build();
            ds.setKeepaliveTime(40000);
            ds.setMinimumIdle(1);
            ds.setMaxLifetime(45000);
            ds.setIdleTimeout(35000);
            result.put(source.getTenantId(), ds);
        }

        return result;
    }

    @Bean
    public MultiTenantConnectionProvider multiTenantConnectionProvider() {
        return new DataSourceBasedMultiTenantConnectionProviderImpl();
    }

    @Bean
    public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
        return new CurrentTenantIdentifierResolverImpl();
    }

    @Bean(name="entityManagerFactoryBean")
    public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(
            AbstractDataSourceBasedMultiTenantConnectionProviderImpl multiTenantConnectionProvider,
            CurrentTenantIdentifierResolver currentTenantIdentifierResolver) {

        Map<String, Object> hibernateProps = new LinkedHashMap<>();
        hibernateProps.putAll(this.jpaProperties.getProperties());
        hibernateProps.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
        hibernateProps.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
        hibernateProps.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);
        hibernateProps.put("hibernate.hbm2ddl.auto", "update");
        hibernateProps.put("hibernate.dialact", "org.hibernate.dialect.MySQL8Dialect");
        LocalContainerEntityManagerFactoryBean result = new LocalContainerEntityManagerFactoryBean();
        result.setPackagesToScan("com.itdib");
        result.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        result.setJpaPropertyMap(hibernateProps);

        return result;
    }

    @Bean
    @Primary
    public EntityManagerFactory entityManagerFactory(LocalContainerEntityManagerFactoryBean entityManagerFactoryBean) {
        return entityManagerFactoryBean.getObject();
    }

    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }
}

TenantContextHolder

This component is basically meant for handling the current tenant ID throughout the request execution so that Hibernate can pick the correct requested data source according to the tenant ID received as a subdomain.

It really plays a very important role in our architecture because keeping a specific tenant ID for users helps Hibernate pick the correct Datasource from the pool.

public class TenantContextHolder {

    private static final ThreadLocal<String> CONTEXT = new InheritableThreadLocal <>();

    public static void setTenantId(String tenant) {
        CONTEXT.set(tenant);
    }

    public static String getTenant() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

ClientServiceImpl.java

This component holds our business logic as well as register(OrgDTO orgDTO) method will take care of invoking Base-Service for creating database schemas dynamically whenever the user registers a new tenant.

Also we are creating a Datasource object and giving it to DataSourcesMtApp so that it will include this new Datasource in the existing Datasource collection. Otherwise, our application will not be able to make DB connection for this newly registered tenant because in MultiTenancyJpaConfiguration.java we call BASE-SERVICE/getAll API only while Application Startup.

DataSourceBuilder<?> factory = DataSourceBuilder.create(MultiTenancyJpaConfiguration.class.getClassLoader())
    .url("jdbc:mysql://localhost:3306/" + orgDTO.getInstanceName() + "?useSSL=false").username("root")
    .password("WitchMYSQL").driverClassName("com.mysql.cj.jdbc.Driver");
HikariDataSource ds = (HikariDataSource) factory.build();
ds.setKeepaliveTime(40000);
ds.setMinimumIdle(1);
ds.setMaxLifetime(45000);
ds.setIdleTimeout(35000);
dataSourcesMtApp.put(orgDTO.getInstanceName(), ds);
@Service
public class ClientServiceImpl implements ClientService {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private Map<String, DataSource> dataSourcesMtApp;

    @Override
    public String register(OrgDTO orgDTO) {
        boolean flag = false;
        HttpHeaders headers = new HttpHeaders();
        ResponseEntity<Dsource[]> response = restTemplate.exchange("http://localhost:2021/base/getAll", HttpMethod.GET,
                new HttpEntity<Object>(headers), Dsource[].class);

        if (response.getBody() != null) {
            Dsource[] dsList = response.getBody();
            if (dsList != null && dsList.length > 0) {
                for (Dsource d : dsList) {
                    if (d.getTenantId().equalsIgnoreCase(orgDTO.getInstanceName())) {
                        flag = true;
                    }
                }
            }
        }

        if (flag) {
            return "tenant already exists!";
        }

        String url = "http://localhost:2021/base/addsource/" + orgDTO.getInstanceName();
        try {
            restTemplate.postForEntity(url, orgDTO, String.class);
        } catch (Exception e) {
        }

        DataSourceBuilder<?> factory = DataSourceBuilder.create(MultiTenancyJpaConfiguration.class.getClassLoader())
                .url("jdbc:mysql://localhost:3306/" + orgDTO.getInstanceName() + "?useSSL=false").username("root")
                .password("WitchMYSQL").driverClassName("com.mysql.cj.jdbc.Driver");
        HikariDataSource ds = (HikariDataSource) factory.build();
        ds.setKeepaliveTime(40000);
        ds.setMinimumIdle(1);
        ds.setMaxLifetime(45000);
        ds.setIdleTimeout(35000);
        dataSourcesMtApp.put(orgDTO.getInstanceName(), ds);
        TenantContextHolder.setTenantId(orgDTO.getInstanceName());
        return "tenant registered successfully!";
    }

}

That's it we are all set with both service development, it's time to go ahead and test both.

First, let's hit CLIENT-SERVICE's /registerOrg REST endpoint with below request body. This will internally invoke BASE-SERVICE's /base/addsource/{tenant} REST endpoint to create a new DB schema for our tenant.

{
    "email":"test@test.com",
    "orgName":"fireclient",
    "firstName":"fire",
    "lastName":"client",
    "instanceName":"fireclient"
}

Below is the excepted response,

Screenshot 2022-08-04 at 10.11.42 PM.png

Let's hit the same API for the second time with different request bodies for the second tenant.

{
    "email":"test2@test.com",
    "orgName":"airclient",
    "firstName":"air",
    "lastName":"client",
    "instanceName":"airclient"
}

Below is the expected response, Screenshot 2022-08-04 at 10.14.42 PM.png

Now, let's insert some test data in both tenants databases created by Flyway.

For fireclient,

INSERT INTO `fireclient`.`product` (`id`, `name`) VALUES ('1', 'fire1');
INSERT INTO `fireclient`.`product` (`id`, `name`) VALUES ('2', 'fire2');

For airclient,

INSERT INTO `airclient`.`product` (`id`, `name`) VALUES ('1', 'air1');
INSERT INTO `airclient`.`product` (`id`, `name`) VALUES ('2', 'air2');

Finally, let's hit CLIENT-SERVICE's /getProducts REST endpoint to test if our application connects to correct database and gives us expected output.

Note: In this tenant should be our tenant names like airclient and fireclient.

Response for this POST call,

Screenshot 2022-08-04 at 10.21.12 PM.png

Response for this POST call,

Screenshot 2022-08-04 at 10.21.42 PM.png

We can clearly see our application does its job well and gives us a list of Products for the requested tenant by dynamically switching between different tenant databases.

Thanks for reading this article, Happy coding!!!