Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 068 / 100

Multi-tenancy in EF Core: a hands-on guide for engineers

Patterns from building multi-tenant SaaS in .NET.

Expona Admin10 min read

Building isolation into your SaaS from day one

Multi-tenancy is one of those problems that feels straightforward until you ship your first customer and realize a bug in tenant filtering could expose another company's data. Multi-tenancy in EF Core: a hands-on guide for engineers walks through the patterns we've refined across e-commerce platforms, CRM systems, and trading dashboards—patterns that keep tenant data sealed off by default, not by hope. We'll cover how to resolve the tenant context early, apply global query filters, stamp tenant IDs on saves, and know when to reach for database-per-tenant isolation.

Resolve the tenant early with middleware and a scoped context

The foundation of multi-tenancy is knowing which tenant is making the request. We do this by extracting the tenant identifier—usually from the subdomain, a custom header, or a JWT claim—as early as possible, typically in middleware. The tenant context then lives in a scoped service so every downstream component (controller, service, repository) can access it without passing it through method parameters.

Start with a simple ITenantContext interface:

csharp
public interface ITenantContext
{
    string TenantId { get; }
}

public class TenantContext : ITenantContext
{
    public string TenantId { get; set; }
}

Next, add middleware that reads the tenant from the request. We typically check the subdomain first (e.g., acme.app.example.com → tenant acme), fall back to a header like X-Tenant-Id, and finally check JWT claims:

csharp
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;

    public TenantResolutionMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
    {
        var tenantId = ResolveTenantId(context);
        
        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Tenant not resolved");
            return;
        }

        ((TenantContext)tenantContext).TenantId = tenantId;
        await _next(context);
    }

    private string ResolveTenantId(HttpContext context)
    {
        // Try subdomain first
        var host = context.Request.Host.Host;
        var parts = host.Split('.');
        if (parts.Length > 2 && parts[0] != "www")
            return parts[0];

        // Fall back to header
        if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
            return tenantHeader.ToString();

        // Fall back to JWT claim
        var tenantClaim = context.User.FindFirst("tenant_id");
        return tenantClaim?.Value;
    }
}

Register it in Program.cs:

csharp
builder.Services.AddScoped<ITenantContext, TenantContext>();

var app = builder.Build();
app.UseMiddleware<TenantResolutionMiddleware>();

Now every request has an ITenantContext available via dependency injection. This is your safety net: if the middleware can't resolve a tenant, the request fails fast.

Filter globally with EF Core query filters

The second layer of defense is the global query filter. This ensures that every query against tenant-aware entities automatically includes a WHERE TenantId = currentTenant clause. Even if a developer forgets to add the filter manually, the database engine enforces it.

First, add a TenantId property to your entities. We've found it's easier to add it to all entities than to reason about which ones need it:

csharp
public class Order
{
    public int Id { get; set; }
    public string TenantId { get; set; }
    public DateTime CreatedAt { get; set; }
    public decimal Total { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    public string TenantId { get; set; }
    public string Name { get; set; }
}

Then configure the global query filter in your DbContext. We inject ITenantContext into the context so it can access the current tenant:

csharp
public class ApplicationDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }

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

        // Apply global query filter to all entities with TenantId
        var tenantId = _tenantContext.TenantId;

        modelBuilder.Entity<Order>()
            .HasQueryFilter(e => e.TenantId == tenantId);

        modelBuilder.Entity<Customer>()
            .HasQueryFilter(e => e.TenantId == tenantId);
    }
}

Now every query against Orders or Customers automatically filters by tenant. A simple db.Orders.ToListAsync() becomes SELECT * FROM Orders WHERE TenantId = 'acme' behind the scenes. No developer can accidentally leak data across tenants.

Stamp the tenant on save with SaveChanges override

Global filters protect reads, but we also need to ensure new entities are always stamped with the correct tenant ID on writes. We override SaveChanges to set TenantId on any entity that doesn't already have it:

csharp
public class ApplicationDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    // ... existing code ...

    public override int SaveChanges()
    {
        SetTenantIdOnNewEntities();
        return base.SaveChanges();
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SetTenantIdOnNewEntities();
        return await base.SaveChangesAsync(cancellationToken);
    }

    private void SetTenantIdOnNewEntities()
    {
        var entries = ChangeTracker.Entries()
            .Where(e => e.State == EntityState.Added);

        foreach (var entry in entries)
        {
            if (entry.Entity is not { } entity)
                continue;

            var tenantIdProperty = entry.Properties
                .FirstOrDefault(p => p.Metadata.Name == "TenantId");

            if (tenantIdProperty != null && string.IsNullOrEmpty(tenantIdProperty.CurrentValue?.ToString()))
            {
                tenantIdProperty.CurrentValue = _tenantContext.TenantId;
            }
        }
    }
}

This means a controller can simply create an order without thinking about the tenant:

csharp
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
    var order = new Order
    {
        CreatedAt = DateTime.UtcNow,
        Total = request.Total
        // TenantId is set automatically in SaveChanges
    };

    _db.Orders.Add(order);
    await _db.SaveChangesAsync();

    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

Combining global filters with automatic tenant stamping creates a robust two-layer system: new data is tagged with the tenant, and all queries only see data tagged with the current tenant.

Database per tenant for enterprise isolation

For some customers—especially those in highly regulated industries or with data residency requirements—a single database with row-level filtering isn't enough. They want a dedicated database per tenant.

We handle this by switching the connection string based on the tenant. Create a tenant configuration that maps each tenant to a connection string:

