ASP.NET Core: Microservices and Multi-tenancy
Full code for this article can be found here:
Nowadays, everyone wants to get started building Microservices. Even though, the first rule of object distribution is : don’t do object distribution. I think this translates to Microservices, too.
I believe modern web applications require the following properties :
- Be as small as possible
- Focus on one aspect of the domain
- Ability to deploy as a single unit
- Ability to scale individually
- Should be independent from other domain-centric services
But what happens if you mix in multiple tenants with their own data stores or integrations? You don’t want to re-deploy these microservices for each new tenant and support multiple versions of your microservices across all the various tenants. No. You want to deploy each service once and have it interact with each tenant-specific data store (or integration) based on the context of the consumer.
Similar to ports and adapters, the data store for the tenant is an adapter, it respects a certain contract, it allows the domain to persist and retrieve the data. All other domain logic should reside in the domain layer and should be a shared across all tenants, ideally.
To setup a system described above, you’ll need to write adapters for all your tenants and load the adapter at runtime, when it is time to persist or read data from the tenant data store, you call upon the adapter to persist or read this data. An example, for tenant 1, you could interact with an SQL Server database, whilst for tenant 2 the data is stored in Azure Table Storage. Depending on the context, the correct adapter is loaded and invoked from the domain.
In order to support a setup described above, you need a plugin system, that would, based on the context, inject a corresponding adapter (plugin) and let the domain interact with it.
Prise is a plugin framework for .NET Core, written in .NET Core. It can help you write adapters (plugins) for your microservice. So that you can focus on writing isolated adapters for your project architecture.
Context
A microservice architecture mostly contains of the following elements:
- Domain-centric microservices (products, orders, invoices,…)
- An API Gateway to aggregate these services
- A Service Discovery service to help discover these services
- A message bus, to allow for communication between the services
Each service can have its own logic and data store. Products might use a classic relational database, whilst Orders uses Azure Table Storage as persistence medium.
Whenever you need multi-tenancy, things become can instantly become more complex. Will you handle multi-tenancy inside of the SQL databases ? Or do you need more flexibility ?
There are various reasons why you’d need to consider preparing for multiple tenants when designing your microservices.
For example, the Products service might store its data in separate data store based on whatever tenant is being accessed, via an HTTP Context header
Multi-tenancy within a Microservice
There are several ways to enable Multi-Tenancy for a microservice. For the most flexible approach, a plugin-based multi-tenancy system is most desirable. The plugin needs to respect a certain Contract, the contract will be called from the microservice in order to execute the action on the plugin. A plugin-framework will bridge the call between the microservice API (Host) and the plugin (Remote).
Prise uses a DispatchProxy in order to provide host and remote decoupling. This allows for the host (and the contract) to change, evolve. Whilst supporting plugin backwards-compatibility.
This means that the host will be able to call into older plugins given these conditions:
- The method being called still exists in the same form (method signature)
- The host and plugin are both targeting netcoreapp (version does not matter)
Let’s look into this by using an example, by zooming into the Products Microservice.
Use Case: Products
Our Products Microservice is one of the oldest services we have in our domain. In our legacy system, product data was stored in an SQL Server database using Entity framework. This was ported to EF Core later on.
As new Tenants were enrolled into our system, we experimented with Azure Table Storage and SQL Azure. It became clear that, for the time being, we’ll need to support the legacy SQL databases, the newly enrolled Table Storage and the tenants that were already migrated from on-premise SQL to Azure SQL Databases. Henceforward, we’d have three plugins; OldSQLPlugin, TableStoragePlugin and the new SQLPlugin.
To decide whichever plugin assembly to load, the TenantAssemblySelector will read the incoming Tenant HTTP Header for the GET call and look up whatever plugin needs to be loaded from the appsettings.json.
This could’ve been a lookup table or a whole other service, but for now, loading it from configuration will suffice.
Dynamically loading plugins is what Prise was built for, let’s see how the implementation for our Products Microservice was done by the following example.
The implementation
We start off with our Products.API project, this an ASP.NET Core WebAPI project in .NET Core 3.0. The Contract is a .NET Standard 2.0 class library. The Plugins folder contains all our three plugins:
OldSqlPlugin ➜ .NET Core 2.1
SQLPlugin ➜ .NET Core 3.0
TableStoragePlugin ➜ .NET Core 3.0
The Products.API will expose two endpoints; GetAll() and Get(by id).
You’ll see that the controller expects an IProductsRepository, this is our Contract.
At this point, our contract consists of two API’s (All and Get), this demonstrates the backwards compatibility later on
The loading of the corresponding plugin will be done by Prise, by default, Prise loads plugins from the Plugins directory from wherever the host is executing.
There are two ways of loading a plugin assembly in Prise:
- Explicit loading by providing the name of the plugin assembly
- Implicit loading by scanning a certain folder for candidate plugin assemblies
If you know what plugin (assembly) you expect to be present, Prise can be configured in two ways :
- Staticly hardcode the assembly name of the plugin to be loaded (like MyPlugin.dll)
- Let the context decide whatever assembly needs to be loaded (NameOfMyTenantBasedOnTheHTTPHeader.dll)
Recently, Prise has improved in the field of assembly scanning (searching for types within assemblies). So we will focus on the second approach, implicitly loading.
This is also the more simple approach; compile some plugins, drop them in their own folder in the Plugins directory and let Prise do all the scanning. Easy.
You just need to select which plugin assembly you wish to use.
Configuration of Prise
We will get to the plugin implementations later, first we need to configure Prise. In order to do so, please look at the Startup.cs file for this example.
First. Consider the ConfigureServices method, we first require to Add the IHttpContextAccessor to the service collection, this way we can inject it in the TenantAssemblySelector and read out the Tenant HTTP Header.
To read out the TenantConfig from the appsettings.json file, we bind a class to this configuration section using the Configuration.Bind method and add it as a singleton to the service collection, again, to allowing it to be injected in the TenantAssemblySelector.
Next, is the setup of Prise. This contains the following logic.
AddPrise based on a Contract (IProductsRepository).
Did you know you could register multiple plugin contracts using the AddPrise method?
Then, tell Prise to look for plugins in the Plugins directory from wherever the Host is executing (bin/Debug/netcoreapp3.0)
.WithDefaultOptions(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins"))
Also, tell Prise to do assembly scanning
(using Prise.AssemblyScanning.Discovery). This will start a recursive scan for the any class that is attributed with the Plugin Attribute for the IProductsRepository.
.ScanForAssemblies(composer =>
composer.UseDiscovery())
Now, all of the three plugins (OldSQLPlugin, SQLPlugin and TableStoragePlugin) require one service from the Host application, namely, the IConfiguration. The Configuration contains all the config section entries from the appsettings.json file. Each plugin requires these in order to connect and interact with their data source. These shared services must be configured using the following ConfigureSharedServices builder method.
.ConfigureSharedServices(sharedServices =>
{
sharedServices.AddSingleton(Configuration);
})
Each plugin will receive a new IServiceCollection to do some sort of simple Dependency Injection. With the ConfigureSharedServices method, you’re able to configure this service collection.
Lastly, let Prise use a custom Assembly Selector, this way we can choose whatever assembly gets loaded.
Remember, Assembly Scanning will find 3 plugins, we only need to load 1 per request. The TenantAssemblySelector will load the assembly that corresponds to the Tenant HTTP Header.
.WithAssemblySelector<TenantAssemblySelector<IProductsRepository>>()
Each plugin will require their own configuration, the OldSQLPlugin requires a connectionstring to an on-premise database. The SqlPlugin requires a connectionstring to an Azure Sql database. The TableStoragePlugin requires configuration to connect to a storage account in Azure.
You could load this from a Configuration service, I think this is the most durable approach for handling this. For brevity, however, we’ll just put it in the appsettings.json configuration file.
Implementing the OldSQLPlugin
Now, we’re ready to start implementing our plugin, starting with the plugin that will be loaded for tenants that are using a legacy SQL server on-premise.
Each plugin receives a newly instantiated IServiceCollection, this collection can be enriched by the plugin assembly, in order to create, itself.
For this to work, we need three things to be setup in our plugin assembly:
- an Internal Constructor for the plugin
- a PluginBootstrapper
- a Plugin Factory Method
An internal constructor can only be invoked from within the same assembly (plugin assembly). By decorating a constructor as internal, we protect it from being used outside of this assembly.
The PluginBootstrapper will configure the IServiceCollection for a specific plugin, this is where you’d write the code from a Startup file’s ConfigureServices method. It is pre-filled with the Shared Services from the Host from the ConfigureSharedServices method. This way, you have access to services from outside of the plugin assembly.
The Plugin Factory Method will be invoked from Prise, this is a public static method that will be instantiated using the service collection that was configured in the PluginBootstrapper.
Our OldSQLPlugin will read out the IConfiguration (appsettings,json) values and bind the OldSQLPlugin config section to a new object: SQLPluginConfig.
This contains only the ConnectionString and will be bound to the appsettings.json section:
Now let’s look at the implementation of the IProductsRepository for the OldSQLPlugin.
Remember, the IProductsRepository has two methods we require to implement (All() and Get(int productId)). This we see implemented in the OldSQLPlugin using a dbContext (EF Core).
Above the class declaration, we see the Plugin Attribute, here we tell Prise that this class can be used as an IProductsRepository plugin.
The plugin does not necessarily need to implement the IProductsRepository, but doing so will help with type-safety. It is just best practice. Prise only looks for the Plugin Attribute.
Next we find the internal constructor for the SqlProductsRepository, this is invoked by the PluginFactory, called ThisIsTheFactoryMethod. The method name does not mean anything, Prise is only looking for the PluginFactory Attribute. It will be invoked with a new IServiceCollection which is configured by the PluginBootstrapper for the SqlProductsRepository.
A PluginBootstrapper is linked to a specific plugin. This class is instantiated by Prise and invoked with an IServiceCollection that contains all the services from the ConfigureSharedServices builder method. In this case, it should contain the IConfiguration, which includes everything from the appsettings.json.
In this class, you can see that we enrich the IServiceCollection with a DbConnection, DbContextOptions and ProductsDbContext in order to setup EF Core for this plugin. This is the same as you’d do in the Startup.cs file’s ConfigureServices method.
The EF Core ProductsDbContext class looks simple enough, it uses the Products class from the Contract assembly as an entity.
That’s it, we wrote our first plugin. In order to use it in our Products.API application, we need to publish it and copy it to the bin/Debug/netcoreapp3.0/Plugins directory.
dotnet publish
This is how the bin/Debug/netcoreapp3.0 directory would look like
The Plugins directory should contain the published output of the OldSQLPlugin project.
Our second Plugin, the SQLPlugin
Whilst supporting the old on-premise SQL Servers for our Tenant 1 users, we were in the process of migrating some databases to the cloud, to Azure SQL Databases. For this, we needed another plugin, for the users for Tenant 2.
The implementation is strikingly similar, but the contract has expanded to more than just the All and Get operations.
Hence the SQLPlugin’s SqlProductsRepository is richer in features.
One other big difference is that this plugin is based on netcoreapp 3 and thus it can benefit from using the new Microsoft.Data.SqlClient package. Whilst the OldSQLPlugin was stuck on the legacy System.Data.SqlClient.
Now we need to publish this SQLPlugin alongside our OldSQLPlugin, this can be easily by creating two separate folders in the bin/Debug/netcoreapp3.0/Publish directory of our Products.API host application.
Each folder will contain the published output of the plugins, Prise will scan the assemblies to find these plugins and load the references from the corresponding plugin folder. Just like that.
Our third plugin, using Table Storage
For our new tenants, we’re rolling out a TableStoragePlugin. Their data will be stored in an Azure storage account, using storage tables. This repository requires a bit more setup, but semantically, should be the same as the other plugins. We need a class that implements the IProductsRepository (Contract), an internal constructor, a PluginFactory method and a PluginBootstrapper.
Since we’re not using EF Core, we need a base class to encapsulate the boilerplate of connecting to Azure Table Storage.
And with that, we now have 1 API that can leverage 3 types of data storage plugins. Based on whatever tenant is using the API.
The Plugins directory of the bin/Debug/netcoreapp3.0 Products.API should contain all our 3 published plugins in order to support this, the heavy lifting is done by Prise!
Deploying our plugin-based Microservice
Deploying such a microservice is rather simple, the Products.API can be deployed simply by doing a dotnet publish
for the Release configuration and copying it to your web application (IIS or Azure). In addition, there needs to be a Plugins directory in the published web application. This will contain all the published plugins that can be loaded at runtime, based on the Tenant HTTP Header.
As mentioned earlier, the TenantAssemblySelector will load the plugin corresponding to the Tenant HTTP Header provided in the request, if no value was provided, we assume the legacy system is calling the Products API, and the OldSQLPlugin will be invoked.
That is all there is to deploying an Microservice API with the Prise plugin framework!
References
OldSQLPlugin code can be found here: https://github.com/merken/Prise.Examples/tree/master/MultiTenantMicroserviceWithDiscovery/Plugins/OldSQLPlugin
SQLPlugin code can be found here: https://github.com/merken/Prise.Examples/tree/master/MultiTenantMicroserviceWithDiscovery/Plugins/SQLPlugin
TableStoragePlugin code can be found here: https://github.com/merken/Prise.Examples/tree/master/MultiTenantMicroserviceWithDiscovery/Plugins/TableStoragePlugin
The Products.API can be found here: https://github.com/merken/Prise.Examples/tree/master/MultiTenantMicroserviceWithDiscovery/Products.API
All Prise examples can be found on the Prise.Examples repo.
Happy new year!