EF Core Multitenancy in .NET 8: Row-Level Filters, Per-Tenant Connection Strings & Tenant Resolution Middleware

The Architecture Decision That Shapes Everything Else

Multitenancy is not a feature you add to an existing application — it is an architectural constraint that shapes every layer of the data access stack from the first line of code. Add it after the fact and you are refactoring every query, every repository, every test, every migration. Design for it from the beginning and EF Core's global query filter system does the isolation work for you at the framework level, invisibly, consistently, for every query your application ever runs. The difference between a multitenant architecture that leaks cross-tenant data and one that provably cannot is not developer vigilance — it is where the isolation boundary is enforced.

EF Core supports both primary multitenancy models. Shared database with row-level isolation uses a TenantId column on every tenant-scoped table and a global query filter that appends WHERE TenantId = @current to every LINQ query automatically. Database-per-tenant uses a dynamically resolved connection string per request and a DbContext that routes to the correct database before executing any query. Both models use the same tenant resolution infrastructure — a scoped ICurrentTenantService populated by middleware — and the choice between them affects the DbContext wiring, not the resolution logic. This article covers both, gives you a clear decision framework, and shows the exact code for each layer.

Two Isolation Models: When to Use Each

The choice between shared-database and database-per-tenant is the single most consequential architectural decision in a multitenant system. It affects cost, operational complexity, compliance posture, query performance characteristics, and how you handle migrations. Making the wrong choice at the start is expensive to reverse — the data access layer, the migration strategy, and the deployment pipeline are all downstream of this decision.

Most SaaS products start with shared database because it is operationally simpler and cost-effective at low tenant counts. Database-per-tenant is the right answer when contractual isolation requirements — GDPR data residency, HIPAA BAA scope, SOC 2 audit boundaries — make shared storage non-compliant, or when individual tenant datasets grow large enough that per-tenant query tuning becomes necessary. The two models are not mutually exclusive: many platforms use shared databases for free-tier tenants and database-per-tenant for enterprise plans.

MultitenancyModels.cs — Shared Database vs Database-Per-Tenant Trade-offs
// ════════════════════════════════════════════════════════════════════════════
// MODEL A: SHARED DATABASE WITH ROW-LEVEL ISOLATION
// ════════════════════════════════════════════════════════════════════════════
//
// Architecture:
//   One database. All tenants share every table.
//   Every tenant-scoped table has a TenantId column.
//   EF Core global query filter appends WHERE TenantId = @current to every query.
//   SaveChanges interceptor populates TenantId on every new entity automatically.
//
// Advantages:
//   ✓ Single database to provision, backup, restore, and monitor
//   ✓ No per-tenant migration runner — one migration applies to all tenants
//   ✓ Cost-efficient for large numbers of small tenants (thousands of free-tier users)
//   ✓ Zero connection string management per tenant
//   ✓ Cross-tenant analytics and reporting are SQL joins, not federated queries
//
// Disadvantages:
//   ✗ A bug in IgnoreQueryFilters() or raw SQL can expose cross-tenant data
//   ✗ Cannot satisfy data residency requirements (GDPR jurisdiction boundaries)
//   ✗ Noisy-neighbour: one large tenant's queries affect all others
//   ✗ Cannot restore a single tenant's data without point-in-time filtering
//   ✗ All tenants share the same schema version — cannot upgrade one at a time
//
// Best for: SMB SaaS platforms, free-tier workloads, teams without dedicated DBAs


// ════════════════════════════════════════════════════════════════════════════
// MODEL B: DATABASE-PER-TENANT
// ════════════════════════════════════════════════════════════════════════════
//
// Architecture:
//   One database per tenant. DbContext resolves connection string per request.
//   No TenantId columns — the database boundary IS the isolation boundary.
//   No global query filter needed — wrong-tenant queries are physically impossible.
//   IDbContextFactory creates a tenant-specific DbContext for each operation.
//
// Advantages:
//   ✓ Physical data isolation — satisfies GDPR residency, HIPAA BAA, SOC 2 scope
//   ✓ Per-tenant backup and restore without affecting other tenants
//   ✓ Per-tenant query performance tuning (indexes, statistics, read replicas)
//   ✓ Independent schema migrations per tenant — can upgrade incrementally
//   ✓ Tenant offboarding: drop the database, done
//
// Disadvantages:
//   ✗ One database instance per tenant — cost scales linearly with tenant count
//   ✗ Migration runner must iterate all tenant databases — slower deployments
//   ✗ Connection pool management across hundreds of connection strings
//   ✗ Cross-tenant reporting requires federated queries or a separate analytics store
//   ✗ Tenant onboarding requires database provisioning (typically 1–5 seconds)
//
// Best for: enterprise SaaS, regulated industries, large tenants with dedicated SLAs


