Multi-Tenancy in .NET — Patterns and Implementation
Build multi-tenant .NET systems: tenant isolation strategies (shared DB, schema-per-tenant, DB-per-tenant), EF Core global query filters, tenant resolution from JWT/subdomain, and trade-offs.
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."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.