Implementing multitenancy architecture: Spring Boot, JPA, Hibernate, Flyway

Wiktor Rosiński
Deviniti Technology-Driven Blog
12 min readJan 28, 2021

What is multi-tenant architecture?

Multitenancy is a single application architecture based on using multiple databases. Each tenant database has its own resources that are isolated from the rest. This solution is appreciated especially in cloud computing. Atlassian is a good example here — tools such as Jira, Confluence, Trello, or Bitbucket are based on multitenancy.

But what exactly is a tenant? In general, we can describe a tenant as a user group defined by a common identifier (tenant id). This could be, for example, a customer who wants to buy access to the website or a department in a large corporation.

Suppose we’re writing an e-commerce application that we want to sell to multiple customers as a product. We can handle its distribution in two ways: on-premises or SaaS. In the first case, we have to install the application on the client’s own infrastructure. It’s an expensive solution both in terms of development and maintenance operational costs since we end up with multiple application instances in different environments to which we need to adapt. However, this comes with its advantages. The customer has full control over the availability of services and their quality by managing their own infrastructure. Another option is software as a service, where the application is installed in one place, and the customer only buys access to the service. This way, application maintenance and development are simpler and cheaper. However, all the clients share resources such as databases, FTP or DMS servers, authorization servers, etc.

Single-tenant architecture examples: on-premise and SaaS model comparison
Single-tenant architecture examples: on-premise and SaaS model comparison

But how to serve the customers who would like to have a distinct database at their disposal? Or what to do when the application’s business requirements make it difficult to store data in an isolated way for security reasons? Then it’s necessary to ensure that each client has their own database, and the application knows which database to connect to depending on the logged-in client. This is what multitenancy is all about.

Multi-tenant architecture example diagram
Multi-tenant architecture example diagram

Multitenancy in the cloud: resource allocation

A multitenancy architecture will require an additional TenantContext (TC) or TenantResolver layer that can identify which tenant has access to which resources. Here’s how to solve this: at the authorization stage, the information about the tenant id is placed in the JWT token, which is then interpreted on the back end in TenantContext and determines which database to connect to.

We can offer access to the database in the multitenancy architecture in three ways:

  • Separate database — every client has its own database. The application will decide which database to connect to based on the tenantId. This is the most secure solution because the databases can be located on different servers or even inside the customer’s infrastructure, and only the application is with us.
  • Separate schema — every client has its own schema within one database. In this case, the tenantId defines which schema to connect to. The solution is less secure than the previous one, but we can always define database roles and users on each schema to authorize access only to one schema.
  • Shared schema — All clients have data in one schema. The discriminator in this case will be an additional column in each table that defines the record affiliation to the customer. This is the least secure solution and actually not much different from the SaaS model. But in this case, we have already implemented multi-tenancy, which allows us to go to one of the two above relatively easily.

The core of a multi-tenant web application

Based on the example above, we’d like to create an application using multitenancy. To retrieve data from a database with use, JPA we will need the appropriate repository class:

@Repository
interface ProductRepository extends JpaRepository<Product, Long>{
}

as well as the service class that will ensure our transactionality:

@Service
@Transactional
class ProductService {

private ProductRepository productRepository;

public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}

public List<ProductDto> getAllProducts() {
return productRepository.findAll().stream()
.map(ProductMapper::mapToDto)
.collect(toList());
}

}

Now it would be nice to use one service to connect to different databases or switch between schemas depending on the tenant in the context of which we’re going to execute the query. Time for multitenancy configurations.

Multi-tenant architecture example configuration

Spring

In most applications based on a Java virtual machine, we use Spring, while access to the database is provided through the Hibernate library. Both Spring and Hibernate support multitenancy, so we can configure it easily. We will use Spring Data JPA configurations — the most popular way to access the database.

On the Spring side, we will have one class to implement that intercepts the request and, on its basis, sets the appropriate tenant in the context (TenantContext).

First, however, it needs to create the context itself. Here’s a sample implementation:

public abstract class TenantContext {

public static final String DEFAULT_TENANT_ID = "public";
private static ThreadLocal<String> currentTenant = new ThreadLocal<String>();

public static void setCurrentTenant(String tenant) {
currentTenant.set(tenant);
}

public static String getCurrentTenant() {
return currentTenant.get();
}

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

}

The implementation is here to ensure that a parameter representing the tenantId is set in the local thread that we can use later. The only thing left for us to do now is to implement the interface: org.springframework.web.servlet.AsyncHandlerInterceptor.

In the earlier versions of Spring Boot, we could extend the org.springframework.web.servlet.handler.HandlerInterceptorAdapter class, but in newer versions, the class is deprecated, so we’ll stay with the interface.

