Learnixo

.NET & C# Development · Lesson 156 of 229

Multi-Tenancy in .NET — Patterns and Implementation

Multi-Tenancy in .NET — Patterns and Implementation

Multi-tenancy means one application instance serves multiple customers (tenants) with strict data isolation. The isolation strategy you choose determines cost, complexity, and regulatory compliance.


Isolation Strategy Comparison

Strategy              Isolation    Cost/Tenant   Complexity   When to Use
────────────────────────────────────────────────────────────────────────────
Shared DB + row filter  Low         Very low      Low          SaaS startups, <1000 tenants
Schema-per-tenant       Medium      Low           Medium       Regulated data, easier backups
DB-per-tenant           High        High          High         Enterprise, HIPAA/GDPR mandates

Strategy 1: Shared Database with Row-Level Filter

All tenants share one database. Every table has a TenantId column. EF Core global query filters ensure tenants never see each other's data.

C#
// Domain entity — all entities implement this interface
public interface ITenantEntity
{
    Guid TenantId { get; set; }
}

public class Order : ITenantEntity
{
    public int    Id         { get; set; }
    public Guid   TenantId   { get; set; }
    public string Status     { get; set; } = "";
    public decimal Total     { get; set; }
}
C#
// TenantContext — resolved once per request
public interface ITenantContext
{
    Guid TenantId { get; }
}

public class HttpTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public Guid TenantId
    {
        get
        {
            var claim = accessor.HttpContext?.User.FindFirst("tenant_id")?.Value
                ?? throw new InvalidOperationException("No tenant_id claim");
            return Guid.Parse(claim);
        }
    }
}
C#
// DbContext with global query filter
public class AppDbContext(DbContextOptions<AppDbContext> opts, ITenantContext tenant)
    : DbContext(opts)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder model)
    {
        // Apply filter to every entity that implements ITenantEntity
        foreach (var entityType in model.Model.GetEntityTypes())
        {
            if (!typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) continue;

            // Global query filter — automatically appended to every query
            var tenantIdParam = Expression.Constant(tenant.TenantId);
            var param         = Expression.Parameter(entityType.ClrType, "e");
            var prop          = Expression.Property(param, nameof(ITenantEntity.TenantId));
            var filter        = Expression.Lambda(Expression.Equal(prop, tenantIdParam), param);

            entityType.SetQueryFilter(filter);
        }
    }
}
C#
// Registration — scoped so TenantContext is resolved per request
builder.Services.AddScoped<ITenantContext, HttpTenantContext>();
builder.Services.AddDbContext<AppDbContext>();   // scoped by default

// When writing, set TenantId automatically
public class TenantSaveChangesInterceptor(ITenantContext tenant) : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        SetTenantIds(eventData.Context!);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
    {
        SetTenantIds(eventData.Context!);
        return base.SavingChangesAsync(eventData, result, ct);
    }

    private void SetTenantIds(DbContext context)
    {
        foreach (var entry in context.ChangeTracker.Entries<ITenantEntity>()
            .Where(e => e.State == EntityState.Added))
        {
            entry.Entity.TenantId = tenant.TenantId;
        }
    }
}

Strategy 2: Schema-Per-Tenant

Each tenant gets their own schema in the same database (tenant_abc.Orders, tenant_xyz.Orders). Schema name is set on the connection before EF Core uses it.

C#
// AppDbContext that switches schema per tenant
public class AppDbContext(DbContextOptions<AppDbContext> opts, ITenantContext tenant)
    : DbContext(opts)
{
    protected override void OnModelCreating(ModelBuilder model)
    {
        // All tables go into the tenant's schema
        model.HasDefaultSchema(tenant.SchemaName);

        model.Entity<Order>().ToTable("Orders");
        model.Entity<Customer>().ToTable("Customers");
    }
}

// Tenant context includes schema name
public interface ITenantContext
{
    Guid   TenantId   { get; }
    string SchemaName { get; }
}
C#
// Migration: create schema + run migrations per tenant
public class TenantMigrationService(IServiceScopeFactory scopeFactory)
{
    public async Task MigrateTenantAsync(TenantInfo tenant)
    {
        using var scope   = scopeFactory.CreateScope();
        var       context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        // Create schema if it doesn't exist
        await context.Database.ExecuteSqlRawAsync(
            $"CREATE SCHEMA IF NOT EXISTS {tenant.SchemaName}");

        // Run migrations scoped to that schema
        await context.Database.MigrateAsync();
    }
}

Strategy 3: Database-Per-Tenant

Each tenant has their own database. Connection string is resolved at runtime.

C#
// Tenant store — resolves connection string from tenant ID
public interface ITenantStore
{
    Task<TenantInfo?> GetTenantAsync(Guid tenantId, CancellationToken ct = default);
}

public record TenantInfo(Guid Id, string ConnectionString, string SchemaName);

public class SqlTenantStore(IDbConnection adminDb) : ITenantStore
{
    public async Task<TenantInfo?> GetTenantAsync(Guid tenantId, CancellationToken ct)
    {
        const string sql = "SELECT Id, ConnectionString, SchemaName FROM Tenants WHERE Id = @tenantId";
        return await adminDb.QuerySingleOrDefaultAsync<TenantInfo>(sql, new { tenantId });
    }
}
C#
// DbContext factory that builds a context per tenant
public class TenantDbContextFactory(ITenantContext tenant, ITenantStore store)
{
    public async Task<AppDbContext> CreateAsync(CancellationToken ct = default)
    {
        var info = await store.GetTenantAsync(tenant.TenantId, ct)
            ?? throw new InvalidOperationException($"Tenant {tenant.TenantId} not found");

        var opts = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(info.ConnectionString)
            .Options;

        return new AppDbContext(opts, tenant);
    }
}

