Skip to content
Kumar Chandrachooda
.NET

The Model-Cache Trap: Why Only One Tenant Migrates

You built schema-aware migrations, provisioned tenant A, and everything worked. Tenant B provisions without a single error — and gets no tables. EF Core's model cache is the silent culprit, and IModelCacheKeyFactory is the three-field fix.

By Kumar Chandrachooda 24 Aug 2025 5 min read
Same key, wrong tenant

Everything from part 3 was in place. The schema-parameterised DbContext, the constructor-injected migrations, the per-schema history table, the replaced IMigrationsAssembly. I provisioned my first tenant and watched the tables appear in tenant_a1b2c3d4. Then I provisioned a second tenant and watched the migration run, log success, write its history row — and create nothing. No exception, no warning, no tables in tenant_9f8e7d6c.

Bugs that fail loudly are gifts. This one failed silently, per tenant, in the one code path you only exercise when a new customer signs up. It deserves its own part.

What actually happened

EF Core builds your model — every entity, table mapping, index, the default schema — by running OnModelCreating. Building a model is expensive, so EF does it once per context type and caches the result for the lifetime of the process. Every subsequent instance of SchemaAwareDbContext skips OnModelCreating entirely and reuses the cached model.

Now re-read that with multi-tenant eyes. My model is parameterised by schema:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasDefaultSchema(_schemaName);   // per-tenant!
    ConfigurationFactory.ApplyConfigurationsWithSchema(
        modelBuilder, typeof(SchemaAwareDbContext).Assembly, _schemaName);
}

The first context instance in the process was built for tenant_a1b2c3d4, so the cached model has tenant_a1b2c3d4 baked into it. When tenant B's context asked for a model, EF served the cached one. The migrator then compared “what does the model want” against “what does tenant B's history table say has been applied” — using tenant A's model. Depending on timing you get one of two failure modes, both quiet: operations that target the wrong schema, or a differ that concludes there is nothing to do.

The default cache key is essentially (context type, design-time flag). Nothing about it knows my context has a third identity dimension.

The three-field fix

EF exposes the cache key computation as a service, IModelCacheKeyFactory, and replacing it is the entire fix. The custom key adds the schema:

internal class InStoreCacheKey(DbContext context, bool designTime)
{
    private readonly Type _dbContextType = context.GetType();
    private readonly bool _designTime = designTime;
    private readonly string? _schema = (context as ISchemaAwareContext)?.Schema;

    protected bool Equals(InStoreCacheKey other) =>
        _dbContextType == other._dbContextType
        && _designTime == other._designTime
        && _schema == other._schema;

    public override int GetHashCode()
    {
        HashCode hash = new();
        hash.Add(_dbContextType);
        hash.Add(_designTime);
        hash.Add(_schema);
        return hash.ToHashCode();
    }
}

internal class InStoreCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context, bool designTime)
        => new InStoreCacheKey(context, designTime);
}

Note how ISchemaAwareContext earns its keep again: the factory does not reference SchemaAwareDbContext, it just asks “does this context have a schema identity?” — a plain context yields null and behaves exactly as stock EF. Wiring up is the second ReplaceService line, sitting right next to the first:

optionsBuilder
    .ReplaceService<IMigrationsAssembly, InStoreAssembly>()
    .ReplaceService<IModelCacheKeyFactory, InStoreCacheKeyFactory>();

The XML comment I left on InStoreCacheKey in the repo is the most useful documentation I have ever written under pressure, so I will quote it verbatim: "the ModelCacheKey is used by EF to track if a migration has or not been performed, therefore we need to add the schema in consideration, otherwise it will only perform migrations for only one schema." If you take one sentence from this whole series, take that one.

What the fix costs

Now EF builds and caches one model per schema. That is correct — the models genuinely differ — but it is not free:

  • Memory. Each cached model holds the full entity graph metadata. At the platform's group size of fifty schemas per database, the overhead is real but unremarkable. At five thousand tenants in one process, you would want a bounded, LRU-style cache — EF's SetModelCacheKeyFactory-adjacent memory cache options make that possible, but it is work.
  • First-request latency per tenant. The first time each tenant is touched after a process start, OnModelCreating runs for its schema. It is milliseconds, but it shows up as a per-tenant cold start in traces, and it confused me the first time I saw it.

Both costs are the honest price of the truth: this application does not have one model, it has one model shape with N instantiations.

Verifying the fix is refreshingly mechanical, and worth scripting before you trust it: provision two tenants in one process lifetime, then ask PostgreSQL rather than the application. Each schema should contain its own tables and its own __EFMigrationsHistory rows — SELECT * FROM "tenant_a1b2c3d4"."__EFMigrationsHistory" and the same for tenant B should both list every migration. Before the cache-key fix, the second schema's history table is the tell: either it does not exist, or it claims migrations that its schema visibly does not contain. The database never lies about this; the application logs cheerfully will.

How the schema reaches every table mapping

HasDefaultSchema covers entities that do not specify their own table schema — but some configuration genuinely needs the schema value itself (schema-suffixed constraint names, for instance). The platform routes the schema into its entity configurations through a small reflection-driven factory:

public class CrumbConfiguration : SchemaAwareEntityConfiguration<Crumb>
{
    public override void Configure(EntityTypeBuilder<Crumb> builder)
    {
        ConfigureTable(builder, "crumb");               // uses injected schema

        builder.HasOne(e => e.Parent)
            .WithMany(e => e.Children)
            .HasForeignKey(e => e.ParentId)
            .HasConstraintName($"FK_crumb_parent_{_schemaName}")
            .OnDelete(DeleteBehavior.Restrict);

        ConfigureLtreeIndex(builder.HasIndex(e => e.Path), "IX_crumb_path");
        ConfigureJsonbIndex(builder.HasIndex(e => e.CrumbData), "IX_crumb_crumb_data");
    }
}

ConfigurationFactory scans the assembly for IEntityTypeConfiguration<> implementations, detects the ones deriving from SchemaAwareEntityConfiguration<>, calls SetSchema(schemaName) on each, and applies them. It works. It is also the piece of the codebase I trust least, and the source contains a TODO comment written in genuine anger to prove it — reflection over generic base types is fragile under refactoring, and for a while the repo carried two configuration systems in parallel: this factory plus a hand-written ConfigureCrumbEntity method kept as a commented-out escape hatch. I left the dead code in deliberately while confidence built. Confidence has built; the dead code should go.

If I rebuilt this today I would skip the reflection and pass the schema explicitly: a constructor argument on each configuration class, applied in a boring, visible list in OnModelCreating. Five extra lines per entity, zero magic. Magic is a loan, and the interest rate on reflection is variable.

The suppressed warning, revisited

Part 3 mentioned that the context ignores PendingModelChangesWarning. The model cache is why the suppression sits in this file: with hand-written migrations and per-schema models, EF 9's “your model has changes no migration covers” check produces false positives structurally, not occasionally. Suppressing it was the pragmatic call — and it removes a guard rail. My compensating control is the end-to-end provisioning test (part 8's test-migration-system.ps1), which fails loudly if a new entity ships without a migration. A suppressed warning plus a covering test is acceptable; a suppressed warning alone is a time bomb.

The takeaway

Multi-tenant EF Core has two halves: the visible half (contexts, migrations, history tables) and the invisible half (the model cache). The visible half fails loudly when you get it wrong. The invisible half fails by succeeding against the wrong tenant. Replace both services or ship neither.

With migrations that fan out correctly, the next question is who calls them, and when: turning a signup click into a live database or schema in a few seconds — provisioning — is part 5.