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