Chapter 22 · Global Query Filters, Multi-Tenancy, Soft Delete, and Data Partitioning

Model Caching, Tenant-Specific State, DbContext Pooling, and Avoiding Cross-Tenant Leakage

Keep tenant identity in context-instance query parameters rather than cached model shape, then safely combine tenant-scoped state with DbContext pooling and diagnose the cross-tenant leak caused by stale pooled state.

Advanced180–240 minutespool-leak labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · SQL Server RLS optional in Lesson 5Tenancy/security behavior reviewed: August 27, 2026

Learning outcomes

01

Explain the default EF model cache and why tenant IDs normally must not alter the cache key.

02

Use IModelCacheKeyFactory only when model shape genuinely changes and account for design-time keys.

03

Safely inject tenant state into pooled DbContext instances on every checkout.

04

Reproduce a stale pooled-tenant leak and repair it with a scoped factory and fail-closed tenant resolution.

05

Separate DbContext pooling from driver connection pooling and from the model cache.

06

Define telemetry/tests that detect cross-tenant state reuse before production.

1. Three caches/pools, three different failure modes

EF applications often mention “the pool” as though there were one shared cache. Chapter 17 already separated DbContext pooling from driver connection pooling. Multi-tenancy adds a third concept: the EF model cache. Confusing these layers is a direct route to cross-tenant leaks.

Layer What is reused Tenant risk
EF model cache Metadata/model for a context type Wrong model only if tenant changes model shape
DbContext pool Context object + custom instance fields unless application overwrites them Stale TenantId can leak between requests
ADO.NET connection pool Physical DB connections keyed by connection configuration Session state/connection-string fan-out must be managed

2. Tenant ID is data, not model shape

The normal shared-database ServiceHub model has the same tables, columns, keys, and filters for every tenant; only the value of TenantId changes. Therefore the tenant should be read from context instance state and parameterized in the global filter. Adding tenant ID to IModelCacheKeyFactory would create a distinct model per tenant with no semantic benefit.

C# · correct shared model shape
public string TenantId { get; private set; } = null!;protected override void OnModelCreating(ModelBuilder modelBuilder){    modelBuilder.Entity<WorkOrder>()        .HasQueryFilter("TenantFilter", w => w.TenantId == TenantId);}

3. When IModelCacheKeyFactory is actually needed

If the model truly changes—different table/schema mapping, ignored properties, provider-specific model shape—EF must know that the cached model key differs. A custom cache key must include every model-shaping variable and the designTime flag. This service is singleton and must be thread-safe.

C# · illustrative dynamic-model key, not needed for TenantId
public sealed class ServiceHubModelCacheKeyFactory : IModelCacheKeyFactory{    public object Create(DbContext context, bool designTime)    {        var db = (ServiceHubContext)context;        return (context.GetType(), db.ModelVariant, designTime);    }}

Using TenantId as ModelVariant would be a design smell in the supported shared-database approach; it creates many models and often signals an unsupported schema-per-tenant design.

4. Pooled contexts behave like reused singletons for custom state

Context pooling resets EF’s own internal state when a context is returned to the pool, but your custom tenant field is application state. For the pooled variant, ServiceHub deliberately switches from Lesson 1’s constructor-injected tenant to an options-only context constructor plus tenant assignment in the scoped wrapper factory. The safe pattern is to overwrite tenant state on every checkout, require a tenant, and never expose a pooled context before assignment.

