DEV Community

Cover image for Multi-tenancy in EF Core: make "no tenant" mean "no rows"
Zohaib Ahmad
Zohaib Ahmad

Posted on AI-assisted

Multi-tenancy in EF Core: make "no tenant" mean "no rows"

The worst bug a SaaS can ship is one customer seeing another customer's data. If your isolation depends on every developer remembering .Where(x => x.TenantId == tenantId) on every query, it's not a matter of if.

Here's the pattern I use in ASP.NET Core and EF Core, taken from a production point of sale and now the foundation of my SaaS Starter Kit.

1. One base class for everything a tenant owns

public interface ITenantOwned { Guid TenantId { get; } }

public abstract class Entity
{
    public Guid Id { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

public abstract class TenantEntity : Entity, ITenantOwned
{
    public Guid TenantId { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

2. A query filter on every tenant-owned table

The DbContext gets the current user injected, and every tenant-owned entity gets a global query filter:

public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser currentUser /* ... */)
    : IdentityUserContext<AppUser, Guid>(options)
{
    // EF Core evaluates this per query, so each request sees only its own tenant.
    // No tenant (anonymous or background code) means filtered sets return nothing.
    private Guid? CurrentTenantId => currentUser.TenantId;

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

        builder.Entity<Customer>(e =>
        {
            e.ToTable("Customers", "customers");
            e.HasIndex(c => new { c.TenantId, c.Phone }).IsUnique().HasFilter("[Phone] IS NOT NULL");
            e.HasOne<Tenant>().WithMany().HasForeignKey(c => c.TenantId).OnDelete(DeleteBehavior.Restrict);
            e.HasQueryFilter(c => c.TenantId == CurrentTenantId);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Now db.Customers.ToListAsync() only ever returns the signed-in business's customers, everywhere, without anyone writing a Where.

3. The detail that makes it safe: the default is nothing

CurrentTenantId is Guid?. When nobody is signed in, it's null — and c.TenantId == null matches no rows.

That means the failure mode of a forgotten [Authorize] or a background job without a tenant is an empty list, not every business's data. Fail closed, not open.

4. Unique indexes include the tenant

A phone number should be unique within a business, not across the whole platform:

e.HasIndex(c => new { c.TenantId, c.Phone }).IsUnique().HasFilter("[Phone] IS NOT NULL");
Enter fullscreen mode Exit fullscreen mode

Two shops can both have a customer on 0300 1234567; one shop can't create the same customer twice.

5. Keep IgnoreQueryFilters() rare and greppable

Some code genuinely runs before there's a tenant: sign-in (you're looking up the user to find their tenant), self sign-up, and platform jobs like billing webhooks. Those are the only places that call IgnoreQueryFilters(), and code review checks each one. If you see it in a feature module, that's a bug.

6. Prove it with one test

The most valuable test in my suite creates two businesses, has A create records, and has B try to read and change them:

[Fact]
public async Task No_record_of_one_business_can_be_read_or_changed_by_another()
{
    var a = await api.CreateTenantAsync();
    var b = await api.CreateTenantAsync();
    var ownerA = await a.SignInOwnerAsync();
    var ownerB = await b.SignInOwnerAsync();

    var customer = await IdOf(await ownerA.Client.PostAsJsonAsync("/api/v1/customers", new { name = "Ali" }));

    var read = await ownerB.Client.GetAsync($"/api/v1/customers/{customer}");
    Assert.Equal(HttpStatusCode.NotFound, read.StatusCode);

    var write = await ownerB.Client.PutAsJsonAsync($"/api/v1/customers/{customer}", new { name = "Hacked" });
    Assert.False(write.IsSuccessStatusCode);
}
Enter fullscreen mode Exit fullscreen mode

It runs against a real SQL Server (LocalDB locally, a container in CI). When someone adds an entity and forgets the filter, this is the test that catches it — before a customer does.

Want the whole thing?

The SaaS Starter Kit is this pattern plus everything around it: self sign-up into a trial, JWT with rotating refresh tokens, roles and permissions, plans with server-enforced limits, a signed billing webhook, an audit log, Docker, CI and 31 integration tests — ASP.NET Core 10 and Angular 22.

How do you isolate tenants — filters, schemas, or a database per tenant? I'd like to hear in the comments.

Top comments (0)