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.
Learning outcomes
Explain the default EF model cache and why tenant IDs normally must not alter the cache key.
Use IModelCacheKeyFactory only when model shape genuinely changes and account for design-time keys.
Safely inject tenant state into pooled DbContext instances on every checkout.
Reproduce a stale pooled-tenant leak and repair it with a scoped factory and fail-closed tenant resolution.
Separate DbContext pooling from driver connection pooling and from the model cache.
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.
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.
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.
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; }}
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
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.
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
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
-
Register
AddPooledDbContextFactory<ServiceHubContext>with a small pool for the disposable SQLite database. - Implement the intentionally broken conditional tenant factory.
- 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.
- Replace the factory with the fail-closed unconditional assignment version.
-
Run 1,000 alternating A/B operations sequentially and assert
every result’s
tenant_idequals the authorized tenant. - 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
- What does EF’s default model cache assume?
- Should TenantId normally be in the model-cache key?
- Why is DbContext pooling dangerous for custom tenant state?
- What is the safe pooled-factory rule?
- Is DbContext pooling the same as connection pooling?
- 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
- Advanced performance topics — context pooling, custom tenant state, and distinction from connection pooling
- Dynamic models and IModelCacheKeyFactory — when model-shape variables require cache-key customization
- IModelCacheKeyFactory API — service lifetime and model cache contract
- Global query filters — context tenant values used inside model-level filters
- EF Core multi-tenancy — tenant-aware context/factory lifetime guidance
- DbContext configuration — short-lived context/thread-safety lifecycle