Tenant Resolution Strategies

From JWT Claims (most common for API)

C#
// JWT payload includes tenant_id claim
// { "sub": "user-123", "tenant_id": "tenant-abc-guid", "role": "admin" }

public class JwtTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public Guid TenantId
    {
        get
        {
            var raw = accessor.HttpContext?.User.FindFirst("tenant_id")?.Value;
            if (raw is null || !Guid.TryParse(raw, out var id))
                throw new UnauthorizedAccessException("Missing or invalid tenant_id claim");
            return id;
        }
    }
}

From Subdomain

C#
// api.acme.example.com → tenant slug = "acme"
public class SubdomainTenantContext(IHttpContextAccessor accessor, ITenantStore store) : ITenantContext
{
    private Guid? _tenantId;

    public Guid TenantId
    {
        get
        {
            if (_tenantId.HasValue) return _tenantId.Value;

            var host = accessor.HttpContext?.Request.Host.Host ?? "";
            var slug = host.Split('.').FirstOrDefault() ?? "";

            var info = store.GetTenantBySlugAsync(slug).GetAwaiter().GetResult()
                ?? throw new InvalidOperationException($"Unknown tenant slug: {slug}");

            _tenantId = info.Id;
            return info.Id;
        }
    }
}

From Request Header (internal services)

C#
// X-Tenant-Id: guid — set by API gateway after auth
public class HeaderTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public Guid TenantId
    {
        get
        {
            var raw = accessor.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
            if (raw is null || !Guid.TryParse(raw, out var id))
                throw new InvalidOperationException("Missing X-Tenant-Id header");
            return id;
        }
    }
}

Bypassing the Filter for Admin Operations

C#
// Admin query — ignore tenant filter to access all tenants' data
var allOrders = await context.Orders
    .IgnoreQueryFilters()   // bypass the global tenant filter
    .Where(o => o.Status == "Pending")
    .ToListAsync(ct);

// Background jobs often need cross-tenant access
public class TenantSummaryJob(AppDbContext context)
{
    public async Task RunAsync(CancellationToken ct)
    {
        var summary = await context.Orders
            .IgnoreQueryFilters()
            .GroupBy(o => o.TenantId)
            .Select(g => new { TenantId = g.Key, Count = g.Count() })
            .ToListAsync(ct);
    }
}

Testing Multi-Tenant Logic

C#
public class OrderQueryTests
{
    [Fact]
    public async Task GetOrders_ReturnsOnlyCurrentTenantOrders()
    {
        var tenantA = Guid.NewGuid();
        var tenantB = Guid.NewGuid();

        // Seed data for both tenants
        var opts = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        await using (var seedCtx = new AppDbContext(opts, new FakeTenantContext(tenantA)))
        {
            seedCtx.Orders.AddRange(
                new Order { TenantId = tenantA, Status = "Pending" },
                new Order { TenantId = tenantB, Status = "Pending" });
            await seedCtx.SaveChangesAsync();
        }

        // Query as Tenant A — should only see their order
        await using var ctx = new AppDbContext(opts, new FakeTenantContext(tenantA));
        var orders = await ctx.Orders.ToListAsync();

        Assert.Single(orders);
        Assert.Equal(tenantA, orders[0].TenantId);
    }
}

file sealed class FakeTenantContext(Guid tenantId) : ITenantContext
{
    public Guid TenantId => tenantId;
}

Trade-Off Summary

Shared DB + row filter:
  Pros: cheapest, simplest, one migration for all tenants
  Cons: one slow tenant hurts all (noisy neighbour), harder GDPR deletion
  Risk: missing filter = data leak — test this aggressively

Schema-per-tenant:
  Pros: pg_dump per tenant, easier data deletion, moderate isolation
  Cons: migration must run per schema, schema sprawl at 1000+ tenants
  Risk: migration drift across schemas

DB-per-tenant:
  Pros: complete isolation, separate backups, size/scaling per tenant
  Cons: connection pool explosion (10,000 tenants = 10,000 pools), expensive
  Risk: infrastructure cost and operational complexity

Hybrid (common in practice):
  Small tenants: shared DB + row filter
  Large/enterprise tenants: dedicated DB
  Route based on tier in the tenant store

Interview Answer

"Multi-tenancy means one app serves multiple customers with strict data isolation. The three main strategies: (1) Shared database with row-level filtering — every table has a TenantId column, EF Core global query filters automatically scope all queries; cheapest but noisy-neighbour risk and must never call IgnoreQueryFilters accidentally; (2) Schema-per-tenant — each tenant gets their own schema in the same database; good for compliance and per-tenant backup/deletion; migration must run per schema; (3) Database-per-tenant — complete isolation, expensive, connection pool explosion at scale. In practice: resolve the tenant from the JWT tenant_id claim in a scoped ITenantContext service; register it in DI; EF Core's global query filter calls tenant.TenantId at query time. For writes, use a SaveChanges interceptor to set TenantId automatically. Testing: always verify the filter actually isolates data by seeding two tenants and asserting one tenant's query cannot return the other's rows."