Building Multi-Tenant Applications in .NET

When I started working on a payroll and HR system for multiple clients, the biggest technical hurdle was building it as a multi-tenant application. Each client needed their own private data, some custom configurations, and a smooth onboarding experience, all without duplicating the whole system.

If you’re developing a SaaS product in .NET and thinking about multi-tenancy, this post is for you. I’ll walk you through real decisions I made, the code that worked, and the lessons I wish I knew earlier.

What is Multi-Tenancy?

Multi-tenancy is an architecture where a single software instance serves multiple customers (called “tenants”). Tenants share the application but should feel like they are the only user of the system. Their data must be isolated, settings customizable, and access controlled.

Let’s visualize the concept:

Plaintext
App Instance
├── Tenant A → Data A
├── Tenant B → Data B
└── Tenant C → Data C

There are different ways to implement this, and each comes with trade-offs.

Architecture Options

Option 1: Single Database, Shared Schema

In this model, all tenants share the same database and the same tables. Each row is tagged with a TenantId to identify which tenant owns it.

Pros:

  • Simple and low-cost to maintain
  • Easy to scale horizontally
  • Shared resources (e.g., connection pool, cache)

Cons:

  • Requires strict row-level filtering to avoid data leakage
  • Difficult to support tenant-specific customizations
  • Increased testing complexity

Example:

C#
public class ApplicationDbContext : DbContext
{
    private readonly string _tenantId;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, string tenantId)
        : base(options)
    {
        _tenantId = tenantId;
    }

    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>().HasQueryFilter(c => c.TenantId == _tenantId);
    }
}
Option 2: Single Database, Separate Schemas

Each tenant has their own schema in the same database. For example, TenantA.Users, TenantB.Users, etc.

Pros:

  • Stronger data isolation than shared schema
  • Still benefits from a single database to manage
  • Easier to drop or migrate individual tenants

Cons:

  • Schema management becomes complex
  • Harder to query across tenants
  • Higher overhead for schema updates

Example:

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    string tenantSchema = GetSchemaFromTenantContext();
    modelBuilder.HasDefaultSchema(tenantSchema);
    // Define entities
}

Note: You’ll need to manage DbContextOptions to identify and select the correct schema at runtime.

Option 3: Separate Database per Tenant

Each tenant has a fully isolated database, possibly with identical schema.

Pros:

  • Maximum data isolation
  • Can fully customize schema per tenant
  • Easier to backup, restore, or delete individual tenants

Cons:

  • Expensive to maintain at scale
  • Harder to apply schema updates globally
  • More complex infrastructure management

Example:

C#
public class TenantDbContext : DbContext
{
    public TenantDbContext(DbContextOptions<TenantDbContext> options) : base(options) {}
}

// Service registration with per-tenant connection string
services.AddDbContext<TenantDbContext>((serviceProvider, options) =>
{
    var tenant = GetTenantInfo();
    options.UseSqlServer(tenant.ConnectionString);
});
Choosing the Right Architecture
RequirementRecommended Option
Few tenantsSeparate Database
Many tenants, low budgetShared Schema
Strong isolation neededSeparate Schema or Database
Easy global schema changesShared Schema
Custom per-tenant logicSeparate Database

Tenant Resolution Strategy

To serve each tenant correctly, we must identify them early in the request pipeline. We used subdomains like:

Plaintext
https://abc.payatcloud.com

This abc is the tenant key.

Here’s our middleware:

C#
public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
    {
        var host = context.Request.Host.Host;
        var tenantId = host.Split('.')[0];
        tenantProvider.SetTenant(tenantId);

        await _next(context);
    }
}

Registered in Startup.cs

C#
app.UseMiddleware<TenantMiddleware>();

The middleware helps us figure out who is making the request before we do anything else. Think of it as a doorman for your application, it checks which tenant the request belongs to and hands that information to the rest of the app.

Here’s how it makes our lives easier:

  1. Tenant Identification. The first job is to detect which tenant sent the request. By pulling the subdomain (acme in acme.myapp.com), we know which company or client is interacting with our app—without needing a login token or body data.
  2. Consistent Access Across the App. After we identify the tenant, we store the tenantId in a scoped service (ITenantProvider). This way, any service, controller, or database query during that request knows exactly which tenant it’s serving—no need to pass the ID around manually.
  3. Clean URL Design. Using subdomains keeps our URLs elegant and clear. We avoid messy query parameters like ?tenant=abc, and each tenant gets their own clean space to work in—something that also helps with branding and SSL certificates if needed.

Storing and Using Tenant Context

Once we extract the tenant from the subdomain, we need a way to carry that information throughout the rest of the request. That’s where the TenantProvider comes in, it acts like a backpack that each request carries, holding the tenant ID so we can use it anywhere in the app.

Here’s the idea: we define a scoped service that lives only for the duration of a single request. This is important because you don’t want one user’s tenant ID leaking into another’s request by accident. Every time a request comes in, a fresh TenantProvider instance is created just for that request.

Here’s the basic structure:

C#
public interface ITenantProvider
{
    string TenantId { get; }
    void SetTenant(string tenantId);
}

public class TenantProvider : ITenantProvider
{
    public string TenantId { get; private set; }