C# · pooled context with explicit tenant assignment
public sealed class ServiceHubContext : DbContext{    public ServiceHubContext(DbContextOptions<ServiceHubContext> options)        : base(options) { }    public string TenantId { get; private set; } = null!;    public void AssignTenant(string tenantId)    {        if (string.IsNullOrWhiteSpace(tenantId))            throw new InvalidOperationException("Tenant is required.");        TenantId = tenantId;    }}
C# · scoped factory over singleton pooled factory
public sealed class TenantScopedContextFactory(    IDbContextFactory<ServiceHubContext> pooledFactory,    ITenantContext tenant){    public ServiceHubContext CreateDbContext()    {        var db = pooledFactory.CreateDbContext();        db.AssignTenant(tenant.RequiredTenantId); // overwrite every checkout        return db;    }}

5. Deliberate failure: stale tenant survives pool reuse

C# · WRONG: assignment is conditional
public ServiceHubContext CreateDbContext(){    var db = pooledFactory.CreateDbContext();    if (tenant.TryGetTenantId(out var id))        db.AssignTenant(id);    // If resolution fails, a pooled instance can still carry the previous tenant.    return db;}

Reproduce it deterministically: request A obtains a pooled context, assigns tenant-a, disposes it; request B has no tenant, obtains the same instance, and runs a query. If the custom field was not overwritten/fail-closed, B can query tenant A. The repair is simple in principle: no tenant means no context. Assignment must be unconditional before handoff.

Fail closed

A missing/invalid tenant must terminate the operation. “Keep the previous value” is catastrophic in a pool.

6. Connection/session state is another leakage channel

Even with a correct TenantId field, provider-level session state can persist on pooled database connections. For example, SQL Server row-level-security designs often use SESSION_CONTEXT. If the application sets per-tenant session state, it must understand connection-pool lifetime and reset/overwrite semantics. Never assume disposing a DbContext destroys the underlying physical connection.

The free SQLite lab does not use server session state, which is itself an important topology distinction.

7. Observability: make tenant assignment visible without leaking identities

C# · low-cardinality safe diagnostics
logger.LogDebug(    "ServiceHub context checked out with tenant_scope={TenantScopePresent}",    !string.IsNullOrEmpty(db.TenantId));// Avoid tenant IDs as metric labels in high-cardinality telemetry.// Correlate tenant identity in protected audit/security logs instead.

Production metrics should count missing-tenant failures, context-pool checkouts, and authorization denials without using arbitrary tenant IDs as time-series labels. Security audit logs may record tenant IDs under stricter retention/access controls.

8. Mandatory pool-leak lab

  1. Register AddPooledDbContextFactory<ServiceHubContext> with a small pool for the disposable SQLite database.
  2. Implement the intentionally broken conditional tenant factory.
  3. Run tenant A, dispose, then simulate a tenant-less request until the same pooled context instance is observed; prove the stale scope in a controlled test.
  4. Replace the factory with the fail-closed unconditional assignment version.
  5. Run 1,000 alternating A/B operations sequentially and assert every result’s tenant_id equals the authorized tenant.
  6. Repeat without DbContext pooling to prove the bug is custom pooled state, not the global filter itself.

9. Production judgment and bridge

Use pooling only after the application has a disciplined state-reset/assignment story and a benchmark justifies it. Tenant ID should remain instance query state for the supported shared-model topology. A custom model cache key is for genuine model differences, not per-request identity. Lesson 5 finishes the chapter by assuming every application layer can fail and adding database constraints, authorization, tests, audit, and optional database row-level security.

Check your understanding

  1. What does EF’s default model cache assume?
  2. Should TenantId normally be in the model-cache key?
  3. Why is DbContext pooling dangerous for custom tenant state?
  4. What is the safe pooled-factory rule?
  5. Is DbContext pooling the same as connection pooling?
  6. When is IModelCacheKeyFactory justified?
Review the answers

1. For a given DbContext type the model shape is the same, so the model can be cached and reused.

2. No. In discriminator tenancy it is a query parameter/context value, not a model-shaping variable.

3. The context object is reused; application fields can retain stale values unless overwritten/fail-closed on every checkout.

4. Resolve an authorized tenant and assign it unconditionally before returning the context; missing tenant means do not return a context.

5. No. EF reuses context objects; the ADO.NET provider manages physical database connections independently.

6. When the EF model shape truly changes based on a context variable, and the key includes that variable plus design-time state.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n