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:
- A way to know who the current tenant is
- 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");
}
}