Software architectures require time and money. Granting customers dedicated servers would mean financial suicide. The IT industry came up with a solution: multi-tenant architecture. What is multi-tenant architecture (a.k.a. multitenancy)? In this architecture, many application instances use one IT resource set. And what is a tenant? Tenants are customers using these instances. They aren’t aware of one another’s existence, and their data is separated. Multitenancy in cloud computing limits resources and thus cuts maintenance costs. The next logical step is accelerating your multi-tenant architecture by automating adding new tenants. How can you do that? Read on to find out.

Automate multitenancy without app restarts

The previous article showed you how to prepare your application in the multi-tenant architecture and configure your project. When it comes to multi-tenant configuration management, this specific configuration is simple so that you don’t lose sight of the bigger picture due to additional functionalities. However, there is one major drawback to this method. Adding another tenant means supplementing the properties with new data. If you don’t use the spring-boot-devtools, you will have to restart the application after adding every new tenant. This can be troubling, but there is a way to add new tenants without interfering with the application code.

Firstly, transfer all the Tenant data from the configuration files to the database and add functionality that supports the Tenant. Here, you must slightly modify your existing code. As a reward, you will be able to fully benefit from the multitenancy architecture, not only regarding databases. Transferring data to the database will give you unlimited possibilities in terms of usability. For example, you can autonomize the TenantId from schema name within your database, manage your information called helm to the KeyCloak or perform many other operations.

Transfer your tenants’ data to the database

You may keep the information on your tenants in the configuration files, but it is inconvenient. Every time you add another tenant, your application needs restarting. You probably don’t want to go through with this. A good idea is to use a specific database to avoid this hassle.

Tenant domain

Firstly, create your entities using the following lines of code:

@Entity
@Table(schema="public")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Tenant {
@Id
private String tenantId;
private String schemaName;
}

Then, create the TenantDTO that will store data on access to resources. The best idea is to group your data according to the resources, e.g., database, authorization service, or DMS. Simply follow the instructions below:

@Data
@Builder
public class TenantDTO {
private String tenantId;
private DataSourceDTO dataSource;
private AuthorizationDTO authorization;
private DmsDTO dms;
}

In the next stage, you need to implement a mapper that will let you rewrite entities to the DTO (meaning data transfer object) and back. Here’s how to do it:

interface TenantMapper {     public default TenantDTO mapToDto(Tenant tenant) {
return TenantDTO.builder()
.tenantId(tenant.getTenantId())
.dataSource(DataSourceDTO.builder()
.schemaName(tenant.getSchemaName())
.build())
.build();
}
public default Tenant mapToEntity(TenantDTO dto) {
return Tenant.builder()
.tenantId(dto.getTenantId())
.schemaName(Optional.ofNullable(dto)
.map(TenantDTO::getDataSource)
.map(DataSourceDTO::getSchemaName)
.orElse(null))
.build();
}
}

To achieve that, use the interface that facilitates integration with the website. Of course, you can always use the MapStruct library.

Since you are working on the DDD architecture layers, all operations related to Tenant support and setting the TenantContext will be performed through the Tenant domain:

public interface TenantDomain {     public boolean setTenantInContext(String tenantId);}

The domain implementation goes as follows:

@Component
@AllArgsConstructor
class TenantDomainImpl implements TenantDomain {
private final TenantService service; @Override
public boolean setTenantInContext(String tenantId) {
TenantDTO tenant = service.findByTenantId(tenantId);
TenantContext.setCurrentTenant(tenant);
return true;
}
}

You should also add services responsible for business logic. In this case, the DTO will return based on the tenantId:

@Service
@Transactional
@AllArgsConstructor
class TenantService implements TenantMapper {
private final TenantRepository repository; public TenantDTO findByTenantId(String tenantId) {
return Optional.ofNullable(tenantId)
.map(repository::findByTenantId)
.map(this::mapToDto)
.orElseThrow(() -> new CredentialsException(
String.format("Uknown tenantId: %s", tenantId))
);
}
}

Finally, it’s time for you to set up the repository:

@Repository
interface TenantRepository extends JpaRepository<Tenant, String>{
public Tenant findByTenantId(String tenantId);}

If you prepare your domain in this way, it will provide you with one method to set data related to access to resources based on the tenantId within the TenantContext.

Spring

Once you have established a tenant domain, you can modify the existing code. Start by altering the context itself. This won’t be a major change as you just need to switch the type of stored data from String to TenantDTO as shown below:

public abstract class TenantContext {     public static final String DEFAULT_TENANT_ID = "public";     private static ThreadLocal<TenantDTO> currentTenant = new
ThreadLocal<TenantDTO>();
public static void setCurrentTenant(TenantDTO tenant) {
currentTenant.set(tenant);
}
public static TenantDTO getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}

In the next step, you should modify the interceptor. You will no longer set it in the context of tenantId itself. Instead, set the TenantDTO with access data to all resources using the TenantDomain. You can do this by applying the following:

@Component
@AllArgsConstructor
public class TenantRequestInterceptor implements AsyncHandlerInterceptor{
private final SecurityDomain securityDomain;
private final TenantDomain tenantDomain;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
return Optional.ofNullable(request)
.map(securityDomain::getTenantIdFromJwt)
.map(tenantDomain::setTenantInContext)
.orElse(false);
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler, ModelAndView
modelAndView) {
TenantContext.clear();
}
}

Hibernate

In terms of modifying the Hibernate database, you will introduce only minor changes in the TenantIdentifierResolver and SchemaMultiTenantConnectionProvider related to altering the type in the TenantContext. Simply use these lines of code:

public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
return getTenantIdentifier()
.orElse(TenantContext.DEFAULT_TENANT_ID);
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
private Optional<String> getTenantIdentifier() {
return Optional
.ofNullable(TenantContext.getCurrentTenant())
.map(TenantDTO::getTenantId);
}
}public class SchemaMultiTenantConnectionProvider extends AbstractMultiTenantConnectionProvider { private static final String HIBERNATE_PROPERTIES_PATH =
"/application.properties";
private static final String DEFAULT_SCHEMA_NAME = "public";
private final Map<String, ConnectionProvider>
connectionProviderMap;
public SchemaMultiTenantConnectionProvider() {
this.connectionProviderMap = new HashMap();
}
@Override
public Connection getConnection(String tenantIdentifier) throws
SQLException {
Connection connection = super
.getConnection(tenantIdentifier);
connection.createStatement()
.execute(String.format("SET SCHEMA '%s';",
getTenantSchema()));
return connection;
}
@Override
protected ConnectionProvider getAnyConnectionProvider() {
return getConnectionProvider(TenantContext.DEFAULT_TENANT_ID);
}
@Override
protected ConnectionProvider selectConnectionProvider(String
tenantIdentifier) {
return getConnectionProvider(tenantIdentifier);
}
private String getTenantSchema() {
return Optional
.ofNullable(TenantContext.getCurrentTenant())
.map(TenantDTO::getDataSource)
.map(DataSourceDTO::getSchemaName)
.orElse(DEFAULT_SCHEMA_NAME);
}
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("Cannot create new connection provider for tenant: "+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("Cannot open hibernate properties: "+ HIBERNATE_PROPERTIES_PATH);
}
}
private ConnectionProvider initConnectionProvider(Properties hibernateProperties) {
DriverManagerConnectionProviderImpl connectionProvider = new DriverManagerConnectionProviderImpl();
connectionProvider.configure(hibernateProperties);
return connectionProvider;
}
}

Thanks to this modification, you can transfer the resource access data to the database. But don’t forget about data security. You should consider encrypting your data within the database. For example, you can apply Hibernate database password encryption in the hibernate.cfg.xml file.

Another way to protect your data may be moving TenantDomain to a separate application with restricted access. You can refer to the data via REST. Then, secure the request with an additional token or OAuth2 for our application. This simple method can ensure the safety of your tenants’ data without being a burden for you.

Flyway

You can start by preparing the Flyway configuration in the application.properties. It goes like this:

flyway.url=${hibernate.connection.url}
flyway.user=ddl_user_name
flyway.password=ddl_user_password
flyway.baselineOnMigrate=true
flyway.enabled=false

Remember about providing the permissions for users needed by Flyway as well as disabling Flyway with flyway.enabled=false.

By default, Flyway uses the SQL files contained in the db/migration location. In this case, you can divide our folder into two subfolders: default and tenant. The first one contains scripts modifying individual schemas, i.e., in our case — the scheme with the “tenant” table. Use the second one to store our scripts for all schemas of each tenant.

Now, you need to create the org.flywaydb.core.Flyway bean which will let you run the migration for scripts from the db/migration/default location and the org.springframework.boot.CommandLineRunner bean. You will use those to run scripts from db/migration/tenant on all tenant schemas. Interestingly, you can carry out independent versioning for each folder because every schema includes its own“flyway_schema_history” table. Here’s the entire operation:

@Configuration
class FlywayConfiguration {

private final String flywayUrl;
private final String flywayUser;
private final String flywayPassword;
private final String flywayDriver;

DataSource dataSource;

public FlywayConfiguration(DataSource dataSource,
@Value("${flyway.url}") String flywayUrl,
@Value("${flyway.user}") String flywayUser,
@Value("${flyway.password}") String flywayPassword,
@Value("${spring.datasource.driverClassName}")
String flywayDriver) {
this.flywayUrl = flywayUrl;
this.flywayUser = flywayUser;
this.flywayPassword = flywayPassword;
this.flywayDriver = flywayDriver;
this.dataSource = createFlywayDataSource();
}
@Bean
FlywayBuilder flywayBuilder() {
return new FlywayBuilder(dataSource);
}
@Bean
Flyway flyway() {
Flyway flyway = flywayBuilder()
.createFlyway(TenantContext.DEFAULT_TENANT_ID);
flyway.migrate();
return flyway;
}

@Bean
CommandLineRunner commandLineRunner(TenantDomain tenantDomain,
DataSource dataSource) {
return args -> {
tenantDomain.getAllTenants().forEach(tenant -> {
Flyway flyway = flywayBuilder()
.createFlyway(tenant);
flyway.migrate();
});
};
}

private DataSource createFlywayDataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName(flywayDriver);
dataSource.setUrl(flywayUrl);
dataSource.setUsername(flywayUser);
dataSource.setPassword(flywayPassword);
return dataSource;
}

}

At this point, you should use the builder pattern because soon you will want to add new schemas for tenants. You probably don’t want to create a Flyway object on the tenant domain level — the implementation should remain in the configuration package. You should create the builder as a bean too. Thanks to this, your builder can be used more easily within the domain. To make this happen, you will need the DataSource to establish a connection to the database. Unfortunately, you must set up this object manually because you shouldn’t use the Hibernate parameters. Those will be required for preparing the DataSource to handle the proper application queries.

When we discuss the CommandLineRunner building method, you could use tenantDomain to retrieve the access data of all your tenants. Even if you don’t know the names of your schemas, you can run scripts on all tenant schemas. To make things interesting, a “flyway_schema_history” table will be created within each schema that, in turn, will be managed independently by Flyway. The lines of code for this are as follows:

@AllArgsConstructorpublic class FlywayBuilder {     private static final String DEFAULT_SCHEMA_LOCATION =
"db/migration/default";
private static final String TENANT_SCHEMA_LOCATION =
"db/migration/tenants";
private final DataSource dataSource;
Flyway createFlyway(String schemaName) {
return Flyway.configure()
.dataSource(dataSource)
.locations(DEFAULT_SCHEMA_LOCATION)
.schemas(schemaName)
.load();
}
public Flyway createFlyway(TenantDTO tenant) {
return Flyway.configure()
.dataSource(dataSource)
.locations(TENANT_SCHEMA_LOCATION)
.schemas(getSchemaName(tenant))
.load();
}
private String getSchemaName(TenantDTO tenant) {
return Optional.ofNullable(tenant)
.map(TenantDTO::getDataSource)
.map(DataSourceDTO::getSchemaName)
.orElseThrow(() -> new RuntimeException("tenant
model without schema name"));
}
}

At the builder level, you must choose the path for SQL scripts to be used. Our suggestion is the strategy where creating a schema-based Flyway object will work for a single schema. In terms of schemas for each tenant, the best course of action is to use TenantDTO as an argument. Additional security is provided by access modifiers. The method used with TenantDomain is the only one granting public access. The former method provides package access because the builder is in the same package as the configuration class for Flyway.

Flyway — create a new tenant

The last missing element to achieving full application automation is adding a new tenant. If you enable the registration of a new customer (tenant), you will want to automatically prepare a schema with the current model for them. Surprisingly, using Flyway to perform this action is very simple.

In a real application, creating a new schema for the tenant will probably be one of the registration stages. What is more, the call will come from another domain. For the sake of our example, we will simplify the case and issue a REST endpoint to present the mode of operation clearly:

@RestController
@RequestMapping("/tenant")
@AllArgsConstructor
public class TenantController {
private TenantDomain tenantDomain;
@PutMapping
public void createTenant(@RequestBody TenantDTO dto) {
tenantDomain.createNewTenant(dto);
}
}

Next, you need to extend the tenant domain with a method for creating a new tenant:

public interface TenantDomain {     public boolean setTenantInContext(String tenantId);
public void createNewTenant(TenantDTO dto);
public List<TenantDTO> getAllTenants();
}

The implementation of the new method is only a delegation to the service layer:

@Override
public List<TenantDTO> getAllTenants() {
return service.findAllTenants();}

Now, let’s move on to the business logic within the service. To create a new schema, include the FlywayBuilder dependency into the TenantService:

@Service
@Transactional
@AllArgsConstructor
class TenantService implements TenantMapper {
private final TenantRepository repository;
private final FlywayBuilder flywayBuilder;
public List<TenantDTO> findAllTenants() {
return repository.findAll()
.stream()
.map(this::mapToDto)
.collect(toList());
}
public TenantDTO findByTenantId(String tenantId) {
return Optional.ofNullable(tenantId)
.map(repository::findByTenantId)
.map(this::mapToDto)
.orElseThrow(() -> new CredentialsException(String.format("Uknown tenant")));
}
public Tenant createNewTenant(TenantDTO dto) {
return Optional.ofNullable(dto)
.map(this::buildDatabaseSchema)
.map(this::mapToEntity)
.map(repository::save)
.orElseThrow(() -> new RuntimeException("Cannot
add new tenant"));
}
private TenantDTO buildDatabaseSchema(TenantDTO dto) {
flywayBuilder.createFlyway(dto)
.migrate();
return dto;
}
}

In this way, the process of building a Flyway object remains in the configuration. Here, you will only use it in the buildDatabaseSchema method. All the magic takes place within Flyway. In the TenantDTO, you transfer the schema name. Consequently, Flyway knows which schema should include the migration. Since there is no schema with a given name, Flyway will first create a schema. Afterward, the system will develop a schema history table in it, and finally run all scripts in the db/migration/tenant location. Now, you have your new schema, and all SQLs have been called. The last thing to do is save the tenant to the database using the TenantRepository.

Multitenancy automation successful!

Let’s summarize the entire procedure. To automate software multitenancy in the separate schema strategy, you must separate TenantDomain which will take over the tenant support operations (read/write). This will involve minor changes to the TenantContext, TenantRequestInterceptor, TenantIdentifierResolver, and MultiTenantConnectionProvider classes related to the change of type to the TenantDTO. This also involves delegating the functionality to the domain. Flyway will play a key role in the automation process. It will ensure a consistent model in all schemas. Just note that you don’t know the names of the future tenants’ schemas at the development stage. In addition, you will need Flyway to create a new tenant schema and build the current model.

During the implementation stage, you should keep in mind the data protection issues as you store access data to external websites in your database. For the sake of security, you can encrypt specific tables, for example, through the Jasypt library. It’s also a good idea to transfer the tenant domain to a separate application allowing for additional access safety. When using Flyway, make sure that you provide two users with different authorization levels within the database. The application should access the database with a user having only DML privileges while Flyway will require another user with DDL privileges.

Completing all these steps will guarantee the automation of adding new tenants without the need to restart the application. Thus, you have achieved the peak of efficiency in managing your multi-tenant architecture. If you are interested in a more detailed representation of this automation process, you can find the entire project here.

As Deviniti, we share our expertise to support world companies in conducting successful operations and improving their workflow. For the last 17 years, we have been analyzing thousands of businesses to provide only the best solutions and quality of service. If you need a trusted technological partner who will help you save time and earn profits (we prefer hard data to measure results), please contact us for a free consultation and software/business recommendations.

Still hungry for knowledge? Learn more tips and tricks from our other articles:

--

--