// ════════════════════════════════════════════════════════════════════════════
// THE HYBRID MODEL (common in practice)
// ════════════════════════════════════════════════════════════════════════════
//
// Free / Starter tier  → Shared database, row-level isolation
// Pro / Growth tier    → Shared database with dedicated schema (PostgreSQL schemas)
// Enterprise tier      → Dedicated database per tenant
//
// The tenant resolution middleware and ICurrentTenantService are the same
// across all tiers. The DbContext routing logic branches on the tenant's tier.
// This article covers the two primary models — the hybrid follows naturally.

// ── Tenant registry entry — drives the routing decision ──────────────────
public sealed record TenantInfo(
    string   TenantId,
    string   Name,
    string   Plan,               // "free" | "pro" | "enterprise"
    string?  ConnectionString,   // null for shared-db tenants
    string?  Subdomain);

The tenant registry — the data store that maps incoming requests to TenantInfo records — is the foundation both models share. Whether it lives in a dedicated tenants table in your shared database, a separate configuration database, or an in-memory cache populated from Azure App Configuration, the resolution contract is the same: given an identifier extracted from the request, return a TenantInfo that tells the DbContext how to connect and the global filter what TenantId to use. Design the registry interface before designing the isolation model — everything else is an implementation detail of the registry lookup result.

Tenant Resolution: Subdomain, Header & JWT Claims Strategies

Tenant resolution is the act of extracting a tenant identifier from an incoming HTTP request and making it available to every downstream component — the DbContext, background services, logging enrichers, and authorisation policies — for the duration of that request. The extraction strategy depends on how your application surfaces its multitenancy to clients: subdomains (acme.yourapp.com), request headers (X-Tenant-Id: acme), or a claim in the authenticated JWT ("tid": "acme"). In practice, most platforms use subdomain for browser-facing tenants and JWT claims for API-to-API calls, with header-based resolution as a fallback for clients that cannot manage subdomains.

Middleware/TenantMiddleware.cs + Services/ICurrentTenantService.cs
// ── ICurrentTenantService: the scoped tenant context ─────────────────────
// Scoped to the HTTP request lifetime.
// Every component that needs to know the current tenant resolves this interface.
// Never inject HttpContext directly into DbContext or repositories —
// ICurrentTenantService is the clean abstraction boundary.
public interface ICurrentTenantService
{
    string?    TenantId   { get; }
    TenantInfo? TenantInfo { get; }
    bool       IsResolved { get; }
    void       SetTenant(TenantInfo tenant);
}

public sealed class CurrentTenantService : ICurrentTenantService
{
    private TenantInfo? _tenantInfo;

    public string?     TenantId   => _tenantInfo?.TenantId;
    public TenantInfo? TenantInfo => _tenantInfo;
    public bool        IsResolved => _tenantInfo is not null;

    public void SetTenant(TenantInfo tenant) =>
        _tenantInfo = tenant ?? throw new ArgumentNullException(nameof(tenant));
}

// ── ITenantResolver: strategy interface for extraction ────────────────────
// Implement one per resolution strategy. Register all in DI.
// TenantMiddleware tries each resolver in priority order.
public interface ITenantResolver
{
    int      Priority { get; }   // lower = tried first
    Task ResolveAsync(HttpContext context);
}

// ── Strategy 1: Subdomain resolution ─────────────────────────────────────
// acme.yourapp.com → "acme"
// Handles www. prefix and the root domain (no subdomain = no tenant)
public sealed class SubdomainTenantResolver : ITenantResolver
{
    public int Priority => 1;

    public Task ResolveAsync(HttpContext context)
    {
        var host = context.Request.Host.Host;     // "acme.yourapp.com"
        var parts = host.Split('.');

        // Require at least three parts: subdomain.domain.tld
        if (parts.Length < 3 || parts[0] is "www")
            return Task.FromResult(null);

        return Task.FromResult(parts[0].ToLowerInvariant());
    }
}

// ── Strategy 2: Request header resolution ────────────────────────────────
// X-Tenant-Id: acme
// Common for API-to-API calls and mobile clients
public sealed class HeaderTenantResolver : ITenantResolver
{
    private const string HeaderName = "X-Tenant-Id";
    public int Priority => 2;

    public Task ResolveAsync(HttpContext context)
    {
        var headerValue = context.Request.Headers[HeaderName].FirstOrDefault();
        return Task.FromResult(
            string.IsNullOrWhiteSpace(headerValue) ? null : headerValue.Trim().ToLowerInvariant());
    }
}

