Skip to main content

Multi-Tenant Applications with EF Core

Source

This page is a summary of Multi-Tenant Applications With EF Core by Milan Jovanović.

Most modern software applications are built around the concept of multi-tenancy — one application serves multiple customers while keeping their data isolated.

Multi-Tenancy Strategies

There are two main approaches to multi-tenancy:

StrategyDescription
Single DatabaseShare a single DB and separate data logically using a TenantId column
Multiple DatabasesUse a separate DB per tenant for physical data isolation

The choice depends on your requirements. Some industries like healthcare require a high degree of data isolation, making a database-per-tenant mandatory.


EF Core Query Filters Primer

EF Core's Global Query Filters automatically apply a filter to all queries for a given entity.

Basic Usage

modelBuilder
.Entity<Order>()
.HasQueryFilter(order => !order.IsDeleted);
  • Configure the filter by calling HasQueryFilter on the entity
  • EF Core applies it to all queries for that entity automatically
  • Disable for a specific query with IgnoreQueryFilters()
  • Only one query filter per entity is allowed

With the example above, every query against the Order table will include an IsDeleted = FALSE condition.


Single-Database Multi-Tenancy with EF Core

Two things are needed to implement multi-tenancy on a single database:

  1. A way to identify the current tenant
  2. A filter that limits results to that tenant's data

The standard approach is to add a TenantId column to each table and filter on it during queries.

DbContext Implementation

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);
}
}

The current tenant value is obtained from TenantProvider and wired up as a Query Filter.

TenantProvider Implementation

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];
}

In this example, TenantId is read from the HTTP request header X-TenantId.

Ways to Provide TenantId

MethodExampleSecurity
HTTP HeaderX-TenantId: tenant-1Low
Query Stringapi/orders?tenantId=tenant-1Low
JWT Claim"tenant_id": "tenant-1"High
API KeyAuthorization: ApiKey ...High

For a more secure implementation, prefer JWT Claims or API Keys.


Multi-Database Multi-Tenancy with EF Core

To isolate each tenant to a separate database, two changes are required:

  • Apply a different connection string per tenant
  • Resolve the correct connection string for the current tenant

Query Filters are not applicable here since each tenant uses a different database.
Tenant information and connection strings must be stored and resolved separately.

appsettings.json Example

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

Register an IOptions instance containing a list of Tenant objects.

TenantProvider (Multi-Database Version)

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;
}
}

Dynamically Registering DbContext

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

var connectionString = tenantProvider.GetConnectionString();

options.UseSqlServer(connectionString);
});

On every request a new OrdersDbContext is created that connects to the appropriate database for the current tenant.

Security Best Practice

Store tenant connection strings in a secure location such as Azure Key Vault rather than plain configuration files.


Strategy Comparison

ItemSingle DatabaseMultiple Databases
Data IsolationLogical (TenantId column)Physical (separate DBs)
CostLowHigh
ScalabilityDB load concentrates as tenants increaseEach tenant can scale independently
Operational ComplexityLowHigh (migrations become complex)
Regulatory ComplianceMay be insufficient for strict requirementsSuitable for healthcare, finance, etc.
Query FilterApplied automatically via HasQueryFilterNot needed (DBs are already isolated)
Connection String ManagementSingleOne per tenant

Multi-Tenancy in Background Jobs

Background jobs cannot access HttpContext, so special care is needed to obtain the TenantId.

  • Include TenantId in job data when scheduling the job
  • If a User ID is available in the job, look up TenantId from the database using it
  • When using message queues, include TenantId in the message payload

Summary

Key points for implementing multi-tenancy with EF Core:

  • Single DB: Use Global Query Filters combined with a TenantId column
  • Multiple DBs: Dynamically switch connection strings per tenant
  • Use TenantProvider to identify the current tenant on every request
  • Prefer JWT Claims or API Keys for secure TenantId propagation
  • Store connection strings in a secure vault such as Azure Key Vault

Once you understand the basic principles, you can evolve this foundation into a more robust and secure multi-tenant system.