Multi-tenancy -The shared database, separate schema approach... with a flavour of separate database on top!
The term multi-tenancy is used to describe the architecture where a single software instance is able to provide its services across multiple tenants. In this example, we'll focus on turning a traditional spring boot application into a multi-tenant one.
To be more precise, the following notes and hints will give you a good starting point on how to implement the "Shared Database, Separate Schema" approach... with a cherry on top which also adds the "Separate Database" in the mix, making it somehow a hybrid multi-tenancy approach.
As software engineers we're often asked to build applications that serve multiple groups of users. Most of the "Software as a Service" products are a good example of a multi-tenant application. Imagine an application that serves different clients and companies. Each company will likely want their data isolated from the rest of the users of that service. The reasons can vary from easier maintenance to performance, privacy and security and even legal obligations signed via contracts.
Without further ado, here are the steps to follow to update a Spring Boot application to a multi-tenant Spring Boot application:
- First you'll need a central schema where our application can connect to it by default. This is where the the identification information of all the tenants will be held. This could be as simple as a single table that'll hold basic information about tenants like their name, the URL they use to identify themselves (we'll get to that in a minute) and the data source details, like the database connection URL. The latter actually is the cherry we mentioned above. By storing the entire database connection URL, we later have the option to use it in order to connect to a tenant schema. This way the schema can live in any database. So, the "Shared Database", can easily be considered as "Separate Database", making the whole approach a hybrid of the most common multi-tenancy options.
- Next we'll need to decide a way to identify tenants. The easier way to do it's through the use of a custom, dedicated HTTP header ie. "Tenant-ID". Another way, although a bit more complicated, but much cleaner and much more professional, is through the use of sub-domains. For instance, you could have your tenants use their own versions of the URL to access the application ie. tenantA.myApplication.com, tenantB.myApplication.cometc. Of course, depending on the requirements, the URLs can be as customised as they need to be.
After the decision on how the tenants will differentiate from each other, our next step is to put down some code that will read and handle that information. This is simply done through a spring interceptor, or a filter.
ie. using a HTTP header
@Component
public class TenantInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
// get the value of the tenant HTTP header "Tenant-ID"
String tenantID = request.getHeader("Tenant-ID");
// keep the tenant detail to use further down
// throughout the whole execution of this thread
MultitenancyContext.setTenant(tenantID);
return true;
}
}
or by using a the URL approach
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// get the tenant identification from the URL
String tenantID = request.getHeader("x-forwarded-host");
// keep the tenant detail to use further down
// throughout the whole execution of this thread
MultitenancyContext.setTenant(tenantID);
filterChain.doFilter(servletRequest, servletResponse);
}
}
So what's this the
MultitenancyContext.setTenant(tenantID);
we see in the examples above?
Well, it's nothing more than a "ThreadLocal" variable. This is where we store the tenant identification and use it further down the road. A ThreadLocal variable is accessible only by a specific thread, which makes it perfect for our use case.
This is how our "MultitenancyContext" could look like:
public class MultitenancyContext {
private static final ThreadLocal tenant = new InheritableThreadLocal<>();
/**
* Retrieve the value of the {@link ThreadLocal} variable
*/
public static String getTenant() {
return tenant.get();
}
/**
* Set the tenant into the {@link ThreadLocal} variable
*/
public static void setTenant(String tenant) {
MultitenancyContext.tenant.set(tenant);
}
}
3. Now it's time to connect to the proper database and schema. To achieve this, we need to get our hands a bit dirty around some Hibernate configuration.
a) Implementation of Hibernate's "CurrentTenantIdentifierResolver". This is where we let Hibernate know about our tenant.
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
private String central_tenant_schema = "tenants";
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = MultitenancyContext.getTenant();
if (StringUtils.isBlank(tenant)) {
return central_tenant_schema;
}
return tenant;
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
b) Implementation of a subclass of Hibernate's "AbstractDataSourceBasedMultiTenantConnectionProviderImpl". This is the place where our application determines the datasource by reading the value we store on each thread with the use of the ThreadLocal variable.
@Component
public class MultiTenantConnectionProviderImpl extends
AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
private String central_tenant_schema = "tenants";
private final ApplicationContext context;
private final DataSource centralTenantSchemaDatasource;
boolean initCompleted = false;
private final Map dataSourceMap = new HashMap<>();
/**
* After bean is constructed perform datasource map
* initialization, add only the central tenant schema as
* configured in the connection details determined in the
* application.properties
*/
@PostConstruct
public void load() {
dataSourceMap.put(central_tenant_schema, new TenantDataSource(null, defaultDS));
}
@Override
protected DataSource selectAnyDataSource() {
return dataSourceMap.get(central_tenant_schema).getDataSource();
}
@Override
protected DataSource selectDataSource(String schema) {
if (!initCompleted) {
initCompleted = true;
updateDataSourceMap();
}
TenantDataSource tenantDataSource = dataSourceMap.get(schema) != null ?
dataSourceMap.get(schema) : dataSourceMap.get(central_tenant_schema);
return tenantDataSource.getDataSource();
}
/**
* Read the Tenants table from the "central_tenant_schema"
* and update the map that holds the tenant datasources.
*/
private void updateDataSourceMap() {
...
}
public static class TenantDataSource {
private Tenant tenant;
private DataSource dataSource;
...
}
}
c) Now we need a class that will orchestrate and glue all the pieces together.
@Configuration
public class HibernateConfig {
private final JpaProperties jpaProperties;
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource,
MultiTenantConnectionProvider multiTenantConnectionProvider,
CurrentTenantIdentifierResolver currentTenantIdentifierResolver) {
Map jpaPropertiesMap = new HashMap<>(jpaProperties.getProperties());
jpaPropertiesMap.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);
LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean();
entityManager.setDataSource(dataSource);
entityManager.setPackagesToScan("com.myApp*");
entityManager.setJpaVendorAdapter(this.jpaVendorAdapter());
entityManager.setJpaPropertyMap(jpaPropertiesMap);
return entityManager;
}
}
... and that's pretty much all! Everything you need to support multi-tenancy in your Spring Boot application is described in the above simple steps. Of course, the class examples are a narrowed-down version that focuses on their most important parts.
So, the next time a request is received, our Interceptor or Filter will identify the tenant, and later Hibernate will do its thing to create and use an entity manager throughout the process until its completion.
Happy coding!
Take a look at our web design and development services to see where we can help!