blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

Loading tenants from the database with SaasKit - Part 2, Caching

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 AppTenants 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:

Warning, while the code below works, I don't recommend you use this specific version of it, as explained below. For a better implementation, see my subsequent post here.

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 AppTenants 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.

Update - After seeing this tweet from David Fowler, it looks like you should probably use an IServiceScopeFactory to create a scope before obtaining an IServiceProvider, something similar to this.:

services.Configure<MultitenancyOptions>(
    options =>
    {
        var scopeFactory = services
            .BuildServiceProvider()
            .GetRequiredService<IServiceScopeFactory>();
            
        using (var scope = scopeFactory.CreateScope())
        {
            var provider = scope.ServiceProvider;
            using (var dbContext = provider.GetRequiredService<ApplicationDbContext>())
            {
                options.AppTenants = dbContext.AppTenants.ToList();
            }
        }
    });

Update 2 - A much cleaner approach to this can be achieved by using IConfigureOptions<T>, as explained in my post here. Also see this post by Ben Collins for the inspiration.

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 TenantContexts 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 TenantContexts 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.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?