How I structure multi-tenant SaaS in ASP.NET Core
Row-level tenancy, EF Core global query filters, and the one mistake that leaks another customer's data.
Most of the SaaS products I've shipped are multi-tenant. One database, one deployment, many customers sharing the same tables. It's cheaper to run and easier to migrate than a database-per-tenant setup, but it has one failure mode that keeps engineers awake: a query that forgets its tenant and returns someone else's rows.
Here's the structure I've settled on after building a few of these for tuition management, CRM, and commerce.
Resolve the tenant once, at the edge
Every request needs to know which tenant it belongs to before any query runs. I resolve it in middleware, from a subdomain, a header, or a claim on the authenticated user, and stash it in a scoped service.
public class TenantContext
{
public Guid TenantId { get; private set; }
public void Set(Guid tenantId) => TenantId = tenantId;
}
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext ctx, TenantContext tenant)
{
var claim = ctx.User.FindFirst("tenant_id")?.Value;
if (Guid.TryParse(claim, out var id))
tenant.Set(id);
await _next(ctx);
}
}
The TenantContext is registered as Scoped, so it lives for exactly one request. Nothing downstream has to pass the tenant id around by hand.
Push isolation into the DbContext, not the queries
The tempting approach is to add .Where(x => x.TenantId == tenant.TenantId) to every query. Do that and you will forget it exactly once, and that once is a data breach. Instead I put a global query filter on the DbContext so EF Core adds the predicate automatically.
public class AppDbContext : DbContext
{
private readonly Guid _tenantId;
public AppDbContext(DbContextOptions options, TenantContext tenant)
: base(options) => _tenantId = tenant.TenantId;
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Invoice>().HasQueryFilter(x => x.TenantId == _tenantId);
b.Entity<Customer>().HasQueryFilter(x => x.TenantId == _tenantId);
}
}
Now db.Invoices.ToList() only ever returns the current tenant's invoices. The filter also applies to navigation loads and joins, so related data stays scoped too.
The writes need help as well
Query filters only cover reads. On insert, nothing stops you saving a row with the wrong tenant id, or none at all. I override SaveChanges to stamp the tenant on new entities.
public override int SaveChanges()
{
foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
{
if (entry.State == EntityState.Added)
entry.Entity.TenantId = _tenantId;
}
return base.SaveChanges();
}
Every tenant-owned entity implements ITenantOwned with a single TenantId property. Forgetting to set it becomes impossible, which is exactly the property you want for something this sensitive.
Where it leaks anyway
Two places break the filter, and both have bitten me.
The first is background jobs. A Celery-style worker or a hosted service runs outside the HTTP pipeline, so there's no request to resolve a tenant from. The TenantContext is empty and the filter matches Guid.Empty, quietly returning nothing. For jobs I create the DbContext manually with the tenant id passed in the job payload, never inheriting from an ambient context.
The second is IgnoreQueryFilters(). It exists for admin tooling and cross-tenant reports, and it's the one call that turns off every guardrail at once. I ban it in normal code and only allow it in a dedicated admin service that's audited separately. If you grep the codebase and find it somewhere unexpected, treat that as a bug report.
Indexing for the filter
Since every query now carries WHERE TenantId = @p, that column belongs at the front of your composite indexes. A lookup on (TenantId, CreatedAt) serves both the tenant filter and the ordering in one index. Leaving TenantId out means Postgres or SQL Server scans across every tenant's rows before filtering, which gets slow the moment one customer grows large. I wrote more about picking these in the Postgres indexes I reach for first.
Does it scale?
Row-level tenancy holds up well into the thousands of tenants. The point where I'd reconsider is when one customer needs data residency in a specific region, or a single tenant's volume dwarfs everyone else's and starts affecting query plans for the rest. At that point you split the noisy tenant into its own database while keeping the shared one for the long tail. The code above doesn't change, only the connection string the tenant resolves to.
Start with the shared model. It's less infrastructure to run, and the query filter plus the SaveChanges stamp cover the dangerous cases. Just never trust yourself to remember the WHERE clause by hand.