I recently had the opportunity to make an existing application multi-tenant using Entity Framework Core. The experience turned out to be surprisingly smooth and pleasant — which, let's be honest, is not always the case when you touch the architecture of a production application. In this article, I will share the different strategies I explored, the choices I made, and most importantly, concrete code examples so you can do the same.
What is multi-tenancy?
Multi-tenancy is a software architecture pattern where a single instance of an application serves multiple customers (called tenants). Each tenant has its own data, isolated from the others. It's a ubiquitous pattern in SaaS applications: think of Slack, Notion, or Azure DevOps — each organization is a tenant.
The main challenge is clear: guaranteeing data isolation between tenants. A customer should never be able to access another customer's data, even by accident. Trust me, your DPO will thank you for handling this properly.
Isolation strategies
There are mainly three approaches for multi-tenancy with EF Core:
- Discriminator (column) — A single database with a
TenantIdcolumn in each table to filter data. Simple and cost-effective. - Database per tenant — Each tenant gets its own database. Maximum isolation, but more expensive in infrastructure.
- Schema per tenant — A single database with a dedicated schema per tenant. A compromise, but not natively supported by EF Core migrations.
The choice depends on your constraints: budget, regulatory requirements (healthcare, finance…), data volume and operational complexity. In most cases, the discriminator approach with global query filters is the best starting point. That's the one we'll detail first.
Identifying the current tenant
Before filtering anything, we need to know who the current tenant is. We create a dedicated service for this, typically registered as Scoped to live for the duration of an HTTP request.
public interface ITenantService
{
string TenantId { get; }
}
public sealed class TenantService : ITenantService
{
private const string TenantIdHeaderName = "X-TenantId";
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string TenantId =>
_httpContextAccessor.HttpContext?.Request.Headers[TenantIdHeaderName]
?? throw new InvalidOperationException("Tenant ID is missing from the request.");
}
Here, the TenantId is retrieved from an HTTP header X-TenantId.
In production, you would probably prefer to extract it from a JWT claim, an API Key, or even the subdomain of the URL.
The key point is to have a single, reliable entry point for this information.
Approach 1: Single database with global query filters
This is the most common and the simplest approach to implement. The idea: each entity has a TenantId property,
and EF Core automatically applies a global query filter to only return data for the current tenant.
Let's start by defining a common interface for our multi-tenant entities:
public interface ITenantEntity
{
string TenantId { get; set; }
}
public class Order : ITenantEntity
{
public int Id { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string TenantId { get; set; } = string.Empty;
}
Next, let's configure the DbContext to apply the global filter:
public class AppDbContext : DbContext
{
private readonly string _tenantId;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantService tenantService)
: base(options)
{
_tenantId = tenantService.TenantId;
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Global filter: only data for the current tenant is returned
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantId);
}
}
And just like that, every LINQ query on Orders will automatically include a WHERE TenantId = @tenantId.
No need to repeat it throughout your code — EF Core handles it for you. That's the magic of Query Filters.
Pro tip: If you have multiple multi-tenant entities, you can automate the filter application by looping through the model. No need to configure it entity by entity:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
{
var method = typeof(AppDbContext)
.GetMethod(nameof(ApplyTenantFilter),
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, new object[] { modelBuilder, _tenantId });
}
}
}
private static void ApplyTenantFilter<T>(
ModelBuilder modelBuilder, string tenantId) where T : class, ITenantEntity
{
modelBuilder.Entity<T>()
.HasQueryFilter(e => e.TenantId == tenantId);
}
With this approach, every new entity implementing ITenantEntity will be automatically filtered. A real time-saver as your model grows.
Automatically assigning TenantId
It would be a shame to have to manually assign the TenantId on every insert.
We can override SaveChangesAsync to do it automatically:
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.TenantId = _tenantId;
}
// Prevent modification of TenantId on an existing entity
if (entry.State == EntityState.Modified)
{
entry.Property(nameof(ITenantEntity.TenantId)).IsModified = false;
}
}
return base.SaveChangesAsync(cancellationToken);
}
This code guarantees two things: the TenantId is always correctly assigned on creation,
and it cannot be accidentally modified during an update. Enhanced security with zero extra effort for the developer.
Approach 2: Database per tenant
For cases where physical data isolation is required (regulations, performance, demanding clients…), each tenant gets its own database. No need for global filters here — isolation is structural.
Disclaimer: I haven't had the chance to use this approach in my career so far, but it's a well-documented and proven strategy that I wanted to cover for completeness. Who knows, it might be the next project on my list!
We start by storing the connection strings in the configuration:
// appsettings.json
{
"ConnectionStrings": {
"tenant-alpha": "Server=.;Database=AppAlpha;Trusted_Connection=True;",
"tenant-beta": "Server=.;Database=AppBeta;Trusted_Connection=True;"
}
}
We extend the ITenantService to resolve the correct connection string:
public interface ITenantService
{
string TenantId { get; }
string GetConnectionString();
}
public sealed class TenantService : ITenantService
{
private const string TenantIdHeaderName = "X-TenantId";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IConfiguration _configuration;
public TenantService(
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration)
{
_httpContextAccessor = httpContextAccessor;
_configuration = configuration;
}
public string TenantId =>
_httpContextAccessor.HttpContext?.Request.Headers[TenantIdHeaderName]
?? throw new InvalidOperationException("Tenant ID is missing.");
public string GetConnectionString()
{
var connectionString = _configuration.GetConnectionString(TenantId);
return connectionString
?? throw new InvalidOperationException(
$"No connection string found for tenant '{TenantId}'.");
}
}
Then we configure the DbContext to dynamically resolve the connection:
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
var tenantService = sp.GetRequiredService<ITenantService>();
var connectionString = tenantService.GetConnectionString();
options.UseSqlServer(connectionString);
});
On every request, a new AppDbContext is created and connected to the appropriate tenant's database. Clean and efficient.
Important note: in production, store your connection strings in a secure location
such as Azure Key Vault or AWS Secrets Manager rather than in appsettings.json.
Service registration
For the single database approach, registering the services in Program.cs looks like this:
// Program.cs — Single database approach
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")),
ServiceLifetime.Scoped);
Note the ServiceLifetime.Scoped: this is important because the DbContext must be able to depend on the ITenantService, which is itself scoped.
Remember the golden rule of service lifetimes: Transient → Scoped → Singleton. A dependency can never have a longer lifetime than the service that depends on it.
Disabling the filter when needed
Sometimes you need to query data across all tenants — for example, for admin reports or maintenance tasks. EF Core lets you selectively disable global filters:
// Retrieve all orders across all tenants
var allOrders = await dbContext.Orders
.IgnoreQueryFilters()
.ToListAsync();
Use this feature carefully and only in secure contexts (admin endpoints, internal jobs…).
Which approach to choose?
Here's a summary to help you decide:
| Criteria | Single DB + filter | Database per tenant |
|---|---|---|
| Data isolation | Logical (column) | Physical (database) |
| Complexity | Low | Medium to high |
| Infrastructure cost | Low | High |
| Performance | Good (with index on TenantId) | Excellent (isolated data) |
| Regulatory compliance | Standard | High (healthcare, finance) |
| Migrations | Simple (single database) | Must manage per tenant |
Summary
Entity Framework Core makes implementing multi-tenancy remarkably accessible. Whether you opt for a single database with global filters or a database per tenant with dynamic connection strings, the tools are there and well thought out.
Key takeaways:
- Create a dedicated service (
ITenantService) to identify the current tenant. - Use EF Core Query Filters for logical isolation — it's automatic and transparent.
- Automate
TenantIdassignment viaSaveChangesAsyncto avoid mistakes. - For physical isolation, dynamically resolve the connection string per tenant.
- Respect service lifetimes (Scoped for both DbContext and TenantService).
- Think about security: store your connection strings in a vault, not in your config files.
To go further, I recommend the official Microsoft documentation on multi-tenancy with EF Core as well as the excellent article by Milan Jovanović, which helped me a lot.
Happy implementing, and feel free to share your experience in the comments!