Multi-tenancy is a fundamental architecture which can be used to share IT resources cost-efficiently and securely in cloud environments, in which a single instance of software runs on a server and serves multiple tenants.
It's been years since we first heard about it; it came out again riding the wave of cloud computing, so we can assume that multi-tenancy is a consolidated architecture and the benefits in terms of maintainability and costs are well known.
APIs are the backbone of a distributed cloud architecture, so building a multi-tenant API is the natural aftermath in this scenario.
In this article I will show you how to build a simple multi-tenant RESTful API using Java in a quick and easy way, dramatically reducing the configuration code amount required by the most frequently adopted solutions.
One of the most common solution is relying on Hibernate to implement multi-tenancy behaviour at DataSource level. Hibernate requires two interfaces to be implemented: CurrentTenantIdentifierResolver
to resolve what the application considers the current tenant identifier and MultiTenantConnectionProvider
to obtain Connections in a tenant specific manner. This could be tricky and not so trivial, possibly leading to boilerplate code rising.
And finally, last but not least, this solution is only suitable for a JPA implementation, since Hibernate is directly used as tenant resolver and connection router.
Furthermore, the Spring Framework is often used in conjunction with Hibernate to build a multi-tenant API or application. One of the most useful aspects of the core Spring architecture is the availability of scopes: in a multi-tenant scenario, sooner or later you'll surely miss a tenant scope, which is not available out-of-the-box. Creating a custom scope in Spring is not trivial, it requires a deep knowledge of the Spring architecture and a lot of code to implement and register the new scope.
You can check the Spring+Hibernate version of the example shown in the second part of this article here: https://github.com/fparoni/spring-boot-hibernate-multitenant-jaxrs-api.
We will use the Holon platform to achieve this goal, relying on the platform's native multi-tenant support. I'll show you a working example of a simple RESTful API, using the following Holon platform's components and configuration facilities:
TenantResolver
interface to provide the current tenant identifierThe complete example is available on GitHub: https://github.com/holon-platform/spring-boot-jaxrs-multitenant-api-example.
Our RESTful API implements the CRUD operations to manage a simple Product, providing methods to create, update, delete and query the product data.
The multi-tenancy architecture of this example is organized per schema: each tenant is bound to a separate database schema and will access data through a different Java DataSource
.
Each time an API request is performed we need to know the caller tenant identifier, to correctly route the persistence operations to the right database schema.
The three most common ways to provide the tenant identifier are:
We will use the second option, through a custom HTTP header called 'X-TENANT-ID'.
An example to use JWT to provide the tenant identifier is available in the jwt branch of the GitHub example repository.
Besides the Holon platform components listed above, for our example we will use:
The Holon Datastore API will be used to access data in a technology-indipendent way (using JDBC in this example is only a matter of configuration) and the Holon Property model to define the product data model. Check to Holon reference documentation for detailed information.
The result will be a fully working example made of just three Java classes: now let's build our example step by step.
We'll use the Holon Platform BOM (Bill of Materials) to easily obtain the dependencies we need.
<properties>
<holon.platform.version>5.0.6</holon.platform.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Holon Platform BOM -->
<dependency>
<groupId>com.holon-platform</groupId>
<artifactId>bom</artifactId>
<version>${holon.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
The Holon JDBC Datastore Spring Boot starter is used to automatically configure the data access layer:
<!-- Holon JDBC Datastore with HikariCP starter -->
<dependency>
<groupId>com.holon-platform.jdbc</groupId>
<artifactId>holon-starter-jdbc-datastore-hikaricp</artifactId>
</dependency>
Likewise we declare the Holon JAX-RS Spring Boot starter dependency to use and auto-configure Jersey as JAX-RS implementation.
<!-- Holon JAX-RS using Jersey -->
<dependency>
<groupId>com.holon-platform.jaxrs</groupId>
<artifactId>holon-starter-jersey</artifactId>
</dependency>
At last, the H2 database dependency:
<!-- H2 database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.196</version>
</dependency>
To configure our Spring application we will use an application.yml configuration file.
We simulate that two tenants are available, the first bound to a schema named tenant1 and the second associated to a schema named tenant2. So we need two DataSource instances, one for each schema.
Auto-configure more than one DataSource with the default Spring Boot DataSource auto-configuration is not possible without writing additional code. For this reason, we'll rely on the Holon platform DataSource auto-configuration facilities to define the two tenant DataSources, through a holon.datasource configuration property structure like this:
holon:
datasource:
tenant1:
url: "jdbc:h2:mem:tenant1"
username: "sa"
tenant2:
url: "jdbc:h2:mem:tenant2"
username: "sa"
@Qualifier(“tenant1”)
annotation.
To be up and running at startup we will use a couple of .sql file to create a sample table named 'product' and one row for each tenant.
As I said before we need just 3 Java classes.
First of all, we create the Application
class, which acts as the Spring Boot application entry-point and as the main Spring configuration class:
@SpringBootApplication
public class Application {
@Bean
@RequestScope
public TenantResolver tenantResolver(HttpServletRequest request) {
return () -> Optional.ofNullable(request.getHeader("X-TENANT-ID"));
}
@Bean
@ScopeTenant
public Datastore datastore(BeanFactory beanFactory, TenantResolver tenantResolver) {
// get current tenant id
String tenantId = tenantResolver.getTenantId()
.orElseThrow(() -> new IllegalStateException("No tenant id available"));
// get Datastore using tenantId qualifier
return BeanFactoryAnnotationUtils.qualifiedBeanOfType(beanFactory, Datastore.class, tenantId);
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
We have configured two Spring beans:
TenantResolver
implemetation to provide the current tenant identifier and to enable the Holon platform tenant scope. In this example, we want to use a custom HTTP header named “X-TENANT-ID” to obtain the tenant identifier from the caller. We need the current HttpServletRequest
to obtain the header value, so we declare the TenantResolver as request-scoped and rely on Spring to obtain a reference to the current request.Datastore
is correctly retrieved by Spring through the tenant identifier which acts as bean qualifier.
The two Datastore instances have already been configured by the Holon Platform Spring Boot autoconfiguration facilities, using the two DataSources declared through the holon.datasource.* configuration properties. The current tenant-related Datastore is retrieved using the bean qualifier which matches with the current tenant id, provided by the TenantResolver
interface.
The Product
interface represents our data model:
/**
* Product model
*/
public interface Product {
static final PathProperty<Long> ID = PathProperty.create("id", Long.class);
static final PathProperty<String> SKU = PathProperty.create("sku", String.class);
static final PathProperty<String> DESCRIPTION = PathProperty.create("description", String.class);
static final PathProperty<String> CATEGORY = PathProperty.create("category", String.class);
static final PathProperty<Double> UNIT_PRICE = PathProperty.create("price", Double.class)
// not negative value validator
.validator(Validator.notNegative());
// Product property set
static final PropertySet<?> PRODUCT = PropertySet.of(ID, SKU, DESCRIPTION, CATEGORY, UNIT_PRICE);
// "products" table DataTarget
static final DataTarget<String> TARGET = DataTarget.named("products");
}
The Holon platform Property model is used to define the product data model. Deepening this topic is out of the scope of this article, check the official Holon documentation for further details.
The ProductEndpoint
class represents the JAX-RS resource that will provide the API CRUD, using JSON as data exchange format. The class is annotated with @Component
to make it available as a Spring component and we rely on the Holon platform Jersey auto-configuration facilities to automatically register the endpoint in the JAX-RS application:
@Component
@Path("/api")
public class ProductEndpoint {
@Autowired
private Datastore datastore;
/*
* Get a list of products PropertyBox in JSON.
*/
@GET
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
public List<PropertyBox> getProducts() {
return datastore.query().target(TARGET).list(PRODUCT);
}
/*
* Get a product PropertyBox in JSON.
*/
@GET
@Path("/products/{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response getProduct(@PathParam("id") Long id) {
return datastore.query().target(TARGET).filter(ID.eq(id)).findOne(PRODUCT).map(p -> Response.ok(p).build())
.orElse(Response.status(Status.NOT_FOUND).build());
}
/*
* Create a product. The @PropertySetRef must be used to declare the request
* PropertyBox property set.
*/
@POST
@Path("/products")
@Consumes(MediaType.APPLICATION_JSON)
public Response addProduct(@PropertySetRef(Product.class) PropertyBox product) {
// set id
long nextId = datastore.query().target(TARGET).findOne(ID.max()).orElse(0L) + 1;
product.setValue(ID, nextId);
// save
datastore.save(TARGET, product);
return Response.created(URI.create("/api/products/" + nextId)).build();
}
/*
* Update a product. The @PropertySetRef must be used to declare the request
* PropertyBox property set.
*/
@PUT
@Path("/products/{id}")
@Consumes(MediaType.APPLICATION_JSON)
public Response updateProduct(@PropertySetRef(Product.class) PropertyBox product) {
return datastore.query().target(TARGET).filter(ID.eq(product.getValue(ID))).findOne(PRODUCT).map(p -> {
datastore.save(TARGET, product);
return Response.noContent().build();
}).orElse(Response.status(Status.NOT_FOUND).build());
}
/*
* Delete a product by id.
*/
@DELETE
@Path("/products/{id}")
@Consumes(MediaType.APPLICATION_JSON)
public Response deleteProduct(@PathParam("id") Long id) {
datastore.bulkDelete(TARGET).filter(ID.eq(id)).execute();
return Response.noContent().build();
}
}
Note that we injected the Datastore instance to perform data access operations: thanks to tenant scope with which the Datastore bean is declared, we'll obtain the right Datastore instance according to the current tenant identifier.
The spring-boot-maven-plugin will create a runnable jar to have our application up and running using our JRE. We can run application using this maven command:
mvn spring-boot:run
Application is running under Tomcat, listening to port 8080. We can test our endpoint using Postman application and making GET or POST request to the path we've defined before. Let's test the /products operation using both tenants. Setting the X-TENANT-ID header to tenant1 will produce the following result:
Using tenant2 as the X-TENANT-ID header value, the result is:
We've seen how to setup a multi-tenant API quickly, highly reducing the configuration effort and boilerplate code that is often required for this kind of task. The multi-tenant architecture that we've used in our simple API implementation can of course be leveraged for other, more complex, services or applications. The source code of the application can be found at https://github.com/holon-platform/spring-boot-jaxrs-multitenant-api-example.