// ── Strategy 3: JWT claim resolution ─────────────────────────────────────
// Authenticated requests carry tenant context in a "tid" or "tenant_id" claim.
// Requires UseAuthentication() to run before TenantMiddleware in the pipeline.
public sealed class ClaimsTenantResolver : ITenantResolver
{
    private const string TenantClaimType = "tid";
    public int Priority => 3;

    public Task ResolveAsync(HttpContext context)
    {
        if (!context.User.Identity?.IsAuthenticated ?? true)
            return Task.FromResult(null);

        var claim = context.User.FindFirstValue(TenantClaimType);
        return Task.FromResult(
            string.IsNullOrWhiteSpace(claim) ? null : claim.ToLowerInvariant());
    }
}

// ── TenantMiddleware: orchestrates resolution and DI population ───────────
public sealed class TenantMiddleware(
    IEnumerable resolvers,
    ITenantRegistry              tenantRegistry,
    ILogger    logger)
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantService = context.RequestServices
            .GetRequiredService();

        // Try resolvers in priority order — first non-null result wins
        foreach (var resolver in resolvers.OrderBy(r => r.Priority))
        {
            var tenantIdentifier = await resolver.ResolveAsync(context);

            if (string.IsNullOrEmpty(tenantIdentifier))
                continue;

            var tenantInfo = await tenantRegistry.FindAsync(tenantIdentifier);

            if (tenantInfo is null)
            {
                logger.LogWarning(
                    "Tenant identifier '{Identifier}' resolved but not found in registry.",
                    tenantIdentifier);
                break;
            }

            tenantService.SetTenant(tenantInfo);
            logger.LogDebug("Tenant resolved: {TenantId} via {Resolver}",
                tenantInfo.TenantId, resolver.GetType().Name);
            break;
        }

        // Proceed even if no tenant resolved — endpoints can opt into requiring tenant
        // context via a policy or middleware assertion rather than blocking all requests
        await next(context);
    }
}

// ── Program.cs: registration and pipeline order ───────────────────────────
// builder.Services.AddScoped();
// builder.Services.AddTransient();
// builder.Services.AddTransient();
// builder.Services.AddTransient();
// builder.Services.AddSingleton();
//
// Pipeline order:
// app.UseRouting();
// app.UseAuthentication();   // must be before TenantMiddleware if using ClaimsTenantResolver
// app.UseMiddleware();
// app.UseAuthorization();

The middleware pipeline order for tenant resolution has one hard constraint: if you use ClaimsTenantResolver, UseAuthentication() must appear before UseMiddleware<TenantMiddleware>(). The JWT bearer middleware populates HttpContext.User — without it, context.User.Identity.IsAuthenticated is always false and the claims resolver always returns null. The subdomain and header resolvers have no such dependency and can be placed before or after authentication. Register ICurrentTenantService as scoped — never singleton. A singleton tenant service captures the tenant identity of the first request and uses it for all subsequent requests, which is a data breach waiting for a production incident to surface it.

Row-Level Isolation: HasQueryFilter, ITenantEntity & SaveChanges Interception

EF Core's global query filter is the mechanism that makes shared-database multitenancy safe at the framework level rather than at the developer discipline level. Register a filter once in OnModelCreating and it applies to every LINQ query against that entity type — includes, joins, and navigation property loads included — without any modification to query code. The filter reads the current tenant from ICurrentTenantService, which is resolved from the scoped DI container. The DbContext itself is scoped per request, so the tenant context is stable for the lifetime of each context instance.

Data/AppDbContext.cs — HasQueryFilter, ITenantEntity & SaveChanges
// ── ITenantEntity: marker interface for tenant-scoped entities ────────────
// Apply to every entity that belongs to a tenant.
// Entities that don't implement this — lookup tables, system configuration —
// are NOT filtered and are visible to all tenants. This is intentional.
public interface ITenantEntity
{
    string TenantId { get; set; }
}

// ── Example entities implementing the marker ──────────────────────────────
public sealed class Order : ITenantEntity
{
    public Guid   Id         { get; set; }
    public string TenantId   { get; set; } = "";   // populated by SaveChanges interceptor
    public string Status     { get; set; } = "";
    public decimal Total     { get; set; }
    public DateTime CreatedAt { get; set; }

    public ICollection Lines { get; set; } = [];
}

public sealed class OrderLine : ITenantEntity
{
    public Guid    Id        { get; set; }
    public string  TenantId  { get; set; } = "";
    public Guid    OrderId   { get; set; }
    public Guid    ProductId { get; set; }
    public int     Quantity  { get; set; }
    public decimal UnitPrice { get; set; }

