In my previous post, I showed how you could add multi-tenancy to an ASP.NET Core application using the open source SaasKit library. Saaskit requires you to register an ITenantResolver<TTenant>
which is used to identify and resolve the applicable tenant (if any) for a given HttpContext
. In my post I showed how you could resolve tenants stored in a database using Entity Framework Core.
One of the advantages of loading tenants from the database is that the available tenants can be configured at runtime, as opposed to loaded once at startup. That means we can bring new tenants online or take others down while our app is still running, without any downtime. Also, the tenant details are completely decoupled from our application settings - there's no risk of tenant details being uploaded to our source control system, as the tenant details are production data that just lives in our production database.
The main disadvantage is that every single request (which makes it through the middleware pipeline to the TenantResolutionMiddleware
) will be hitting the database to try and resolve the current tenant. We will always be getting the freshest data that way, but it will become more of a problem as our app scales.
In this post, I'm going to show a couple of ways you can get around this problem, while still storing your tenants in the database. You can find the source code for the examples on GitHub.
1. Loading tenants from the database into IOptions<T>
One of the simplest ways around the problem is to go back to storing our AppTenant
models in an IOptions<T>
backed setting class. In the simplest configuration-based implementation, the AppTenant
s themselves are loaded from appsettings.json (for example) and used directly in the ITenantResolver
. This is the approach demonstrated in one of the SaasKit samples.
First we create an Options
object containing our tenants:
public class MultitenancyOptions
{
public ICollection<AppTenant> AppTenants { get; set; } = new List<AppTenant>();
}
Then we update the app tenant resolver to resolve the tenants from our MultitenancyOptions
object using the IOptions pattern:
public class AppTenantResolver : ITenantResolver<AppTenant>
{
private readonly ICollection<AppTenant> _tenants;
public AppTenantResolver(IOptions<MultitenancyOptions> appTenantSettings)
{
_tenants = appTenantSettings.Value.AppTenants;
}
public Task<TenantContext<AppTenant>> ResolveAsync(HttpContext context)
{
TenantContext<AppTenant> tenantContext = null;
var tenant = _tenants.FirstOrDefault(
t => t.Hostname.Equals(context.Request.Host.Value.ToLower()));
if (tenant != null)
{
tenantContext = new TenantContext<AppTenant>(tenant);
}
return Task.FromResult(tenantContext);
}
}
Finally, in the ConfigureServices
method of Startup
, you can configure the MultitenancyOptions
. In the SaasKit sample application, this is loaded directly from the IConfigurationRoot
using:
services.Configure<MultitenancyOptions>(Configuration.GetSection("Multitenancy"));
We can use a similar technique in our application, using the same AppTenantResolver
and MultitenancyOptions
, but instead of configuring them directly from IConfigurationRoot
, we will load them from the database. Our full ConfigureServices
method, including configuring our Entity Framework DbContext
and adding the multi-tenancy services, is shown below:
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
var connectionString = Configuration["ApplicationDbContext:ConnectionString"];
services.AddDbContext<ApplicationDbContext>(
opts => opts.UseNpgsql(connectionString)
);
services.Configure<MultitenancyOptions>(
options =>
{
var provider = services.BuildServiceProvider();
using (var dbContext = provider.GetRequiredService<ApplicationDbContext>())
{
options.AppTenants = dbContext.AppTenants.ToList();
}
});
services.AddMultitenancy<AppTenant, AppTenantResolver>();
}
The key here is that we are configuring our MultitenancyOptions
to be loaded from the database. This lambda will be run the first time that IOptions<MultitenancyOptions>
is required and will be cached for the lifetime of the app.
When the first request comes in, the TenantResolutionMiddleware
will attempt to resolve a TenantContext<AppTenant>
. This requires creating an instance of the AppTenantResolver
, which in turn has a dependency on IOptions<MultitenancyOptions>
. At this point, the AppTenant
s are loaded from the database as per our configuration and added to the MultitenancyOptions
. The remainder of the request then processes as normal.
On subsequent requests, the previously configured IOptions<MultitenancyOptions>
is injected immediately into the AppTenantResolver
, so the configuration code and our database are hit only once.
Obviously this approach has a significant drawback - any changes to the AppTenants
table in the database are ignored by the application; the available tenants are fixed after the first request. However it does still have the advantage of tenant details being stored in the database, so it may fit your needs.
One final thing to point out is the way we resolved the Entity Framework ApplicationDbContext
while still in the ConfigureService
method. To do this, we had to call IServiceCollection.BuildServiceProvider
in order to get an IServiceProvider
, from which we could then retrieve an ApplicationDbContext
.
While this works perfectly well in this example, I am not 100% sure this is a great idea - explicitly having to call BuildServiceProvider
just feels wrong! Also, I believe it could lead to some subtle bugs if you are using a third party container (like Autofac or StructureMap) that uses it's own implementation of IServiceProvider
; the code above would bypass the third-party container. Just some things to be aware of if you decide to use it in your application.
2.Caching tenants using MemoryCacheTenantResolver
So the configuration based approach works well enough but it has some caveats. We no longer hit the database on every request, but we've lost the ability to add new tenants at runtime.
Luckily, SaasKit comes with an ITenantResolver<TTenant>
implementation base class which will give us the best of both worlds - the MemoryCacheTenantResolver<TTenant>
. This class adds a wrapper around an IMemoryCache
, allowing you to easily cache TenantContext
s between requests.
To make use of it we need to implement some abstract methods:
public class CachingAppTenantResolver : MemoryCacheTenantResolver<AppTenant>
{
private readonly ApplicationDbContext _dbContext;
public CachingAppTenantResolver(ApplicationDbContext dbContext, IMemoryCache cache, ILoggerFactory loggerFactory)
: base(cache, loggerFactory)
{
_dbContext = dbContext;
}
protected override string GetContextIdentifier(HttpContext context)
{
return context.Request.Host.Value.ToLower();
}
protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<AppTenant> context)
{
return new[] { context.Tenant.Hostname };
}
protected override Task<TenantContext<AppTenant>> ResolveAsync(HttpContext context)
{
TenantContext<AppTenant> tenantContext = null;
var hostName = context.Request.Host.Value.ToLower();
var tenant = _dbContext.AppTenants.FirstOrDefault(
t => t.Hostname.Equals(hostName));
if (tenant != null)
{
tenantContext = new TenantContext<AppTenant>(tenant);
}
return Task.FromResult(tenantContext);
}
}
The first method, GetContextIdentifier()
returns the unique identifier for a tenant. It is used as the key for the IMemoryCache
, so it must be unique and resolvable from the HttpContext
.
GetTenantIdentifiers()
is called after a tenant has been resolved. We return all the applicable identifiers for the given tenant, which allows us to resolve the provided context when any of these identifiers are found in the HttpContext
. That allows you to have multiple hostnames which resolve to the same tenant, for example.
Finally, ResolveAsync()
is the method where the actual resolution for a tenant occurs, which is called if a tenant cannot be found in the IMemoryCache
. This method call is identical to the one in my previous post, where we are finding the first tenant with the provided hostname in the database. If the tenant can be resolved, we create a new context and return it, whereupon it will be cached for future requests.
It's worth noting that if the tenant can not be resolved from the HttpContext
(no tenant exists in the database with the provided hostname), then ResolveAsync returns null
. However, this value is not cached in the IMemoryCache
. This means every request with the missing hostname will require a call to ResolveAsync
and consequently a hit against the database. Depending on your setup that may or may not be an issue. If necessary you could create your own version of MemoryCacheTenantResolver
which also caches null
results.
To see the caching in effect we can just check out the logs generated by SaasKit when we make a request, thanks to the universal logging enabled by universal dependency injection.
On the first request to a new host, where I have my tenants stored in a PostgreSQL database, we can see the MemoryCacheTenantResolver
attempting to resolve the tenant using the hostname, getting a miss, and so hitting the database:
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
Resolving TenantContext using CachingAppTenantResolver.
dbug: SaasKit.Multitenancy.MemoryCacheTenantResolver[0]
TenantContext not present in cache with key "localhost:5000". Attempting to resolve.
dbug: Npgsql.NpgsqlConnection[3]
Opening connection to database 'DbTenantswithSaaskit' on server 'tcp://localhost:5432'.
info: Microsoft.EntityFrameworkCore.Storage.Internal.RelationalCommandBuilderFactory[1]
Executed DbCommand (2,233ms) [Parameters=[@__ToLower_0='?'], CommandType='Text', CommandTimeout='30']
SELECT "t"."AppTenantId", "t"."Hostname", "t"."Name"
FROM "AppTenants" AS "t"
WHERE "t"."Hostname" = @__ToLower_0
LIMIT 1
dbug: Npgsql.NpgsqlConnection[4]
Closing connection to database 'DbTenantswithSaaskit' on server 'tcp://localhost:5432'.
dbug: SaasKit.Multitenancy.MemoryCacheTenantResolver[0]
TenantContext:131d4739-0447-47f6-a0b3-f8a8656a946f resolved. Caching with keys "localhost:5000".
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
TenantContext Resolved. Adding to HttpContext.
On the second request to the same tenant, the MemoryCacheTenantResolver
gets a hit from the cache, so immediately returns the TenantContext
from the first request.
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
Resolving TenantContext using CachingAppTenantResolver.
dbug: SaasKit.Multitenancy.MemoryCacheTenantResolver[0]
TenantContext:131d4739-0447-47f6-a0b3-f8a8656a946f retrieved from cache with key "localhost:5000".
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
TenantContext Resolved. Adding to HttpContext.
When we call a different host (localhost:5001), the MemoryCacheTenantResolver
again hits the database, and stores the result in the cache.
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
Resolving TenantContext using CachingAppTenantResolver.
dbug: SaasKit.Multitenancy.MemoryCacheTenantResolver[0]
TenantContext not present in cache with key "localhost:5001". Attempting to resolve.
dbug: Npgsql.NpgsqlConnection[3]
Opening connection to database 'DbTenantswithSaaskit' on server 'tcp://localhost:5432'.
info: Microsoft.EntityFrameworkCore.Storage.Internal.RelationalCommandBuilderFactory[1]
Executed DbCommand (118ms) [Parameters=[@__ToLower_0='?'], CommandType='Text', CommandTimeout='30']
SELECT "t"."AppTenantId", "t"."Hostname", "t"."Name"
FROM "AppTenants" AS "t"
WHERE "t"."Hostname" = @__ToLower_0
LIMIT 1
dbug: Npgsql.NpgsqlConnection[4]
Closing connection to database 'DbTenantswithSaaskit' on server 'tcp://localhost:5432'.
dbug: SaasKit.Multitenancy.MemoryCacheTenantResolver[0]
TenantContext:3915c2b9-8210-47ad-a22c-193e23f2d552 resolved. Caching with keys "localhost:5001".
dbug: SaasKit.Multitenancy.Internal.TenantResolutionMiddleware[0]
TenantContext Resolved. Adding to HttpContext.
With our new CachingAppTenantResolver
we now have the best of both worlds - we can happily add new tenants to the database and they will be resolved in subsequent requests, but we are not hitting the database for every subsequent request to a known host. Obviously this approach can be extended - as with any sort of caching, we may well need to be able to invalidate certain tenant contexts if for example a tenant is removed. And there is the question of whether failed tenant resolutions should be cached. Again, just things to think about when you come to adding it to your application!
Summary
Multi-tenancy can be a tricky thing to get right, and SaasKit is a great open source project for providing the basics to get up and running. As before, I recommend you check out the project on GitHub and also check out Ben Foster's blog as he has whole bunch of posts on it. In this post we showed a couple of approaches for caching TenantContext
s between requests, to reduce the traffic to the database.
Whether either of these approaches will work for you will depend on your exact use case, but hopefully they will give you a start in the right direction. Thanks to the design of the SaasKit TenantResolutionMiddleware
it is easy to just plug in a new ITenantResolver
if your requirements change down the line.