    public void SetTenant(string tenantId)
    {
        TenantId = tenantId;
    }
}

We register this service as Scoped in the Startup.cs or wherever you set up your DI container:

C#
services.AddScoped<ITenantProvider, TenantProvider>();

Now, any part of our app, controllers, services, even Entity Framework—can pull the current tenant ID from this provider without needing to know where it came from or how it was extracted. It’s a simple pattern, but it becomes the backbone of tenant-aware logic throughout the whole application.

Securing Data with Global Filters

Once we know which tenant is making the request and we’ve stored that info safely in the TenantProvider, the next big question is: how do we make sure they can only access their own data?

This is where global query filters in Entity Framework Core become a lifesaver. Instead of adding where TenantId = … in every query (which is both error-prone and exhausting), we tell EF Core, “Hey, anytime you’re fetching a certain entity, make sure it’s filtered by the current tenant ID.”

Here’s what that looks like:

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Employee>().HasQueryFilter(e => e.TenantId == _tenantProvider.TenantId);
}

This line ensures that every query to the Employee table automatically includes the tenant filter. Even if you forget to add a condition in your LINQ, this one’s always there—no accidental data leaks.

But here’s the catch: _tenantProvider is a service, and services aren’t naturally available during OnModelCreating. To get around that, I followed Microsoft’s recommended approach of injecting dependencies via a factory or using constructor-based DbContext initialization.

It might feel like an extra step, but this simple filter saved me countless hours of debugging and gave me peace of mind that one client could never see another client’s data—even if something went wrong in the frontend or business logic.

Per-Tenant Configurations and Branding

After we got the tenant filtering working, the next challenge was making the app feel like it belonged to each client. Not just in terms of data, but in branding too, like their own company name, logo, and email templates.

To handle this, I created a simple Tenants table in the database. It stored things like:

  • TenantId (e.g., abc)
  • CompanyName
  • LogoUrl
  • TimeZone
  • PrimaryColor, SecondaryColor
  • Custom email settings (sender name, footer notes, etc.)

Here’s a rough example of what the model looked like:

C#
public class TenantSettings
{
    public string TenantId { get; set; }
    public string CompanyName { get; set; }
    public string LogoUrl { get; set; }
    public string TimeZone { get; set; }
    public string PrimaryColor { get; set; }
    public string EmailFooterNote { get; set; }
}

Why Use In-Memory Caching?

Fetching tenant settings from the database every single request? Not ideal.

So I added a simple in-memory cache to avoid that. Since the settings rarely change, caching them after the first request was a big performance win.

Here’s a sample service that does just that:

C#
public class TenantSettingsService
{
    private readonly IMemoryCache _cache;
    private readonly AppDbContext _dbContext;

    public TenantSettingsService(IMemoryCache cache, AppDbContext dbContext)
    {
        _cache = cache;
        _dbContext = dbContext;
    }

    public TenantSettings GetSettings(string tenantId)
    {
        return _cache.GetOrCreate($"tenant-settings-{tenantId}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // optional expiration
            return _dbContext.Tenants.FirstOrDefault(t => t.TenantId == tenantId);
        });
    }
}

Then in a controller or service that needs branding:

C#
var tenantId = _tenantProvider.TenantId;
var settings = _tenantSettingsService.GetSettings(tenantId);

ViewBag.Logo = settings.LogoUrl;
ViewBag.CompanyName = settings.CompanyName;

How It Feels in the App?

  • The login screen shows the tenant’s logo and name.
  • System emails use the tenant’s preferred signature.
  • The dashboard colors reflect their brand identity.
  • Reports are formatted in their preferred time zone.

These little things made a big difference, clients felt like the app was theirs, not some generic off-the-shelf tool.

Authentication and Authorization per Tenant

We integrated IdentityServer. One trap: if you’re not careful, users can log in from a different tenant context. So we added a tenant ID claim during login:

C#
claims.Add(new Claim("tenant_id", tenantId));

And validated that claim on every request.

Tenant-Aware Logging

We used Serilog:

C#
LogContext.PushProperty("TenantId", tenantProvider.TenantId);

This saved us hours debugging bugs specific to one client.

Admin and Tenant Contexts

Admins could switch between tenants. We added IsGlobalAdmin in the TenantProvider, and used it to bypass query filters if needed.

C#
.HasQueryFilter(e => tenantProvider.IsGlobalAdmin || e.TenantId == tenantProvider.TenantId)

Metrics and Performance

We tracked performance per tenant using Application Insights:

  • Request Duration
  • Failed Requests
  • DB Query Times

It helped us find slow tenants (some had massive reports).

Final Thoughts

Building multi-tenant in .NET was a lot more than just adding TenantId column. It required:

  • A consistent way to resolve tenant
  • Strong isolation in code and queries
  • Per-tenant migrations, logging, and configuration
  • Avoiding accidental cross-tenant data leaks

Would I do it the same way again? Mostly yes. But I would start with separate schemas early to support bigger clients.

References

This blog post is based on my personal experience building a real-world multi-tenant payroll and HR system in .NET. All insights, code examples, and challenges reflect practical lessons from that journey. Some technical concepts and approaches were also informed by the references listed below.

Assi Arai
Assi Arai
Articles: 31