csharp
public interface ITenantConnectionResolver
{
    string GetConnectionString(string tenantId);
}

public class TenantConnectionResolver : ITenantConnectionResolver
{
    private readonly IConfiguration _config;

    public TenantConnectionResolver(IConfiguration config)
    {
        _config = config;
    }

    public string GetConnectionString(string tenantId)
    {
        // Fetch from config, secrets manager, or a custom table
        var connStr = _config[$"Tenants:{tenantId}:ConnectionString"];
        
        if (string.IsNullOrEmpty(connStr))
        {
            // Fall back to a default shared database
            return _config.GetConnectionString("DefaultConnection");
        }

        return connStr;
    }
}

Then wire it into the context factory so it builds a context with the right connection string per request:

csharp
public class TenantAwareContextFactory : IDbContextFactory<ApplicationDbContext>
{
    private readonly IServiceProvider _services;

    public TenantAwareContextFactory(IServiceProvider services)
    {
        _services = services;
    }

    public ApplicationDbContext CreateDbContext()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseSqlServer(GetConnectionString())
            .Options;

        return new ApplicationDbContext(options, _services.GetRequiredService<ITenantContext>());
    }

    private string GetConnectionString()
    {
        var tenantContext = _services.GetRequiredService<ITenantContext>();
        var resolver = _services.GetRequiredService<ITenantConnectionResolver>();
        return resolver.GetConnectionString(tenantContext.TenantId);
    }
}

Register it:

csharp
builder.Services.AddScoped<ITenantConnectionResolver, TenantConnectionResolver>();
builder.Services.AddDbContextFactory<ApplicationDbContext>((provider, options) =>
{
    var resolver = provider.GetRequiredService<ITenantConnectionResolver>();
    var tenantContext = provider.GetRequiredService<ITenantContext>();
    var connStr = resolver.GetConnectionString(tenantContext.TenantId);
    options.UseSqlServer(connStr);
});

Now enterprise customers get a dedicated database, while smaller customers share a single database with row-level isolation. The application code doesn't need to know the difference.

Test the isolation with integration tests

Multi-tenancy bugs are among the most dangerous in production. We write integration tests that explicitly verify tenant A cannot read or modify tenant B's data:

csharp
[TestFixture]
public class TenantIsolationTests
{
    private ApplicationDbContext _dbAcme;
    private ApplicationDbContext _dbWidgets;

    [SetUp]
    public async Task Setup()
    {
        // Create two contexts with different tenant contexts
        var tenantContextAcme = new TenantContext { TenantId = "acme" };
        var tenantContextWidgets = new TenantContext { TenantId = "widgets" };

        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseInMemoryDatabase("test")
            .Options;

        _dbAcme = new ApplicationDbContext(options, tenantContextAcme);
        _dbWidgets = new ApplicationDbContext(options, tenantContextWidgets);

        await _dbAcme.Database.EnsureDeletedAsync();
        await _dbAcme.Database.EnsureCreatedAsync();
    }

    [Test]
    public async Task TenantA_CannotSeeOrdersFromTenantB()
    {
        // Tenant ACME creates an order
        var acmeOrder = new Order 
        { 
            Id = 1, 
            TenantId = "acme", 
            Total = 100 
        };
        _dbAcme.Orders.Add(acmeOrder);
        await _dbAcme.SaveChangesAsync();

        // Tenant WIDGETS queries orders
        var widgetsOrders = await _dbWidgets.Orders.ToListAsync();

        Assert.That(widgetsOrders, Is.Empty);
    }

    [Test]
    public async Task TenantA_CannotUpdateOrdersFromTenantB()
    {
        // Both tenants in the same database
        var acmeOrder = new Order { TenantId = "acme", Total = 100 };
        _dbAcme.Orders.Add(acmeOrder);
        await _dbAcme.SaveChangesAsync();

        // Widgets tenant tries to load all orders (should be empty due to filter)
        var orders = await _dbWidgets.Orders.ToListAsync();
        Assert.That(orders, Is.Empty);
    }
}

These tests run against a real (in-memory) database and verify the query filters work. We also run similar tests against a real SQL Server instance in staging before any production deployment.

Common mistakes

  • Bypassing filters with raw SQL: Using context.Database.ExecuteSqlRaw() or raw EF queries without checking the tenant first. Always use parameterized queries and include tenant filtering.
  • Forgetting to configure the filter on new entities: If you add a new entity class and forget to add the query filter in OnModelCreating, it won't be protected. Use a base entity class or a helper method to apply filters consistently.
  • Testing only the happy path: Integration tests that don't explicitly verify isolation are almost useless. Write tests that try to break the isolation and fail if they succeed.
  • Leaking tenant context in background jobs: Background tasks (Hangfire, hosted services) won't have an HTTP context. Inject the tenant context explicitly or pass it through the job parameters.
  • Assuming connection switching is free: Switching databases per request has overhead. Profile it in staging with real data volumes before committing to database-per-tenant for all customers.

Key takeaways

  • Resolve the tenant in middleware early; store it in a scoped ITenantContext so all layers can access it without passing parameters.
  • Apply global query filters in OnModelCreating to filter every query by tenant automatically—developers can't forget what they don't control.
  • Override SaveChanges to stamp new entities with the current tenant ID, eliminating a whole class of insert bugs.
  • Use database-per-tenant for enterprise customers who require it, but keep the application code tenant-agnostic by switching connection strings at the context factory level.
  • Write integration tests that explicitly verify cross-tenant isolation; these are among the most important tests in a SaaS product.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles