Skip to content

Multi-Tenant Applications With EF Core

This is a brief overview of approaching multi-tenant applications with Entity Framework Core, inspired by this page by Milan Jovanović.

For a more in-depth look, see the page The RIGHT Way to Build Multi-Tenant APIs in .NET Presentation by Milan Jovanović I also covered on this site.

Overview

Multi-Tenant applications serve multiple customers, while keeping their data isolated.

You can approach multi-tenancy in two ways:

  • Single database and logical isolation of tenants
  • Multiple databases and physical isolation of tenants

The best option will depend mostly on your requirements. Some industries like healthcare require a high degree of data isolation, and using a database per tenant is a must.

Implementing multi-tenancy support with EF Core

We can use Query Filters to apply a tenant filter to all database queries.

Implement it once and you can almost forget about it.

Single Database Multi-Tenancy With EF Core

You will need two things to implement multi-tenancy on a single database:

  1. A way to know who the current tenant is
  2. A way to filter the data for that tenant only

The typical approach for multi-tenancy on a single database is having a TenantId column in your tables. And then filtering on that column when querying the database.

You can use the Query filters feature in EF Core to apply a global filter for some entity.

Inside of the OnModelCreating method we configure the query filter on the Order entity:

public class OrdersDbContext : DbContext
{
    private readonly string _tenantId;

    public OrdersDbContext(
        DbContextOptions<OrdersDbContext> options,
        TenantProvider tenantProvider)
        : base(options)
    {
        _tenantId = tenantProvider.TenantId;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Order>
            .HasQueryFilter(o => o.TenantId == _tenantId);
    }
}

We're using the TenantProvider class to get the current tenant value.

Here's what the TenantProvider implementation looks like:

public sealed class TenantProvider
{
    private const string TenantIdHeaderName = "X-TenantId";

    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public string TenantId => _httpContextAccessor
        .HttpContext
        .Request
        .Headers[TenantIdHeaderName];
}

The TenantId is coming from the HTTP request header in this example.

A few other options to get the TenantId are:

  • Query string - api/orders?tenantId=example-tenant-id
  • JWT Claim
  • API Key

More secure implementations should use JWT Claims or API Keys to provide the TenantId value.

Separate Databases Multi-Tenancy With EF Core

If we want to isolate each tenant to a separate database we need to make some changes:

  • Applying different connection string per tenant
  • Resolving the connection string for each tenant somehow

You don't need to use Query filters here, since we are working with different databases.

W need to store the tenant information and connection strings somewhere.

A simple example would be store them in the application settings:

"Tenants": [
    { "Id": "tenant-1", "ConnectionString": "Host=tenant1.db;Database=tenant1" },
    { "Id": "tenant-2", "ConnectionString": "Host=tenant2.db;Database=tenant2" }
]   

You can then register an IOptions instance with a list of Tenant objects, and slightly modify the TenantProvider class to return a connection string for the current tenant:

public sealed class TenantProvider
{
    private const string TenantIdHeaderName = "X-TenantId";

    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly TenantSettings _tenantSettings;

    public TenantProvider(
        IHttpContextAccessor httpContextAccessor,
        IOptions<TenantSettings> tenantsOptions)
    {
        _httpContextAccessor = httpContextAccessor;
        _tenants = tenantsOptions.Value;
    }

    public string TenantId => _httpContextAccessor
        .HttpContext
        .Request
        .Headers[TenantIdHeaderName];

    public string GetConnectionString()
    {
        return _tenantSettings.Tenants.Single(t => t.Id == TenantId);
    }
}

Finally register your DbContext to dynamically resolve the connection string for the current tenant.

builder.Services.AddDbContext<OrdersDbContext>((sp, o) =>
{
    var tenantProvider = sp.GetRequiredService<TenantProvider>();

    var connectionString = tenantProvider.GetConnectionString();

    o.UseSqlServer(connectionString);
});

On every request, we create a new OrdersDbContext and connect to the appropriate database for that tenant.

Note: Consider storing the tenant connection strings in a secure place like Azure Key Vault.

My Final Files

I needed to do a lottle work to get the above to compile correctly. Below are the key files of my base solution:

Program.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TenantExperiment.Data;

// Build configuration
var configuration = new ConfigurationBuilder()
    .SetBasePath(AppContext.BaseDirectory)
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .Build();

// Setup dependency injection
var services = new ServiceCollection();

// Add DbContext
//services.AddDbContext<ApplicationDbContext>(options =>
//    options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

services.AddDbContext<ApplicationDbContext>((sp, o) =>
{
    var tenantProvider = sp.GetRequiredService<TenantProvider>();

    var connectionString = tenantProvider.GetConnectionString();

    o.UseSqlServer(connectionString);
});

// Build service provider
var serviceProvider = services.BuildServiceProvider();

// Get DbContext instance
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

Console.WriteLine("Entity Framework Core configured successfully!");
Console.WriteLine($"Database: {dbContext.Database.GetConnectionString()}");

appsettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=TenantExperimentDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
  },
  "Tenants": [
    {
      "Id": "tenant-1",
      "ConnectionString": "Host=tenant1.db;Database=tenant1"
    },
    {
      "Id": "tenant-2",
      "ConnectionString": "Host=tenant2.db;Database=tenant2"
    }
  ]
}

Data/TenantSettings.cs

namespace TenantExperiment.Data;

public class TenantSettings
{
    public List<TenantConfiguration> Tenants { get; set; } = new();
}

public class TenantConfiguration
{
    public string Id { get; set; } = string.Empty;
    public string ConnectionString { get; set; } = string.Empty;
}

Data\TenantProvider.cs

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

namespace TenantExperiment.Data;

public sealed class TenantProvider
{
    private const string TenantIdHeaderName = "X-TenantId";

    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly TenantSettings _tenantSettings;

    public TenantProvider(
        IHttpContextAccessor httpContextAccessor,
        IOptions<TenantSettings> tenantsOptions)
    {
        _httpContextAccessor = httpContextAccessor;
        _tenantSettings = tenantsOptions.Value;
    }

    public string? TenantId => _httpContextAccessor
        .HttpContext
        .Request
        .Headers[TenantIdHeaderName];

    public string GetConnectionString()
    {
        return _tenantSettings.Tenants.Single(t => t.Id == TenantId).ConnectionString;
    }
}

Data/ApplicationDbContext.cs

using Microsoft.EntityFrameworkCore;

namespace TenantExperiment.Data;

public class ApplicationDbContext : DbContext
{
    private readonly string _tenantId;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
        TenantProvider tenantProvider)
        : base(options)
    {
        _tenantId = tenantProvider.TenantId;
    }

    // Add your DbSet properties here
    // Example: public DbSet<YourEntity> YourEntities { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Configure your entities here
        // Example: modelBuilder.Entity<YourEntity>().ToTable("YourEntities");
    }
}