We have to override the preHandle() and postHandle() methods in order to intercept the request and then clear the TenantContext.

@Component
public class TenantRequestInterceptor implements AsyncHandlerInterceptor{

private SecurityDomain securityDomain;

public TenantRequestInterceptor(SecurityDomain securityDomain) {
this.securityDomain = securityDomain;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
return Optional.ofNullable(request)
.map(req -> securityDomain.getTenantIdFromJwt(req))
.map(tenant -> setTenantContext(tenant))
.orElse(false);
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
TenantContext.clear();
}

private boolean setTenantContext(String tenant) {
TenantContext.setCurrentTenant(tenant);
return true;
}
}

In the example above, we can see in the preHandle() method how we extract the tenantId from the JWT token. I won’t go into the details of this implementation, because it’s pretty standard.

After this, we set the tenantId in the TenantContext. In the postHandle() method, we clean up the context. Spring is done at this point. We can reach TenantContext at any time and retrieve information about resources.

Finally, we just need to register the interceptor in Spring:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

@Autowired
private TenantRequestInterceptor tenantInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor).addPathPatterns("/**");
}

}

Hibernate

When moving to Hibernate, we must remember that it supports only the first 2 points — a separate database or a separate schema for each client. We need to use an additional library if we want to implement multitenancy on a single schema.

In Hibernate, we will have 2 classes to implement:

  • org.hibernate.context.spi.CurrentTenantIdentifierResolver — which will define a tenantId for Hibernate, thanks to which it will know what resources to get to;
  • org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider — will open a connection to resources based on the CurrentTenantIdentifierResolver tenant id returned.

The following is an example implementation for CurrentTenantIdentifierResolver:

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {

@Override
public String resolveCurrentTenantIdentifier() {
return Optional.ofNullable(TenantContext.getCurrentTenant())
.orElse(TenantContext.DEFAULT_TENANT_ID);
}

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

}

The resolveCurrentTenantIdentifier() method returns the tenantId for the tenant in the context. If we want Hibernate to validate all existing sessions for the indicated tenantId, then we return the value true in the validateExistingCurrentSessions() method.

The implementation of the ConnectionProvider depends on the strategy we adopt for multitenancy, as well as on where we will store information about tenants. I will describe it in a moment.

Finally, it remains to add the configuration in the properties (application.properties or yaml — if you prefer):

spring.jpa.properties.hibernate.multiTenancy=
spring.jpa.properties.hibernate.tenant_identifier_resolver=
spring.jpa.properties.hibernate.multi_tenant_connection_provider=

The first parameter is used to define what multitenancy strategy we are adopting. We have a choice of values ​​here:

  • NONE — default value — multitenancy is disabled. In this strategy, if we set a tenantId, Hibernate will throw an exception.
  • SCHEMA — for the separate schema strategy.
  • DATABASE — for the separate database strategy.
  • DISCRIMINATOR — for the shared schema strategy (not yet implemented in Hibernate. It was planned for Hibernate 5, but this issue is still open, so we need to wait.)

The other two parameters point to the corresponding class implementations for CurrentTenantIdentifierResolver and AbstractMultiTenantConnectionProvider.

If we use Spring Data JPA, then we have to add a modified configuration for Hibernate:

hibernate.connection.url=
hibernate.connection.username=
hibernate.connection.password=
spring.datasource.url=${hibernate.connection.url}
spring.datasource.username=${hibernate.connection.username}
spring.datasource.password=${hibernate.connection.password}

That’s because Spring for JPA uses parameters that are in spring.datasource, while Hibernate in multitenancy uses the parameters located inside hibernate.connection. To avoid implementing the same thing several times, I suggest that you set parameters once and use them later. This will come in handy, especially when we add Flyway.

Finally, you need to remember that Hibernate is an external library that is imported in Spring. This sometimes causes problems in process synchronization. For example, the ConnectionProvider starts before the entire Spring context is raised — it often happens that when you want to reference the Spring bean, not only we can’t use the Spring context, but often the bean itself is not built yet. So, the references to properties or other resources must be done outside of Spring.

At this stage, we only have to implement the ConnectionProvider. Its implementation will depend on the strategy we adopt or solutions on how to get the database connection configuration depending on the tenant.

Separate database

The safest strategy is that each tenant will use a separate database. Our ConnectionProvider will manage to switch between individual databases. The simplest implementation is creating a separate properties file for each tenant where we will store the configuration. The filename mask can look like this:

hibernate-<tenantId>.properties

Set the parameters in the file:

hibernate.connection.url=
hibernate.connection.username=
hibernate.connection.password=
spring.datasource.url=${hibernate.connection.url}
spring.datasource.username=${hibernate.connection.username}
spring.datasource.password=${hibernate.connection.password}

If we want to connect to a specific schema, we can add another parameter:

hibernate.default_schema=

You can also use this solution when you want to store information about the schema in a configuration file or when you have a dedicated user/role in the database for each schema in case of the separate schema strategy. It will also let us switch between different databases (Oracle, PostgreSQL) as long as ORM mapping allows it — for example, not all databases have sequences.

Our ConnectionProvider will look like this:

public class SchemaMultiTenantConnectionProvider extends AbstractMultiTenantConnectionProvider {

public static final String HIBERNATE_PROPERTIES_PATH = "/hibernate-%s.properties";
private final Map<String, ConnectionProvider> connectionProviderMap;

public SchemaMultiTenantConnectionProvider() {
this.connectionProviderMap = new HashMap<String, ConnectionProvider>();
}

@Override
protected ConnectionProvider getAnyConnectionProvider() {
return getConnectionProvider(TenantContext.DEFAULT_TENANT_ID);
}

@Override
protected ConnectionProvider selectConnectionProvider(String tenantIdentifier) {
return getConnectionProvider(tenantIdentifier);
}

private ConnectionProvider getConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(connectionProviderMap::get)
.orElseGet(() -> createNewConnectionProvider(tenantIdentifier));
}

private ConnectionProvider createNewConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(this::createConnectionProvider)
.map(connectionProvider -> {
connectionProviderMap.put(tenantIdentifier, connectionProvider);
return connectionProvider;
})
.orElseThrow(() -> new ConnectionProviderException(String.format("Cannot create new connection provider for tenant: %s", tenantIdentifier)));
}

private ConnectionProvider createConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(this::getHibernatePropertiesForTenantId)
.map(this::initConnectionProvider)
.orElse(null);
}

private Properties getHibernatePropertiesForTenantId(String tenantId) {
try {
Properties properties = new Properties();
properties.load(getClass().getResourceAsStream(String.format(HIBERNATE_PROPERTIES_PATH, tenantId)));
return properties;
} catch (IOException e) {
throw new RuntimeException(String.format("Cannot open hibernate properties: %s", HIBERNATE_PROPERTIES_PATH));
}
}

private ConnectionProvider initConnectionProvider(Properties hibernateProperties) {
DriverManagerConnectionProviderImpl connectionProvider = new DriverManagerConnectionProviderImpl();
connectionProvider.configure(hibernateProperties);
return connectionProvider;
}

}

The only thing we have to implement in this class are the methods getAnyConnectionProvider() and selectConnectionProvider(String tenantId).

The first method sets up a connection to the database when the tenantId is not set. This happens when the application is starting. Validating whether the tenant is set can be implemented in AsyncHandlerInterceptor on the Spring side and throws an exception when it’s not set or is incorrect.

The second method is responsible for returning the appropriate ConnectionProvider for the indicated tenantId. The solution assumes that we collect ConnectionProvider on a map so as not to create a new one every time. If it’s not on the map yet, then we create a new one and add it to the map. Of course, this can be moved to the classic cache, where you can additionally manage lifetime (TTL).

Separate schema

The Separate schema strategy is based on each tenant owning its own schema in one database. When we use one user on all schemas to connect to the database, then all we need to do is switching the schema.

In the simplest ConnectionProvider implementation, we have one user for all schemas and configuration is on the properties side:

public class SchemaMultiTenantConnectionProvider extends AbstractMultiTenantConnectionProvider {
private static final String HIBERNATE_PROPERTIES_PATH = "/application.properties";
private final Map<String, ConnectionProvider> connectionProviderMap;

public SchemaMultiTenantConnectionProvider() {
this.connectionProviderMap = new HashMap<String, ConnectionProvider>();
}

@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = super.getConnection(tenantIdentifier);
connection.createStatement().execute(String.format("SET SCHEMA '%s';", tenantIdentifier));
return connection;
}

@Override
protected ConnectionProvider getAnyConnectionProvider() {
return getConnectionProvider(TenantContext.DEFAULT_TENANT_ID);
}

@Override
protected ConnectionProvider selectConnectionProvider(String tenantIdentifier) {
return getConnectionProvider(tenantIdentifier);
}

private ConnectionProvider getConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(connectionProviderMap::get)
.orElseGet(() -> createNewConnectionProvider(tenantIdentifier));
}

