.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 mandatesStrategy 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.
// 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; }
}// 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);
}
}
}// 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);
}
}
}// 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.
// 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; }
}// 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.
// 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 });
}
}// 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)
// 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
// 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)
// 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
// 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
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 storeInterview 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."