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.
Top comments (0)