private ConnectionProvider createNewConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(this::createConnectionProvider)
.map(connectionProvider -> {
connectionProviderMap.put(tenantIdentifier, connectionProvider);
return connectionProvider;
})
.orElseThrow(() -> new ConnectionProviderException(String.format("Cannot create new connection provider for tenant: %s", tenantIdentifier)));
}

private ConnectionProvider createConnectionProvider(String tenantIdentifier) {
return Optional.ofNullable(tenantIdentifier)
.map(this::getHibernatePropertiesForTenantId)
.map(this::initConnectionProvider)
.orElse(null);
}

private Properties getHibernatePropertiesForTenantId(String tenantId) {
try {
Properties properties = new Properties();
properties.load(getClass().getResourceAsStream(HIBERNATE_PROPERTIES_PATH));
return properties;
} catch (IOException e) {
throw new RuntimeException(String.format("Cannot open hibernate properties: %s)", HIBERNATE_PROPERTIES_PATH));
}
}

private ConnectionProvider initConnectionProvider(Properties hibernateProperties) {
DriverManagerConnectionProviderImpl connectionProvider = new DriverManagerConnectionProviderImpl();
connectionProvider.configure(hibernateProperties);
return connectionProvider;
}

}

In this case, we have one method to overload: getConnection(). We call an SQL that changes the schema on the connection we’ve already created. In this implementation, we assumed that the tenantId is also the name of the schema. With tenantId mapping, the name of the schema can also be implemented from this side.

Shared schema

In this strategy, all data is contained in one schema, and the information to which tenant the record belongs is located in an additional column. Hibernate doesn’t support this strategy yet.

Flyway

We couldn’t imagine the development of database-based applications without the Flyway library. This tool is extremely useful when you need to manage the development of a database model. That’s why we should use it here as well. The configuration is actually standard, except for one parameter.

flyway.url=${hibernate.connection.url}
flyway.user=${hibernate.connection.username}
flyway.password=${hibernate.connection.password}
spring.flyway.baseline-on-migrate=true
spring.flyway.enabled=false

We’ve already defined the database connection parameters when configuring Hibernate, so we can reuse them here. Remember to place the baseline-on-migrate and enabled parameters from Spring Boot version 2.x in the spring location (in YAML, the configuration files come in the form of a tree, while in the properties files we just add spring. at the beginning).

The enabled parameter should be set to false, because in multitenancy we’ll work on many schemas or databases, so we’ll have to manually implement SQL uploads for each tenant.

Here’s an example implementation for the separate schema strategy. After minor modifications, you can also use it in the second strategy.

@Component
public class FlywayConfiguration {

private static final String DB_MIGRATION_TENANTS = "db/migration/tenants";


@PostConstruct
void migrateAllTenants() {
getAllTenants()
.forEach(this::migrateForTennant);
}

private List<String> getAllTenants() {
return Arrays.asList(
"public",
"tenant1",
"tenant2"
);
}

private void migrateForTennant(String tenantId) {
Pair<String, BasicDataSource> data = dataSource(tenantId);
Flyway.configure()
.locations(DB_MIGRATION_TENANTS)
.baselineOnMigrate(true)
.schemas(data.getFirst())
.dataSource(data.getSecond())
.load()
.migrate();
}

public Pair<String, BasicDataSource> dataSource(String tenantId) {
try {
Properties properties = new Properties();
properties.load(getClass().getResourceAsStream(HIBERNATE_PROPERTIES_PATH));
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName(properties.get(Environment.DRIVER).toString());
dataSource.setUrl(properties.get(Environment.URL).toString());
dataSource.setUsername(properties.get(Environment.USER).toString());
dataSource.setPassword(properties.get(Environment.PASS).toString());
return Pair.of(tenantId, dataSource);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

Summary

Spring and Hibernate support multitenancy. We can implement two strategies using Spring Data JPA and Hibernate: separate database and separate schema. The third strategy is planned for Hibernate, but it hasn’t been released yet.

Using multitenancy will involve creating an AsyncHandlerInterceptor extension interceptor to set our tenantId in the TenantContext and register it in Spring. In Hibernate, you need to create two classes: TenantIdentifierResolver (to set the tenantId on the Hibernate side) and ConnectionProvider (to select the appropriate database connection based on the tenantId).

The entire project with all the examples described in this article is available here.

If you want to learn how to use this configuration in a real application and how to automate adding a new tenant, see the next article.

At Deviniti, we’ve been helping companies around the globe improve their business results with the help of technology for the last 16 years. As a technological partner for your business, we will help you choose the best possible solution according to business analysis and complete the IT skills you need to implement it. Get in touch with us to receive initial consulting and get recommendations to make the right business decision.

Also, read more on our Technology-Driven Blog:

--

--