Multi-Tenant SaaS .NET begins the moment you decide to serve more than one customer from an existing single-tenant codebase. Most SaaS products start that way: one customer, one database, code written with the quiet assumption that every row is yours. The migration to tenancy is mechanical if you keep it data-first: introduce a TenantId column, backfill it, make EF Core filter every query, and only then add tenant resolution and authentication claims. This guide walks the stages in the order that keeps a production app safe at every step.
Stage One: Add TenantId Everywhere
Start with the data model. Every tenant-owned entity gets a TenantId column, and the configuration marks it required and indexes it. Composite keys and unique constraints must include TenantId, so two tenants can both have invoice number 1001.
modelBuilder.Entity<Order>(entity =>
{
entity.Property(o => o.TenantId).IsRequired();
entity.HasIndex(o => new { o.TenantId, o.Number }).IsUnique();
});
Decide which tables are tenant-owned and which are global reference data. Countries, currencies, and system settings usually stay global; orders, customers, and documents get TenantId. Get this split right early, because it is painful to change later.
A useful heuristic is to ask what happens if two tenants call the same endpoint at the same time. Any table that would mix their rows needs TenantId; any table whose rows are genuinely shared, like a list of supported currencies, does not. Write the decision down, because the marker interface approach in stage three will automate whatever you decide here.
Stage Two: Backfill and Split Data
Before any multi-tenant code ships, every existing row must belong to a tenant. Create the first tenant record and backfill with a one-off migration, then verify row counts per table. The SQL below is the shape of that migration for a customer already using the system.
migrationBuilder.Sql("""
INSERT INTO "Tenants" ("Id", "Name", "Code")
VALUES ('11111111-1111-1111-1111-111111111111', 'Default', 'default');
UPDATE "Orders" SET "TenantId" = '11111111-1111-1111-1111-111111111111';
""");
Audit every INSERT and UPDATE path while you are here, including integration jobs and admin tooling. A row created without a TenantId is a row that will leak or vanish once filtering is enabled.
Stage Three: Global Query Filters for Multi-Tenant SaaS .NET
With the data in place, turn on the safety net. EF Core global query filters append a tenant predicate to every query against a tenant-owned entity. The loop below applies the filter to every type that implements ITenantEntity, so you cannot forget to filter a new table.
protected override void OnModelCreating(ModelBuilder builder)
{
foreach (var type in builder.Model.GetEntityTypes())
{
if (typeof(ITenantEntity).IsAssignableFrom(type.ClrType))
{
var param = Expression.Parameter(type.ClrType, "e");
var body = Expression.Equal(
Expression.Property(param, "TenantId"),
Expression.Constant(_tenant.Id));
builder.Entity(type.ClrType)
.HasQueryFilter(Expression.Lambda(body, param));
}
}
}
Once the filter is live, add an isolation test to the build pipeline: authenticate as tenant A, run a representative set of queries, and assert that zero rows from tenant B appear. This test catches regressions the moment a developer forgets that a new endpoint exists.
Stage Four: Tenant Resolution and Authentication
Now the app needs to know who the caller is. Wire JWT authentication and ASP.NET Core Identity, add a tenant_id claim at login, and resolve the TenantContext from the claim in middleware. The auth article in this series covers the details; the middleware below is the production shape.
app.Use(async (context, next) =>
{
var tenantId = context.User.FindFirst("tenant_id")?.Value;
if (tenantId is not null)
{
context.RequestServices
.GetRequiredService<TenantContext>().Id = Guid.Parse(tenantId);
}
await next();
});
At this point the app is multi-tenant end to end: resolution at the edge, filtering in the data layer, and authentication binding users to tenants.
Stage Five: Ship It as Multi-Tenant SaaS .NET
Deploy once and serve many. Because tenancy lives in the data layer, one application instance handles every customer, and a new tenant is a row in the Tenants table rather than a new deployment. Connection strings, pricing tiers, and isolation levels can all be chosen per tenant. This is the architecture the Indotalent SaaS CRM and HRM ship as complete .NET 10 source code, and it is exactly the end state of a careful single-tenant migration.
Staging the migration this way keeps the deployment boring: each stage is a normal pull request with tests, not a big-bang rewrite. Your existing users keep working during the process, and the first real tenant is simply the second row in the Tenants table. From there, adding another customer is a data operation and a signup flow, not an architecture project.
Key Takeaways
- Add TenantId to every tenant-owned entity and include it in unique constraints
- Backfill existing rows before enabling any filter
- Global query filters plus an automated isolation test turn filtering into a structural guarantee
- Add JWT tenant claims and middleware resolution last, once the data is safe
- One deployment serves every tenant once tenancy lives in the data layer
FAQ
Do I have to rewrite every query? No. Global query filters cover LINQ queries automatically. You do have to audit raw SQL and use IgnoreQueryFilters only in deliberate, named places.
What happens to shared lookup tables? Keep truly global reference data outside the tenant filter, and scope only tenant-owned data. The ITenantEntity marker makes the boundary explicit.
Should I migrate straight to schema-per-tenant? Start with the row-level model; it is the fastest safe path. You can graduate specific tenants to dedicated schemas or databases later without changing the query layer.
How do I validate the migration before production? Clone the production database, run the backfill, run the isolation tests, and compare query results before and after on a staging environment.