Subtle breaking change when adding DbContextPool Entity Framework Core 6

· 568 words · 3 minutes to read

During the upgrade process of one of our applications from .NET Core 3.1 to .NET 6.0, I stumbled across a very subtle breaking changing when using the AddDbContextPool() feature of EF Core. I thought it might be worthwhile to document this, in case someone else is troubled by it too.

Use case 🔗

Consider the following code, making use of AddDbContextPool in EF Core.

void RegisterTenant<TDbContext>(IConfigurationSection cfg) where TDbContext : AppDbContext
{
     if (!cfg.Exists()) return;
     services.AddDbContextPool<AppDbContext, TDbContext>(builder =>
     {
        var dbContextCfg = new AppDbContextSettings();
        cfg.Bind(dbContextCfg);
        builder.UseSqlServer(dbContextCfg.ConnectionString, opts =>
        {
             opts.EnableRetryOnFailure();
        });
     });
}

This code registers a DbContext for different tenants in the DI container, where each tenant supplies its own version of the context (the TContextService on the registration method), as a subclass of AppDbContext, which, in semi-pseudo-code looks as follows:

abstract class AppDbContext : DbContext
{
   // omitted for brevity
}

class FooDbContext : AppDbContext
{
    // omitted for brevity, specific for tenant Foo
}

class BarDbContext : AppDbContext
{
    // omitted for brevity, specific for tenant Bar
}

The multi-tenancy here is merely an example, and the point it illustrates is that the problem arises when one has to deal with multiple isolated variants of the same base DbContexts, but pointing at different SQL DBs. A similar situation arises e.g. when you might have different DB instance per geographical location, and you performing data residency segregation manually in the code.

In the specific above case, the tenants are then registered in a strongly typed fashion as:

RegisterTenant<FooDbContext>(config);
RegisterTenant<BarDbContext>(config);

At this point you could inject a collection of all the registered AppDbContext, so one per tenant, and use it however you wish.

public class TenantDbFactory
{
    public TenantFactory(IEnumerable<AppDbContext> implementations)
    {
        // omitted for brevity
    }
    
    public AppDbContext ResolveForTenant(string tenant)
    {
        // pick the correct AppDbContext for the tenant
    }
}

In EF Core 3.1, when calling AddDbContextPool<AppDbContext, TDbContext>() multiple times, each of the implementations, so each TDbContext, would be registered in the DI container both as itself and against the AppDbContext. This of course makes it possible to inject the collection of base class and receive every implementation, in this case, a different one for each of the tenants.

Breaking change 🔗

When upgrading to .NET 6.0, the TenantDbFactory no longer receives all of the implementations, but only the first one, in our example FooDbContext! Now, depending on the scenario, this subtle breaking change may have disastrous consequences, as it does not manifest itself at compile time.

The closer inspection of the EF Core codebase, revealed that this behavior change was introduced, in what appears to be, an unrelated PR. The code was updated from AddScoped(…) to TryAddScoped(…) when registering the TContextService (in our case, the AppDbContext). Since the Try… variant only allows a single registration of a given sort, as a result, only the first tenant context ended up in the DI container, registered against AppDbContext (it was still registered as itself, but, remember that it was not our usage pattern).

Mitigation 🔗

The quickest mitigation is to change from using AddDbContextPool<TContextService,TContextImplementation>() to its sibling variant AddDbContextPool() and then perform the registration against the common base context (in our example, the AppDbContext) manually. This is shown in the updated RegisterTenant() method below:

void RegisterTenant<TDbContext>(IConfigurationSection cfg) where TDbContext : AppDbContext
{
     if (!cfg.Exists()) return;
     services.AddDbContextPool<TDbContext>(builder =>
     {
        var dbContextCfg = new AppDbContextSettings();
        cfg.Bind(dbContextCfg);
        builder.UseSqlServer(dbContextCfg.ConnectionString, opts =>
        {
             opts.EnableRetryOnFailure();
        });
     });
     
     services.AddScoped<AppDbContext>(sp => sp.GetRequiredService<IScopedDbContextLease<TDbContext>>().Context);
}

This restores the original behavior.

About


Hi! I'm Filip W., a software architect from Zürich 🇨🇭. I like Toronto Maple Leafs 🇨🇦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁢󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP