In this tutorial we will look at some of the important features required for a multi-tenant service and how do we leverage springframework to deliver a service that is truly enabled for a modern multi-tenant service.
Here are couple of the important aspects of a multi-tenant service.
- Everybody gets their own endpoints
- Everybody can be given their own databases
Let's design how our endpoint URLs would look like keeping tenancy in mind. We add a discriminator in the URL that identified a tenant. For example our OAuth URLs would become something like below.
http://example.com/tenant1/oauth/token http://example.com/tenant1/oauth/check_token
To accomplish, we define our RequestMapping with an embedded tenant variable that becomes part of each of the URLs.
public static final String BASE_ENDPOINT = "/{tenant}"; public static final String USER_ENDPOINT = BASE_ENDPOINT + "/user"; public static final String REGISTRATION_ENDPOINT = BASE_ENDPOINT + "/registration";
As we can see, the first part of the URL defines the tenant. It is not mandatory but I also design the service so that a header is expected that also defines the tenant. This is just to guard against some client mistakenly calling a wrong tenant because the same tenant has to be added in two places. We will use following header.
X-tenant:tenant1
Every incoming request into the system needs to know the tenant and the authenticated user that is part of the request. We define a sequence of filters for this purpose. Since these filters need to be called in a particular order, we define the order as follow.
public static final int TENANT_HEADER_PRECEDENCE = Ordered.HIGHEST_PRECEDENCE; public static final int SEED_DATA_PRECEDENCE = TENANT_HEADER_PRECEDENCE - 1; public static final int TENANT_PRECEDENCE = SEED_DATA_PRECEDENCE - 1; public static final int USER_PRECEDENCE = Ordered.LOWEST_PRECEDENCE;
As you can see we are going to define four filters which will perform specific functions.
- TenantHeader filter will extract tenant header from the incoming request, match the URL piece with the header and set it in a ThreadLocal variable.
- SeedData filter is only require to create some seed data to make the service usable. We need a default tenant in the system so that we can start making some requests. This doesn't do anything most of the time.
- Tenant filter will extract the tenant object and set it into another ThreadLocal variable.
- User filter is the last in the precedence and will extract the current authenticated user and will store it into a ThreadLocal.
We are using following TutorialRequestContext class to store these thread local variables.
Now let's look at these filters one by one.
This filter just extracts the X-tenant header stores in the thread local.
This filter checks that there should be atleast one tenant in the system, if not found, it inserts a default tenant. This is required so that we can use rest calls.
This filter extract the complete Tenant object from the database and stores it into the thread local.
This filter extract currently authenticate user and populates it in the thread local.
In the last tutorial, we used the spring provided default ClientDetailsService, we create our own service in this tutorial to make sure we can have a schema that we like. To do that we need a entity, TutorialClientDetails and a service TutorialClientDetailsService.
This entity just implements the ClientDetails interface
This entity just implements the ClientDetails interface
This service needs to implement a method loadClientByClientId.
Now that all the foundation work is in place, we get down to making our service multitenant. The first things that we need to do is to remove all the old dataSources that we had defined. We will now define a routing data source that will choose the right data source based on the tenant that we are taking to. This is where our tenant discriminator thread local will be usedful. Take a look at the following class.
Here we implement a method determineCurrentLookupKey which uses thread local to identify the current tenant and returns the key.
The bulk of smartness lies in the class TutorialMultitenantConfig, specifically the dataSource bean that returns the default dataSource for the system. What we are assuming that within our resource directory, we will have a tenants subdirectory and within that we will have one properties file per tenant. The property file will look like below.
Here we have added usual spring data source properties alongwith a name property which will be the name of the tenant. This name will be matched with the name in the URL and the header.
In the class TutorialMultitenantConfig, look at the definition of variable TENANT_PROPERTIES_RESOURCE, this basically looks up for all the *.properties file in the tenant directory. Lines 61 through 74, create a data source object for each of the tenants and store these in a map with key being the tenant name. We remember determineCurrentLookupKey method which returned the name of current tenant, that return value is used to fetch appropriate data source object for the request being processed. Line 88 defines a default data source that is used if there is no data source present for the given tenant.
Now our multi tenanted service is ready and it is time to test. The first thing that we need to do is create couple of more databases with exact same schema as the first database. Please keep in mind that this has to be done manually even if you have defined ddl-auto property. Just take a mysqldump of the first database and import in two other databases.
We also need to make sure that the system atleast has one tenant defined in each of the databases. This is required in order to make sure we are able to use the rest calls. The best approach is to have a default tenant and then take a dump so that default tenant is also copied in each of the databases. We have created an admin user which has ADMIN role. We call the user endpoint that will return the list of all the users.
$ curl --request GET \ > --url http://localhost:8081/tenant1/user \ > --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTI5Mzc1MzksInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiVVNFUiIsIkFETUlOIl0sImp0aSI6IjRmOTdiNThkLTI1NTEtNDA4Yi04ZWM4LWUzZGZmZWQ3MTQ2NiIsImNsaWVudF9pZCI6InN1cGVyc2VjcmV0Y2xpZW50Iiwic2NvcGUiOlsicmVhZCIsImNvZGUiLCJ3cml0ZSJdfQ.fWt_H-ORHP44xOIljoqMfVIeGkqJGQqUBMj8paVxAPM' \ > --header 'cache-control: no-cache' \ > --header 'content-type: application/json' \ > --header 'postman-token: 76b3525e-bc3b-e629-91b6-a75253f657d8' \ > --header 'x-tenant: tenant1' |python -m json.tool [ { "createdAt": 1592936869000, "createdBy": "UnAuthenticated", "email": "admin@springframework.in", "fullname": "Spring Tutorial Admin", "grantedAuthorities": [ "ADMIN", "USER" ], "id": 6, "mask": 1, "tenantId": 1, "updatedAt": 1592936869000, "updatedBy": "UnAuthenticated", "username": "admin" }, { "createdAt": 1592904896000, "createdBy": "UnAuthenticated", "email": "defaultuser@defaultadmin.com", "fullname": "Default User", "grantedAuthorities": [], "id": 2, "mask": 0, "tenantId": 1, "updatedAt": 1592904896000, "updatedBy": "UnAuthenticated", "username": "defaultuser" }, { "createdAt": 1592904900000, "createdBy": "UnAuthenticated", "email": "vinay@avasthi.com", "fullname": "Vinay Avasthi", "grantedAuthorities": [], "id": 3, "mask": 1, "tenantId": 1, "updatedAt": 1592904900000, "updatedBy": "UnAuthenticated", "username": "vavasthi" } ]
Now we run the same query in tenant2
$ curl --request GET --url http://localhost:8081/tenant2/user --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTI5MzgzMDcsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiVVNFUiIsIkFETUlOIl0sImp0aSI6IjJmOWVkOTYxLTk5MjktNDI3Zi1iZGU4LTc5NGIwYWNhMGYzNiIsImNsaWVudF9pZCI6InN1cGVyc2VjcmV0Y2xpZW50Iiwic2NvcGUiOlsicmVhZCIsImNvZGUiLCJ3cml0ZSJdfQ.KNzjB_JqJDaVI5vZNK-OcXDgvM5Uwt4I8tsVCazerpU' --header 'cache-control: no-cache' --header 'content-type: application/json' --header 'postman-token: b1a9651c-e4c0-7b2e-c7b3-e0b3e06fa40e' --header 'x-tenant: tenant2' |python -m json.tool [ { "createdAt": 1592904896000, "createdBy": "UnAuthenticated", "email": "defaultuser@defaultadmin.com", "fullname": "Default User", "grantedAuthorities": [], "id": 2, "mask": 0, "tenantId": 1, "updatedAt": 1592904896000, "updatedBy": "UnAuthenticated", "username": "defaultuser" }, { "createdAt": 1592904900000, "createdBy": "UnAuthenticated", "email": "vinay@avasthi.com", "fullname": "Vinay Avasthi", "grantedAuthorities": [], "id": 3, "mask": 1, "tenantId": 1, "updatedAt": 1592904900000, "updatedBy": "UnAuthenticated", "username": "vavasthi" }, { "createdAt": 1592938002000, "createdBy": "UnAuthenticated", "email": "ut2@springframework.in", "fullname": "User in T2", "grantedAuthorities": [], "id": 6, "mask": 1, "tenantId": 1, "updatedAt": 1592938002000, "updatedBy": "UnAuthenticated", "username": "userint2" }, { "createdAt": 1592937749000, "createdBy": "UnAuthenticated", "email": "admin@springframework.in", "fullname": "Springframework Tenant2 Administrator", "grantedAuthorities": [ "ADMIN", "USER" ], "id": 5, "mask": 1, "tenantId": 1, "updatedAt": 1592937749000, "updatedBy": "UnAuthenticated", "username": "admin" } ]
As we can see, we are seeing two totally different sets of users which are stored in two totally different sets of databases.
Complete code for this blog post is available in my github repository here.