Multitenancy Techniques for EF Core

Introduction

Multitenancy is a hot topic, which I covered a few times on my old blog. I won't dwell on its concepts, but, instead, I will present ways to apply multitenancy to EF Core.

When it comes to data, multitenancy means that we should only retrieve data for the current tenant.

I will present three ways to obtain the tenant id from inside of a DbContext, which can then be used to set up query filters, connection strings, or mapping configuration.

Multitenancy in Databases

So, as I blogged before, there are essentially three strategies for applying multitenancy to databases:

  1. Using a tenant column on tenant-aware tables and filter by it
  2. Using a different schema, meaning, tenant-aware tables will be duplicated in different schemas and for each request, one schema will be used
  3. Using different databases, one for each tenant, and, for each request, pick the right connection string

There are obviously pros and cons to each approach, but I won't go through them now. Instead, I will present different ways to get the tenant id on an EF Core context, for #2 and #3.

Multitenancy in EF Core

When it comes to EF Core, depending on the strategy chosen, we need to do different things on the EF Core side:

  • Find out the current tenant must always be done
  • Filter tenant-aware entities by the current tenant id; this can be done through a global query filter (automatic, more challenging) or with just a plain filter on the LINQ query, which is the usual approach, although manual (strategy #1)
  • Map tenant-aware entities to the appropriate schema (#2) or
  • Select the appropriate connection string (#3)

Both configuration or conventions can be used for this.

In ASP.NET Core, a request is a scope which corresponds to a tenant which will have its own context, this makes things easier for us.

Inside the context, we must learn what the current tenant is and use it appropriately, perhaps inside the OnConfiguring or OnModelCreating methods, to either set up the right connection string or the mapping of entities to schemas. I have talked about how to achieve this before, but, probably, I'll do another post with the state of the art, in the meantime, you can have a look at this Microsoft page.

One thing that we must be aware is, because we are modifying our own context, we cannot return it to a pool, so we cannot use these approaches with AddDbContextPool or AddPooledDbContextFactory, as the context cannot be used on requests with possibly other tenants.

Context

We have an ITenantIdProvider interface, which describes a way to obtain the current tenant id. Let's not worry how it is actually obtained (a query string or route parameter, an HTTP header, a cookie, are all possible options):

public interface ITenantIdProvider
{
    string GetTenantId();
}

Of course, an actual implementation (SomeTenantIdProvider, in my example) must be registered with the Dependency Injection (DI) container:

builder.Services.AddScoped<ITenantIdProvider, SomeTenantIdProvider>();

Also, a marker interface for tenant-aware entities, ITenantAware:

public interface ITenantAware
{
    public string? TenantId { get; set; }
}

Then we have a context, inheriting from DbContext, that has a TenantId property:

public class SomeContext(DbContextOptions options) : DbContext(options)
{ public string? TenantId { get; set; } }

Don't forget that, because we are using DI to instantiate our context, we must have a parameter typed DbContextOptions, which should be passed to the base class' constructor.

Let us now explore the different options available to get the tenant id into the context, which is required for strategies 2 and 3.

Setting Up Query Filters

A global query filter lets us filter tenant-aware entities automatically, without the need to worry about it. So, if our context has a TenantId property - which can be set in one of the ways described later on - we can do this:

public class SomeContext : DbContext
{
    //rest is omitted for clarity
    private static readonly MethodInfo _propertyMethod = typeof(EF).GetMethod(nameof(EF.Property), BindingFlags.Static | BindingFlags.Public)!.MakeGenericMethod(typeof(string));

    public string? TenantId { get; set; }

    private void PopulateTenantId()
    {
        //read on
    }
    
    private static LambdaExpression GetTenantIdRestriction(Type type, string? tenantId)
    {
        var parm = Expression.Parameter(type, "it");
        var prop = Expression.Call(_propertyMethod, parm, Expression.Constant(nameof(ITenantAware.TenantId)));
        var condition = Expression.MakeBinary(ExpressionType.Equal, prop, Expression.Constant(tenantId));
        var lambda = Expression.Lambda(condition, parm);
        return lambda;
    }

    override protected void OnModelCreating(ModelBuilder modelBuilder)
    {
        PopulateTenantId();
        
        foreach (var entityType in modelBuilder.Model.GetEntityTypes().Where(entityType => typeof(ITenantAware).IsAssignableFrom(entityType.ClrType)))
        {
            modelBuilder.Entity(entityType.ClrType)!.HasQueryFilter(GetTenantIdRestriction(entityType.ClrType, TenantId));
        }
    }
}

Here we see that we are registering a query filter for all entities that implement ITenantAware. The PopulateTenantId method depends on the strategy used, or may not be needed at all. I will present two implementations for it.

Persisting the Tenant

Next, we may want to set the tenant id automatically on entities that implement ITenantAware entities. We can skip this if we are sure to do it ourselves.

private void SetTenantIdOnAddedEntities()
{
    if (TenantId == null)
    {
        return;
    }
    
    ChangeTracker.Entries<ITenantAware>()
        .Where(e => e.State == EntityState.Added)
        .ToList()
        .ForEach(e => e.Entity.TenantId ??= TenantId);
}

public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
    SetTenantIdOnAddedEntities();

    return base.SaveChanges(acceptAllChangesOnSuccess);
}

public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
    SetTenantIdOnAddedEntities();

    return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

Here we intercept both the asynchronous (SaveChangesAsync) and the synchronous (SaveChanges) versions of the save changes operation and set the tenant id on all added entities that are tenant-aware, if it is not already set.

Getting the Tenant

Option 1 - Service Injection

The first option I'm going to cover is injecting the ITenantIdProvider into our DbContext. As long as they are both in DI, this works:

public class SomeContext(DbContextOptions options, ITenantIdProvider tenantIdProvider) : DbContext(options)
{
    private void PopulateTenantId()
    {
        TenantId = tenantIdProvider.GetTenantId();
    }

    public string? TenantId { get; set; }
}

Pros

  • Very straightforward to set up and use.

Cons

  • Will not work with EF Core migrations, as it has no way of knowing about the ITenantIdProvider service, and it's even generally impossible to find out what the tenant is without a request.
  • Won't work with pooled contexts.

Option 2 - Service Retrieval from Root Container

This approach postpones the dependency resolution of ITenantIdProvider until it is actually used, inside OnConfiguring or OnModelCreating. We use ServiceProviderAccessor, a service that is registered with EF Core, to get hold of the root service provider, and we need to create a scope before retrieving the ITenantIdProvider.

public class SomeContext : DbContext
{
    private T GetRequiredExternalService<T>() where T : class
    {
        var sp = this.GetService<ServiceProviderAccessor>().RootServiceProvider;
        using var scope = sp.CreateScope();
        return scope.ServiceProvider.GetRequiredService<T>();
    }    
    
    private void PopulateTenantId()
    {
        var tenantIdProvider = GetRequiredExternalService<ITenantIdProvider>();
        TenantId = tenantIdProvider.GetTenantId();
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        PopulateTenantId();
        //do something with TenantId
        base.OnModelCreating(modelBuilder);
    }
}

Pros

  • We delegate the tenant id retrieval to when/if we actually need it.

Cons

  • Slightly convoluted.
  • Will not work with EF Core migrations.
  • Won't work with pooled contexts.

Option 3 - Custom Context Factory

For this approach, you need to use a custom context factory, which means, registering the context with AddDbContextFactory. Here you have access to the instantiated context before returning it, as well as any services in DI.

public class SomeContextFactory(IDbContextFactory<SomeContext> pooledFactory, ITenantProvider tenantProvider) : IDbContextFactory<SomeContext>
{
    public SomeContext CreateDbContext()
    {
        var ctx = pooledFactory.CreateDbContext();
        ctx.TenantId = tenantProvider.GetTenantId();
        return ctx;
    }
}

builder.Services.AddScoped<SomeContextFactory>();

builder.Services.AddDbContextFactory<SomeContext>(options =>
{
    //...
});

builder.Services.AddScoped(sp => sp.GetRequiredService<SomeContextFactory>().CreateDbContext());

Pros

  • Can access the instantiated context as well as any injected services.

Cons

  • More complex to set up.
  • Will not work with EF Core migrations.
  • Won't work with pooled contexts.

Conclusion

So, here are some possible solutions for getting a tenant id into a context. I have the feeling that I barely scratched the surface, maybe another post will follow on this topic. In the meantime, let me know if I missed something.

Comments

Popular posts from this blog

C# Magical Syntax

OpenTelemetry with ASP.NET Core

ASP.NET Core Middleware