    public Order Order { get; set; } = null!;   // navigation — also filtered
}

// ── Lookup table: no tenant isolation needed ──────────────────────────────
public sealed class ProductCatalogue     // does NOT implement ITenantEntity
{
    public Guid   Id   { get; set; }
    public string Name { get; set; } = "";
    public string Sku  { get; set; } = "";
}

// ── AppDbContext: global query filter wired to ICurrentTenantService ──────
public sealed class AppDbContext(
    DbContextOptions options,
    ICurrentTenantService          tenantService)
    : DbContext(options)
{
    public DbSet           Orders           => Set();
    public DbSet       OrderLines       => Set();
    public DbSet Products        => Set();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // ── Apply tenant filter to ALL ITenantEntity implementors ──────────
        // Reflection-based scan: finds every entity type in the model that
        // implements ITenantEntity and applies the TenantId filter to each.
        // Add a new entity implementing ITenantEntity → filter is automatic.
        // No manual per-entity filter registration required.
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
                continue;

            // Build: e => EF.Property(e, "TenantId") == tenantService.TenantId
            var parameter  = Expression.Parameter(entityType.ClrType, "e");
            var tenantId   = Expression.Property(parameter, nameof(ITenantEntity.TenantId));
            var currentId  = Expression.Constant(tenantService.TenantId);
            var body       = Expression.Equal(tenantId, currentId);
            var lambda     = Expression.Lambda(body, parameter);

            modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
        }

        // ── Composite index: TenantId + primary key on every filtered table ─
        // Without this index, every filtered query is a full table scan.
        // Add TenantId as the leading column in the clustered index for
        // SQL Server, or as a composite index prefix for PostgreSQL.
        modelBuilder.Entity()
            .HasIndex(o => new { o.TenantId, o.Id });

        modelBuilder.Entity()
            .HasIndex(ol => new { ol.TenantId, ol.OrderId });
    }

    // ── SaveChanges: auto-populate TenantId on insert ─────────────────────
    // Without this, developers must set TenantId manually on every new entity.
    // One forgotten assignment is a cross-tenant data write.
    // This intercepts at the EF Core change tracker level — no entity escapes it.
    public override Task SaveChangesAsync(CancellationToken ct = default)
    {
        var tenantId = tenantService.TenantId
            ?? throw new InvalidOperationException(
                "Cannot save changes without a resolved tenant context. " +
                "Ensure TenantMiddleware has run before this DbContext is used.");

        foreach (var entry in ChangeTracker.Entries()
            .Where(e => e.State == EntityState.Added))
        {
            if (string.IsNullOrEmpty(entry.Entity.TenantId))
            {
                entry.Entity.TenantId = tenantId;
            }
            else if (entry.Entity.TenantId != tenantId)
            {
                // A developer explicitly set a different TenantId — reject it.
                // This prevents accidental cross-tenant writes from application code.
                throw new InvalidOperationException(
                    $"Entity TenantId '{entry.Entity.TenantId}' does not match " +
                    $"current tenant '{tenantId}'. Cross-tenant writes are not permitted.");
            }
        }

        return base.SaveChangesAsync(ct);
    }
}

The reflection-based filter registration loop in OnModelCreating is the most important scaling decision in the shared-database model. Without it, every new entity that implements ITenantEntity requires a manual HasQueryFilter call — and every forgotten call is a potential data leak. With it, the filter is applied automatically to any entity that implements the marker interface, making correct isolation the path of least resistance. The same pattern applies to soft delete, audit fields, and any other cross-cutting concern you want applied uniformly across the model.

Database-Per-Tenant: Dynamic DbContext Connection String Switching

In the database-per-tenant model, there are no global query filters and no TenantId columns. The isolation boundary is the database itself. The challenge is routing each request to the correct database without creating a new DbContext registration per tenant — which is impossible at design time when tenants are created dynamically. The solution is IDbContextFactory<T> combined with a DbContext that reads its connection string from the scoped tenant context rather than from the DI-configured options.

Data/TenantDbContext.cs + Program.cs — Per-Tenant Connection String Routing
// ── TenantDbContext: resolves its own connection string from tenant context ─
// Does NOT use the connection string configured in AddDbContext().
// Overrides OnConfiguring to build the connection string from ICurrentTenantService
// at the point of DbContext construction — which is per-request in scoped DI.
public sealed class TenantDbContext(
    DbContextOptions options,
    ICurrentTenantService             tenantService,
    ILogger          logger)
    : DbContext(options)
{
    public DbSet     Orders     => Set();
    public DbSet OrderLines => Set();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            // This path is only hit when TenantDbContext is constructed directly,
            // not via DI. Provide a safe default for design-time tools (EF migrations).
            optionsBuilder.UseSqlServer("Server=localhost;Database=design-time;");
            return;
        }

        if (!tenantService.IsResolved)
        {
            throw new InvalidOperationException(
                "TenantDbContext requires a resolved tenant context. " +
                "Ensure TenantMiddleware has populated ICurrentTenantService " +
                "before this DbContext is instantiated.");
        }

        var connectionString = tenantService.TenantInfo?.ConnectionString
            ?? throw new InvalidOperationException(
                $"Tenant '{tenantService.TenantId}' does not have a dedicated " +
                "connection string configured. This tenant may be on the shared " +
                "database plan and should use AppDbContext instead.");

        logger.LogDebug(
            "TenantDbContext routing to tenant database for {TenantId}",
            tenantService.TenantId);

        // Override the connection string — replaces whatever AddDbContext configured.
        // The database, authentication, and server all come from the tenant registry.
        optionsBuilder.UseSqlServer(connectionString, sqlOptions =>
        {
            sqlOptions.EnableRetryOnFailure(
                maxRetryCount: 3,
                maxRetryDelay: TimeSpan.FromSeconds(5),
                errorNumbersToAdd: null);
            sqlOptions.CommandTimeout(30);
        });
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        // No global query filters in database-per-tenant model.
        // The database IS the isolation boundary.
        // Every record in this database belongs to the resolved tenant.
    }
}

// ── Program.cs: registration for database-per-tenant model ───────────────
var builder = WebApplication.CreateBuilder(args);

// Register TenantDbContext with a placeholder connection string.
// OnConfiguring overrides it with the per-tenant string at runtime.
// The placeholder connection string is used only by EF Core design-time tools.
builder.Services.AddDbContext((serviceProvider, options) =>
{
    // Provide the baseline options — OnConfiguring will override the connection.
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("TenantDbPlaceholder")
        ?? "Server=localhost;Database=placeholder;",
        sql => sql.MigrationsHistoryTable("__EFMigrationsHistory"));
});

// IDbContextFactory: for background services and jobs that need a DbContext
// outside of an HTTP request scope. The factory creates a new scoped context
// per operation, with the tenant resolved from the injected ICurrentTenantService.
builder.Services.AddDbContextFactory(lifetime: ServiceLifetime.Scoped);

// ── Migration runner: apply migrations to all tenant databases ────────────
public sealed class TenantMigrationRunner(
    ITenantRegistry                 tenantRegistry,
    IDbContextFactory contextFactory,
    ILogger  logger)
{
    public async Task MigrateAllTenantsAsync(CancellationToken ct = default)
    {
        var enterpriseTenants = await tenantRegistry
            .GetTenantsByPlanAsync("enterprise", ct);

        foreach (var tenant in enterpriseTenants)
        {
            try
            {
                await using var context = await contextFactory.CreateDbContextAsync(ct);
                await context.Database.MigrateAsync(ct);

                logger.LogInformation(
                    "Migrations applied to tenant {TenantId} database.", tenant.TenantId);
            }
            catch (Exception ex)
            {
                logger.LogError(ex,
                    "Migration failed for tenant {TenantId}. " +
                    "Other tenants are unaffected.", tenant.TenantId);
                // Continue to next tenant — one failed migration does not block others.
            }
        }
    }
}

The migration runner's error handling pattern is deliberate. When applying migrations across hundreds of tenant databases, a single failure — a tenant database that is temporarily unreachable, or a schema that is in an unexpected state — must not block all other tenants. Log the failure with the tenant ID, continue to the next tenant, and surface the failures as a list at the end of the migration run rather than as a hard stop. A deployment that partially migrates is recoverable; a deployment that aborts on the first failure leaves an unpredictable number of tenants in an unknown state.

Admin Operations: Bypassing Tenant Filters Safely

Every global query filter needs an escape hatch. Support engineers need to query across tenants to investigate incidents. Billing services need cross-tenant aggregation. Data migration jobs need to read and write without a tenant context. IgnoreQueryFilters() is EF Core's built-in mechanism for bypassing all global filters on a specific query — but unrestricted access to it is as dangerous as not having filters at all. The correct pattern is an explicit admin context with clear access controls rather than scattered IgnoreQueryFilters() calls throughout the codebase.

Data/AdminDbContext.cs + Services/CrossTenantQueryService.cs
// ── AdminDbContext: a dedicated DbContext with NO tenant filter ────────────
// Separate from AppDbContext so the absence of tenant filtering is explicit
// and auditable. AppDbContext is for tenant-scoped operations.
// AdminDbContext is for cross-tenant administrative operations only.
// Register separately — never expose AdminDbContext to tenant-scoped controllers.
public sealed class AdminDbContext(DbContextOptions options)
    : DbContext(options)
{
    public DbSet      Orders     => Set();
    public DbSet  OrderLines => Set();
    public DbSet Tenants    => Set();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        // Intentionally NO HasQueryFilter calls.
        // Every query returns all records across all tenants.
        // Document this explicitly — it is not an oversight.
    }
}

// ── CrossTenantQueryService: the only authorised consumer of AdminDbContext ─
// Centralising cross-tenant queries in one service makes access auditable
// and searchable in code review — grep for CrossTenantQueryService to find
// every cross-tenant data access in the codebase.
public sealed class CrossTenantQueryService(
    AdminDbContext                   adminContext,
    ILogger logger)
{
    // ── Billing: aggregate revenue across all tenants ──────────────────────
    public async Task> GetRevenueByTenantAsync(
        DateOnly from, DateOnly to, CancellationToken ct = default)
    {
        logger.LogInformation(
            "Cross-tenant revenue query: {From} to {To}", from, to);

        return await adminContext.Orders
            .Where(o => o.CreatedAt >= from.ToDateTime(TimeOnly.MinValue)
                     && o.CreatedAt <= to.ToDateTime(TimeOnly.MaxValue))
            .GroupBy(o => o.TenantId)
            .Select(g => new TenantRevenueSummary(
                TenantId: g.Key,
                OrderCount: g.Count(),
                TotalRevenue: g.Sum(o => o.Total)))
            .ToListAsync(ct);
        // No IgnoreQueryFilters() needed — AdminDbContext has no filters to ignore.
    }

    // ── Support: find an order by ID regardless of tenant ─────────────────
    public async Task FindOrderForSupportAsync(
        Guid orderId, CancellationToken ct = default)
    {
        logger.LogInformation(
            "Cross-tenant order lookup for support: OrderId {OrderId}", orderId);

        return await adminContext.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == orderId, ct);
    }
}

public sealed record TenantRevenueSummary(
    string  TenantId,
    int     OrderCount,
    decimal TotalRevenue);

// ── When IgnoreQueryFilters() IS appropriate — one-off LINQ queries ────────
// If you do not want a dedicated AdminDbContext, use IgnoreQueryFilters()
// explicitly on the query and pair it with a structured log event.
// NEVER call IgnoreQueryFilters() without a comment explaining why.
//
// Example: a data migration that must process all records regardless of tenant
//
// var allOrders = await appDbContext.Orders
//     .IgnoreQueryFilters()              // intentional: migration runs cross-tenant
//     .Where(o => o.CreatedAt < cutoff)
//     .ToListAsync(ct);
//
// logger.LogInformation(
//     "Cross-tenant migration query returned {Count} orders", allOrders.Count);

// ── Program.cs: AdminDbContext registration ───────────────────────────────
// Register separately from AppDbContext.
// Do NOT register via AddDbContext — that registration should
// remain exclusively for tenant-scoped operations.
//
// builder.Services.AddDbContext(options =>
//     options.UseSqlServer(
//         builder.Configuration.GetConnectionString("AdminDatabase")));
//
// builder.Services.AddScoped();
//
// Do NOT add AdminDbContext to general controller DI registration.
// Only inject it into explicitly administrative services.

The architectural rule that makes the admin context pattern work is access control at the DI registration level, not at the developer discipline level. AdminDbContext should not appear in the constructor of any controller, minimal API handler, or repository that serves tenant-facing traffic. Inject it only into explicitly administrative services — billing aggregators, support tooling, migration runners. If your DI registration makes it impossible for tenant-facing code to resolve AdminDbContext, the entire class of cross-tenant data exposure from accidental IgnoreQueryFilters() usage in the wrong context is structurally prevented.

Testing Tenant Isolation: Verifying the Filter Actually Works

A global query filter that appears to work is not the same as a global query filter that provably works. The test suite for a multitenant data layer has one non-negotiable requirement: it must assert on what is absent from query results, not only on what is present. An empty result set and a correctly filtered result set look identical without an explicit assertion that Tenant B's records do not appear in Tenant A's query. Write the negative case first — assert that Tenant A cannot see Tenant B's data — and the positive case becomes a consistency check rather than the primary guard.

Tests/TenantIsolationTests.cs — Integration Tests for Filter Correctness
// ── Test fixture: SQLite in-memory database for fast integration tests ────
public sealed class TenantIsolationTests : IDisposable
{
    private readonly AppDbContext          _tenantAContext;
    private readonly AppDbContext          _tenantBContext;
    private readonly CurrentTenantService  _tenantAService;
    private readonly CurrentTenantService  _tenantBService;

    public TenantIsolationTests()
    {
        // Shared in-memory SQLite database — all tenants' data in one DB
        // This mirrors the shared-database production model exactly.
        var connection = new SqliteConnection("DataSource=:memory:");
        connection.Open();

        var options = new DbContextOptionsBuilder()
            .UseSqlite(connection)
            .Options;

        // Create two tenant service instances — each represents a request context
        _tenantAService = new CurrentTenantService();
        _tenantAService.SetTenant(new TenantInfo("tenant-a", "Acme Corp",
            "pro", null, "acme"));

        _tenantBService = new CurrentTenantService();
        _tenantBService.SetTenant(new TenantInfo("tenant-b", "Globex Corp",
            "pro", null, "globex"));

        // Two DbContext instances — same database, different tenant filters
        _tenantAContext = new AppDbContext(options, _tenantAService);
        _tenantBContext = new AppDbContext(options, _tenantBService);

        // Ensure schema exists
        _tenantAContext.Database.EnsureCreated();
    }

    // ── Test 1: Tenant A can only read its own orders ─────────────────────
    [Fact]
    public async Task TenantA_CannotSee_TenantB_Orders()
    {
        // Arrange: seed one order per tenant using their respective contexts
        var orderA = new Order { Status = "Pending", Total = 100m };
        var orderB = new Order { Status = "Shipped", Total = 200m };

        _tenantAContext.Orders.Add(orderA);  // TenantId = "tenant-a" (auto-set by SaveChanges)
        await _tenantAContext.SaveChangesAsync();

        _tenantBContext.Orders.Add(orderB);  // TenantId = "tenant-b"
        await _tenantBContext.SaveChangesAsync();

        // Act: query from Tenant A's context
        var tenantAOrders = await _tenantAContext.Orders.ToListAsync();

        // Assert: Tenant A sees exactly its own order
        tenantAOrders.Should().HaveCount(1);
        tenantAOrders.Single().Total.Should().Be(100m);

        // ── THE CRITICAL NEGATIVE ASSERTION ───────────────────────────────
        // This is the test that matters. Tenant A's result set must not
        // contain Tenant B's order ID — not just "has one record" but
        // explicitly "does not contain the other tenant's record".
        tenantAOrders.Should().NotContain(o => o.Id == orderB.Id,
            because: "Tenant A's query filter must exclude Tenant B's orders");
    }

    // ── Test 2: SaveChanges rejects cross-tenant TenantId assignment ───────
    [Fact]
    public async Task SaveChanges_Throws_WhenTenantIdMismatch()
    {
        // Arrange: create an entity with an explicitly wrong TenantId
        var tampered = new Order
        {
            TenantId = "tenant-b",   // wrong: Tenant A context is active
            Status   = "Malicious",
            Total    = 0m
        };

        _tenantAContext.Orders.Add(tampered);

        // Act & Assert: SaveChanges must reject the cross-tenant write
        var act = async () => await _tenantAContext.SaveChangesAsync();
        await act.Should().ThrowAsync()
            .WithMessage("*Cross-tenant writes are not permitted*");
    }

    // ── Test 3: Includes also respect the tenant filter ───────────────────
    [Fact]
    public async Task Include_OrderLines_OnlyReturnsTenantOwnedLines()
    {
        // Arrange: seed an order with lines for each tenant
        var orderA = new Order { Status = "Pending", Total = 150m };
        orderA.Lines.Add(new OrderLine { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 150m });
        _tenantAContext.Orders.Add(orderA);
        await _tenantAContext.SaveChangesAsync();

        var orderB = new Order { Status = "Pending", Total = 75m };
        orderB.Lines.Add(new OrderLine { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 75m });
        _tenantBContext.Orders.Add(orderB);
        await _tenantBContext.SaveChangesAsync();

        // Act: query with Include from Tenant A's context
        var orders = await _tenantAContext.Orders
            .Include(o => o.Lines)
            .ToListAsync();

        // Assert: navigation properties also filtered — no Tenant B lines visible
        orders.Should().HaveCount(1);
        orders.Single().Lines.Should().HaveCount(1);
        orders.Single().Lines.Single().UnitPrice.Should().Be(150m);
    }

    // ── Test 4: IgnoreQueryFilters returns all tenants' data ──────────────
    [Fact]
    public async Task IgnoreQueryFilters_Returns_AllTenants_ForAdminContext()
    {
        // Arrange: seed one order per tenant
        _tenantAContext.Orders.Add(new Order { Status = "A", Total = 10m });
        await _tenantAContext.SaveChangesAsync();

        _tenantBContext.Orders.Add(new Order { Status = "B", Total = 20m });
        await _tenantBContext.SaveChangesAsync();

        // Act: bypass filter — simulates what AdminDbContext does
        var allOrders = await _tenantAContext.Orders
            .IgnoreQueryFilters()
            .ToListAsync();

        // Assert: all records visible when filter is bypassed
        allOrders.Should().HaveCount(2,
            because: "IgnoreQueryFilters should bypass the tenant isolation filter");
    }

    public void Dispose()
    {
        _tenantAContext.Dispose();
        _tenantBContext.Dispose();
    }
}

Test 2 — the SaveChanges cross-tenant write rejection — is the test most teams skip because it tests error handling rather than happy-path behaviour. It is the second most important test in the suite after the negative isolation assertion in Test 1. If SaveChanges silently accepts a mismatched TenantId — whether from a developer bug, a deserialization issue, or a request forgery — data from one tenant ends up in another tenant's partition. The test makes the enforcement mechanism visible and verifiable. If the test passes, the protection is real. If the test is absent, the protection is assumed.

What Developers Want to Know

Which multitenancy model should I choose — shared database or database-per-tenant?

Choose shared database with row-level filters when you have many small tenants, cost efficiency is the primary constraint, and tenants have no data residency or compliance requirements mandating physical separation. Choose database-per-tenant when tenants have contractual isolation requirements (GDPR jurisdiction, HIPAA BAA, SOC 2 scope boundaries), datasets vary dramatically in size requiring per-tenant query tuning, or tenants need independent backup and restore capabilities. The two models are not mutually exclusive — many platforms use shared databases for free-tier tenants and dedicated databases for enterprise plans.

Can a tenant ever see another tenant's data with HasQueryFilter row-level isolation?

Yes — in two specific scenarios you must guard against. First, IgnoreQueryFilters() bypasses all global query filters including the tenant filter. Any code path that calls it without an explicit admin-only guard can expose cross-tenant data. Second, raw SQL queries executed via ExecuteSqlRaw or FromSqlRaw bypass EF Core's query pipeline entirely and have no tenant filter applied — always append WHERE TenantId = @tenantId manually to any raw SQL in a multitenant context. The global query filter is a safety net for LINQ queries, not a database-level row security policy.

How do I handle tenant resolution in background jobs with no HTTP request context?

Design ICurrentTenantService to return a nullable TenantId and handle the null case explicitly in your DbContext. For background jobs that process data for a specific tenant, inject ICurrentTenantService into the job and call SetTenant() explicitly before any database operation. For cross-tenant background jobs — billing aggregation, maintenance — use IgnoreQueryFilters() or AdminDbContext explicitly and document why. Never allow a null TenantId to silently produce an empty result set — that is indistinguishable from a legitimate empty tenant and masks bugs in jobs that should be processing data but are not.

What is the scoped-in-singleton trap in multitenant DI registration?

The trap occurs when a singleton service captures a scoped dependency at construction time rather than resolving it per operation. If a singleton captures ICurrentTenantService — which is scoped to the HTTP request — at startup, it captures the tenant context of the first request that creates it and uses that tenant ID for every subsequent request, regardless of which tenant the later requests belong to. Fix this by injecting IServiceScopeFactory into the singleton and creating a new scope per operation, or by accessing the current tenant via IHttpContextAccessor rather than injecting ICurrentTenantService directly into singletons.

How do EF Core migrations work in a database-per-tenant model?

Migrations are generated once against the shared schema and applied to each tenant database independently. Build a migration runner service that iterates all tenant connection strings from your tenant registry and calls dbContext.Database.MigrateAsync() for each. Run at application startup or as a dedicated job in your deployment pipeline. The __EFMigrationsHistory table is per-database, so each tenant database tracks its own applied migrations independently — a partially-migrated tenant does not block others. For new tenant onboarding, apply all pending migrations immediately after creating the database rather than lazily at first access.

How do I test that the tenant filter is actually preventing cross-tenant data access?

Write explicit integration tests that seed data for two or more tenants, set the tenant context to Tenant A, and assert queries return only Tenant A's records. Then assert explicitly that Tenant B's specific entity IDs are absent from the result — not just that the count is correct. Use SQLite in-memory for fast test execution. Test the negative case first: assert that a specific entity ID belonging to Tenant B returns null when queried from Tenant A's context. Also test that SaveChanges throws when an entity's TenantId does not match the current tenant context. The absence of an error is not sufficient — assert on what is absent from result sets, not only on what is present.

Back to Articles