SaaSAugust 2026 · 7 min read

Multi-Tenant SaaS .NET: The Complete Architecture Guide

TL;DR

Multi-Tenant SaaS .NET means one application instance serving many customers, with tenant resolution that maps each request to a tenant and tenant isolation that keeps their data separate. In ASP.NET Core 10 you implement this with a Scoped TenantContext, EF Core global query filters, and tenant-aware services — no separate deployment per customer.

Multi-Tenant SaaS .NET is the architecture behind products like the Indotalent CRM Multitenant and HRM Multitenant: a single ASP.NET Core 10 deployment that serves thousands of customers from one codebase. Each customer is a tenant, logs in, sees only their own data, and pays a subscription. Building this well requires two mechanisms working together. Tenant resolution answers the question "which tenant is making this request?", and tenant isolation guarantees the answer is enforced everywhere data is touched, from a grid query to a background job.

Tenant Resolution in Multi-Tenant SaaS .NET

Tenant resolution is the process of determining which tenant owns a request before any business code runs. The three dominant mechanisms are a subdomain, an HTTP header, and a JWT claim. A subdomain such as acme.app.indotalent.com maps cleanly to a tenant and works well for branded customer portals. An X-Tenant-Id header is convenient for APIs and machine-to-machine calls. A JWT claim is the most reliable option for authenticated traffic, because the token is already signed and validated by the time your application sees it. In practice you build a fallback chain: resolve from the JWT claim first, then the header, then the subdomain.

Whichever source you pick, resolve the tenant exactly once per request and store the result in a Scoped service. A DI scope lives and dies with one HTTP request, so the TenantContext cannot leak from one customer into another. The middleware below reads the claim, falls back to the header, then to the subdomain, and writes the TenantContext before the next middleware runs.

// Infrastructure/Tenancy/TenantContext.cs
public sealed class TenantContext
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

// Startup: resolve once per request
app.Use(async (context, next) =>
{
    var tenantId = context.User.FindFirst("tenant_id")?.Value
        ?? context.Request.Headers["X-Tenant-Id"].FirstOrDefault()
        ?? context.Request.Host.Host.Split('.')[0];

    if (tenantId is not null)
    {
        context.RequestServices
            .GetRequiredService<TenantContext>().Id = Guid.Parse(tenantId);
    }

    await next();
});

The important detail is that the middleware runs before controllers, Blazor circuits, or minimal API handlers resolve their services. By the time a feature slice asks for a DbContext, the TenantContext is already populated. This single point of resolution keeps every other layer simple: no endpoint should ever parse a tenant identifier itself.

Isolation Models for Multi-Tenant SaaS .NET

Tenant resolution tells you who the caller is; isolation decides how far apart tenants' data lives. The spectrum ranges from cheapest to most isolated: a shared database with row-level tenant filtering, a shared database with a schema per tenant, and a dedicated database per tenant. Most SaaS products start on the left side of the spectrum and move right only for customers who pay for stronger guarantees.

  • Shared database, shared schema. A TenantId column on every tenant-owned row plus an EF Core global query filter. Lowest cost, one set of migrations, easy analytics.
  • Shared database, schema per tenant. One server, separate schemas per tenant. Clean separation without provisioning new infrastructure.
  • Database per tenant. Complete isolation for compliance-heavy customers. Highest cost, but a dedicated connection string and backup per customer.

Row-level filtering is the default for most SaaS products because it maximizes efficiency and keeps operations single. The catch is that one missing WHERE clause leaks data across tenants. That is why the filter must be structural, not a convention that developers are expected to remember.

Tenant-Aware Services and EF Core Query Filters

EF Core global query filters turn row-level isolation from a convention into a structural guarantee. When an entity is configured with HasQueryFilter and the filter reads the TenantContext, every LINQ query that touches that entity silently gains a WHERE TenantId predicate. The AppDbContext below takes the TenantContext through its constructor and applies the filter in OnModelCreating.

// Data/AppDbContext.cs
public sealed class AppDbContext : DbContext
{
    private readonly TenantContext _tenant;

    public AppDbContext(DbContextOptions<AppDbContext> options,
                        TenantContext tenant)
        : base(options) => _tenant = tenant;

    public DbSet<Contact> Contacts => Set<Contact>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Contact>().HasQueryFilter(
            c => c.TenantId == _tenant.Id);
    }
}

Because AppDbContext is registered as Scoped, and the TenantContext is also Scoped and set before any query runs, the filter always closes over the correct tenant for the current request. The same pattern applies to the write side: set TenantId from the context when creating entities, and treat any code that ignores the filter, such as IgnoreQueryFilters or a raw SQL string, as a security boundary that needs its own review.

Sequencing the Request Pipeline

Put the pieces together and the request flow is: the request arrives, authentication validates the token, middleware copies the tenant claim into the Scoped TenantContext, services resolve against that context, and every EF Core query is filtered by TenantId. Responses then contain only tenant-owned rows. This pipeline is exactly what the Indotalent SaaS CRM and HRM products run today: ASP.NET Core 10, VSA slices, MudBlazor components, JWT Identity, and a tenant context flowing through every layer.

Designing tenancy once, at the infrastructure boundary, is what makes the rest of the codebase look single-tenant. Feature slices, components, and queries read from the shared context and never think about tenancy themselves.

Key Takeaways

  • Tenant resolution should happen once per request and be stored in a Scoped TenantContext
  • Combine JWT claim, header, and subdomain into a fallback chain
  • EF Core global query filters make row-level isolation structural, not conventional
  • Register AppDbContext as Scoped so filters always close over the correct tenant
  • Treat filter bypasses and raw SQL as security boundaries that require review

FAQ

What exactly is Multi-Tenant SaaS .NET? It is one .NET application deployment serving many customer organizations. Each tenant shares the codebase but sees only its own data, enforced by tenant resolution and tenant isolation.

Which tenant resolution method should I use? Use a JWT tenant claim for authenticated users, an X-Tenant-Id header for API clients, and a subdomain for branding. A fallback chain lets you support all three without extra work.

Do global query filters slow down queries? They add one equality predicate per query. With an index on TenantId the impact is small, and you trade that cost for a structural guarantee that every query is filtered.

Can I mix isolation models? Yes. A common production design uses row-level filtering for standard plans and database-per-tenant for enterprise customers, selected per tenant through a strategy.

Ready to study real multi-tenant SaaS code?

Indotalent ships SaaS multi-tenant CRM and HRM products with complete .NET 10 source code — $21 each.

Explore Products