POSETTE 2024 is a wrap! 💯 Thanks for joining the fun! Missed it? Watch all 42 talks online 🍿
POSETTE 2024 is a wrap! 💯 Thanks for joining the fun! Missed it? Watch all 42 talks online 🍿
Written by Joe Kutner
February 13, 2018
If you're building a Java app, there's a good chance you're using Hibernate. The Hibernate ORM is a nearly ubiquitous choice for Java developers who need to interact with a relational database. It's mature, widely supported, and feature rich—as demonstrated by its support for multi tenant applications.
Hibernate officially supports two different multi-tenancy mechanisms: separate database and separate schema. Unfortunately, both of these mechanisms come with some downsides in terms of scaling. A third Hibernate multi-tenancy mechanism, a tenant discriminator, also exists, and it’s usable—but it’s still considered a work-in-progress by some. Unlike the separate database and separate schema approaches, which require distinct database connections for each tenant, Hibernate’s tenant discriminator model stores tenant data in a single database and partitions records with either a simple column value or a complex SQL formula.
But fear not, despite the unfinished state of Hibernate's built-in support for a tenant discriminator (or in simple terms tenant_id
), it's possible to implement your own discriminator using standard Spring, Hibernate, and AspectJ mechanisms that work quite well. The Hibernate tenant discriminator model works well as you start small on a single-node Postgres database, and even better, tenant discriminator can continue to scale as your data grows by leveraging the Citus extension to Postgres.
In this post, you'll learn how to add a tenant id to a Spring Boot 2 application, and use it to partition database records.
We'll use an existing Spring Boot 2 example to demonstrate multi-tenancy. But you can apply the same methods described in this post to any standard Spring app.
The example app has a single Employee
model and the necessary Repository, Service, and Controller classes surrounding it to create a RESTful HTTP interface. It's configured to use a PostgreSQL database, which you'll need to run locally if you want to test the app.
Otherwise, the example is fairly standard—except for its multi-tenant support. Let's look at how the multi-tenant app support is implemented.
To start, you'll need a way to determine which tenant a client is making requests for. Every multi-tenant application does this a little differently depending on business needs, but for this example you'll use an HTTP header. The HTTP header is read by a Servlet Filter and stored in a ThreadLocal variable. The Servlet Filter's doFilter()
method looks like this:
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) servletResponse;
HttpServletRequest request = (HttpServletRequest) servletRequest;
String tenantHeader = request.getHeader("X-TenantID");
if (tenantHeader != null && !tenantHeader.isEmpty()) {
TenantContext.setCurrentTenant(tenantHeader);
} else {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\": \"No tenant header supplied\"}");
response.getWriter().flush();
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
The filter gets the value of the "X-TenantID" HTTP header for every request and sends it to the TenantContext
class. If no header is provided, it responds with an error. The TenantContext
looks like this:
public class TenantContext {
private static ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void setCurrentTenant(String tenant) {
currentTenant.set(tenant);
}
public static void clear() {
currentTenant.set(null);
}
}
When setCurrentTenant()
is called, it adds the tenant value to the ThreadLocal
instance for the current thread. This is the thread that will handle the entire lifecycle of the request, which means the value will be available anywhere in the application that this request is being processed.
Now you can use the TenantContext
to partion your database records.
The example application's Employee
model has a few simple properties and a tenantId
. The tenantId
is what you'll use to determine which tenant a record belongs to. The value of tenantId
will correlate to the value passed in with the HTTP header and added to the ThreadLocal context.
The Employee
model is shown here:
@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Employee implements TenantSupport {
@Id
@GeneratedValue
private UUID id;
private String firstName;
private String lastName;
private String tenantId;
}
The class is defined with standard JPA and Hibernate annotations. Notably, the @FilterDef
and @Filter
annotations will allow us to inject a tenant discriminator clause to every SQL query generated for this model. To do this, you'll use AspectJ advice, which looks like this:
@Aspect
@Component
public class EmployeeServiceAspect {
@Before("execution(* com.example.service.EmployeeService.*(..)) && !execution(* com.example.service.EmployeeService.run(..)) && target(employeeService)")
public void aroundExecution(JoinPoint pjp, EmployeeService employeeService) throws Throwable {
org.hibernate.Filter filter = employeeService.entityManager.unwrap(Session.class).enableFilter("tenantFilter");
filter.setParameter("tenantId", TenantContext.getCurrentTenant());
filter.validate();
}
}
The aroundExecution()
method enables the Hibernate filter on the Employee
model when any of the data-access methods on the EmployeeService
class are executed. It populates the filter criteria with the tenant value from the TenantContext
to limit the query results to only those records that match.
Finally, you must ensure a tenant discriminator is added whenever a database recorded is created or destroyed. For this, you'll use a Hibernate Interceptor, as shown here:
new EmptyInterceptor() {
@Override
public boolean onSave(Object entity, Serializable id, Object[] state, String[] propertyNames, Type[] types) {
if (entity instanceof TenantSupport) {
((TenantSupport) entity).setTenantId(TenantContext.getCurrentTenant());
}
return false;
}
@Override
public void onDelete(Object entity, Serializable id, Object[] state, String[] propertyNames, Type[] types) {
if (entity instanceof TenantSupport) {
((TenantSupport) entity).setTenantId(TenantContext.getCurrentTenant());
}
}
@Override
public boolean onFlushDirty(Object entity, Serializable id, Object[] currentState, Object[] previousState, String[] propertyNames, Type[] types) {
if (entity instanceof TenantSupport) {
((TenantSupport) entity).setTenantId(TenantContext.getCurrentTenant());
}
return false;
}
};
Like the AspectJ advice, the Interceptor uses the value from the TenantContext
to populate the tenantId
field.
Now you're ready to give it a spin.
You can run the sample app locally, or deploy it to Heroku by creating a Heroku account, installing the Heroku CLI, and running the following commands:
$ heroku create
$ git push heroku master
To run the app locally, you'll need to start PostgreSQL and create a database with this command:
$ createdb spring-multi-tenancy
Then run the application with the following command on Mac and Linux:
$ ./mvnw spring-boot:run
Or this command on Windows:
$ mvnw.cmd spring-boot:run
In another terminal, use cURL or a similar HTTP client to test the service. First, get the list of employees for "tenant1", which has been pre-populated with a John Doe employee (if you're running the app on Heroku, replace localhost:8080
with the herokuapp.com
URL for your app):
$ curl localhost:8080/employees -H "X-TenantID: tenant1"
[{"userId":"16b5308b-6bb8-4a75-ae93-66dc71a0b981","firstName":"John","lastName":"Doe","tenantId":"tenant1"}]
Then create your own tenant byt changing the X-TenantID
header, and get a list of its employees:
$ curl localhost:8080/employees -H "X-TenantID: citus"
[]
The list empty because no employees have been created yet. Create the first one with this command:
$ curl localhost:8080/employees -H "X-TenantID: citus" \
-X POST -d '{"firstName":"Joe", "lastName":"Kutner"}' \
-H "Content-Type: application/json"
{"userId":"c77ad6bb-b2ad-47f7-b21c-3e50deb6a574","firstName":"Joe","lastName":"Kutner","tenantId":"citus"}
$ curl localhost:8080/employees -H "X-TenantID: citus"
[{"userId":"c77ad6bb-b2ad-47f7-b21c-3e50deb6a574","firstName":"Joe","lastName":"Kutner","tenantId":"citus"}]
Each value for the "X-TenantID" will return a different result.
Both the separate database and separate schema approaches, which are natively supported by Hibernate, require distinct database connections for each tenant. But the tenant discriminator approach alters every SQL statement sent to the database to reference a "tenant identifier". Using a separate database or separate schema tends to create more of an operational burden and, depending on your infrastructure, may not be an option. The tenant discriminator shifts the work to application developers, but also gives them more flexibility and better scalability.
The tenant discriminator approach provides great value in terms of scalability. With a multi-tenant app using the tenant discriminator approach, you can start small with a singlenode Postgres database. And then when you outgrow the performance of that single-node Postgres database, you can transition smoothly with no downtime to the Citus distributed database. Instead of needing to maintain separate databases or incur the overhead of separate schemas for each tenant, your database remains as "normal" as possible. In this way, sharding becomes a more viable option.
For more information on multi-tenancy support in Hibernate, see the official Hibernate documentation. Or if you already have a multi-tenant application that needs to scale consider creating your Citus Cloud account today to start scaling your database.
This post was a guest post by Joe Kutner. Joe is the Java languages owner at Heroku.