Multi-Tenancy Implementation Using Spring Boot, MongoDB, and Redis
In this tutorial, we will learn how to implement multi-tenancy in a Spring Boot application with MongoDB and Redis.
Prerequisites
- Spring Boot 2.4
- Maven 3.6.+
- JAVA 8+
- Mongo 4.4
- Redis 5
What Is Multi-Tenancy?
Multi-tenancy is a software architecture in which a single instance of a software application serves multiple customers. Everything should be shared, except for the different customers’ data, which should be properly separated. Despite the fact that they share resources, tenants aren’t aware of each other, and their data is kept totally separate. Each customer is called a tenant.
Software-as-a-service (SaaS) offerings are an example of multitenant architecture. More explanations.
Multi-Tenancy Models
Three principal architectural patterns for Multi Tenancy can be identified, which differs in the degree of (physical) separation of the tenant’s data.
- Database per Tenant: Each Tenant has its own database and is isolated from other tenants.
- Shared Database, Shared Schema: All Tenants share a database and tables. Every table has a Column with the Tenant Identifier, that shows the owner of the row.
- Shared Database, Separate Schema: All Tenants share a database, but have their own database schemas and tables.
Get Started
In this tutorial, we'll implement multi-tenancy based on a database per tenant.
We will start by creating a simple Spring Boot project from start.spring.io, with following dependencies:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</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-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Resolving the Current Tenant ID
The tenant id needs to be captured for each client request. To do so, We’ll include a tenant Id field in the header of the HTTP request.
Let's add an interceptor that capture the Tenant Id from an http header X-Tenant.
xxxxxxxxxx
public class TenantInterceptor implements WebRequestInterceptor {
private static final String TENANT_HEADER = "X-Tenant";
public void preHandle(WebRequest request) {
String tenantId = request.getHeader(TENANT_HEADER);
if (tenantId != null && !tenantId.isEmpty()) {
TenantContext.setTenantId(tenantId);
log.info("Tenant header get: {}", tenantId);
} else {
log.error("Tenant header not found.");
throw new TenantAliasNotFoundException("Tenant header not found.");
}
}
public void postHandle(WebRequest webRequest, ModelMap modelMap) {
TenantContext.clear();
}
public void afterCompletion(WebRequest webRequest, Exception e) {
}
}
TenantContext
is a storage that contains a ThreadLocal variable. The ThreadLocal can be considered as a scope of access, like a request scope or session scope.
By storing the tenantId in a ThreadLocal we can be sure that every thread has its own copy of this variable and that the current thread has no access to another tenantId:
xxxxxxxxxx
public class TenantContext {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
log.debug("Setting tenantId to " + tenantId);
CONTEXT.set(tenantId);
}
public static String getTenantId() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
Setup Tenant Datasources
In our architecture, we have a Redis instance that represents the master database where all the tenant database information is centralized. So, from each tenant id provided, the database connection information is retrieved in master database.
RedisDatasourceService.java
is the class responsible for managing all interactions with the master database.
xxxxxxxxxx
public class RedisDatasourceService {
private final RedisTemplate redisTemplate;
private final ApplicationProperties applicationProperties;
private final DataSourceProperties dataSourceProperties;
public RedisDatasourceService(RedisTemplate redisTemplate, ApplicationProperties applicationProperties, DataSourceProperties dataSourceProperties) {
this.redisTemplate = redisTemplate;
this.applicationProperties = applicationProperties;
this.dataSourceProperties = dataSourceProperties;
}
/**
* Save tenant datasource infos
*
* @param tenantDatasource data of datasource
* @return status if true save successfully , false error
*/
public boolean save(TenantDatasource tenantDatasource) {
try {
Map ruleHash = new ObjectMapper().convertValue(tenantDatasource, Map.class);
redisTemplate.opsForHash().put(applicationProperties.getServiceKey(), String.format("%s_%s", applicationProperties.getTenantKey(), tenantDatasource.getAlias()), ruleHash);
return true;
} catch (Exception e) {
return false;
}
}
/**
* Get all of keys
*
* @return list of datasource
*/
public List findAll() {
return redisTemplate.opsForHash().values(applicationProperties.getServiceKey());
}
/**
* Get datasource
*
* @return map key and datasource infos
*/
public Map<String, TenantDatasource> loadServiceDatasources() {
List<Map<String, Object>> datasourceConfigList = findAll();
// Save datasource credentials first time
// In production mode, this part can be skip
if (datasourceConfigList.isEmpty()) {
List<DataSourceProperties.Tenant> tenants = dataSourceProperties.getDatasources();
tenants.forEach(d -> {
TenantDatasource tenant = TenantDatasource.builder()
.alias(d.getAlias())
.database(d.getDatabase())
.host(d.getHost())
.port(d.getPort())
.username(d.getUsername())
.password(d.getPassword())
.build();
save(tenant);
});
}
return getDataSourceHashMap();
}
/**
* Get all tenant alias
*
* @return list of alias
*/
public List<String> getTenantsAlias() {
// get list all datasource for this microservice
List<Map<String, Object>> datasourceConfigList = findAll();
return datasourceConfigList.stream().map(data -> (String) data.get("alias")).collect(Collectors.toList());
}
/**
* Fill the data sources list.
*
* @return Map<String, TenantDatasource>
*/
private Map<String, TenantDatasource> getDataSourceHashMap() {
Map<String, TenantDatasource> datasourceMap = new HashMap<>();
// get list all datasource for this microservice
List<Map<String, Object>> datasourceConfigList = findAll();
datasourceConfigList.forEach(data -> datasourceMap.put(String.format("%s_%s", applicationProperties.getTenantKey(), (String) data.get("alias")), new TenantDatasource((String) data.get("alias"), (String) data.get("host"), (int) data.get("port"), (String) data.get("database"), (String) data.get("username"), (String) data.get("password"))));
return datasourceMap;
}
}
For this tutorial, we have populated in the tenant information from a yml file(tenants.yml).
In production mode, it is possible to create endpoints to save tenant information in the master database.
In order to be able to dynamically switch to the connection to a mongo database, we create a MultiTenantMongoDBFactory
class which extends the SimpleMongoClientDatabaseFactory
class of org.springframework.data.mongodb.core
. It will return a MongoDatabase
instance associated with the currently Tenant.
xxxxxxxxxx
public class MultiTenantMongoDBFactory extends SimpleMongoClientDatabaseFactory {
MongoDataSources mongoDataSources;
public MultiTenantMongoDBFactory( ("getMongoClient") MongoClient mongoClient, String databaseName) {
super(mongoClient, databaseName);
}
protected MongoDatabase doGetMongoDatabase(String dbName) {
return mongoDataSources.mongoDatabaseCurrentTenantResolver();
}
}
We need to initialize the MongoDBFactoryMultiTenant
constructor with default parameters (MongoClient
and databaseName
).
This is a transparent mechanism for retrieving the Current Tenant.
xxxxxxxxxx
public class MongoDataSources {
/**
* Key: String tenant alias
* Value: TenantDatasource
*/
private Map<String, TenantDatasource> tenantClients;
private final ApplicationProperties applicationProperties;
private final RedisDatasourceService redisDatasourceService;
public MongoDataSources(ApplicationProperties applicationProperties, RedisDatasourceService redisDatasourceService) {
this.applicationProperties = applicationProperties;
this.redisDatasourceService = redisDatasourceService;
}
/**
* Initialize all mongo datasource
*/
public void initTenant() {
tenantClients = new HashMap<>();
tenantClients = redisDatasourceService.loadServiceDatasources();
}
/**
* Default Database name for spring initialization. It is used to be injected into the constructor of MultiTenantMongoDBFactory.
*
* @return String of default database.
*/
public String databaseName() {
return applicationProperties.getDatasourceDefault().getDatabase();
}
/**
* Default Mongo Connection for spring initialization.
* It is used to be injected into the constructor of MultiTenantMongoDBFactory.
*/
public MongoClient getMongoClient() {
MongoCredential credential = MongoCredential.createCredential(applicationProperties.getDatasourceDefault().getUsername(), applicationProperties.getDatasourceDefault().getDatabase(), applicationProperties.getDatasourceDefault().getPassword().toCharArray());
return MongoClients.create(MongoClientSettings.builder()
.applyToClusterSettings(builder ->
builder.hosts(Collections.singletonList(new ServerAddress(applicationProperties.getDatasourceDefault().getHost(), Integer.parseInt(applicationProperties.getDatasourceDefault().getPort())))))
.credential(credential)
.build());
}
/**
* This will get called for each DB operations
*
* @return MongoDatabase
*/
public MongoDatabase mongoDatabaseCurrentTenantResolver() {
try {
final String tenantId = TenantContext.getTenantId();
// Compose tenant alias. (tenantAlias = key + tenantId)
String tenantAlias = String.format("%s_%s", applicationProperties.getTenantKey(), tenantId);
return tenantClients.get(tenantAlias).getClient().
getDatabase(tenantClients.get(tenantAlias).getDatabase());
} catch (NullPointerException exception) {
throw new TenantAliasNotFoundException("Tenant Datasource alias not found.");
}
}
}
Test
Let's create an example of CRUD with an Employee document.
xxxxxxxxxx
chain = true) (
collection = "employee") (
public class Employee {
private String id;
private String firstName;
private String lastName;
private String email;
}
Also we need to create EmployeeRepository
, EmployeeService
and EmployeeController
. For testing, we load dummy data into each tenant database when the app starts.
xxxxxxxxxx
public void run(String... args) throws Exception {
List<String> aliasList = redisDatasourceService.getTenantsAlias();
if (!aliasList.isEmpty()) {
//perform actions for each tenant
aliasList.forEach(alias -> {
TenantContext.setTenantId(alias);
employeeRepository.deleteAll();
Employee employee = Employee.builder()
.firstName(alias)
.lastName(alias)
.email(String.format("%s%s", alias, "@localhost.com" ))
.build();
employeeRepository.save(employee);
TenantContext.clear();
});
}
}
Now we can run our application and test it.
And we're done, hope this tutorial helps you understand what multi-tenancy is and how it's implemented in a Spring Boot project using MongoDB and Redis.
Full source code can be found on GitHub.