Introduction
In Entity Framework Core, all the database operations on the code side are performed on the DbContext object which represents one single state of the database. It is a lightweight object and disposing it would not cause any noticeable performance issues.
However, DbContext object does have its own service providers and underlying setup operations that take place whenever a new DbContext object is created. It may have some considerable performance implications when we continuously create and dispose the DbContext objects, which can be the case while building high-transaction applications.
In such cases, we can use DbContextPooling in place of using a single DbContext object.
What is DbContext Pooling?
DbContextPooling is an advanced performance optimization approach. Context Pooling sets up a pool of DbContext objects when the application starts. Whenever an instance of DbContext is requested from the Service Provider, an instance is returned from this pool. When the instance is called to be disposed, the instance is reset and is returned to the pool.
In this way, the application manages and reuses a set of DbContext objects, which eliminates the need for all that underlying setup operations; since not a single DbContext instance is destroyed.
How to configure DbContext Pooling in ASP.NET Core?
In .NET Core, you can easily setup DbContextPooling. You just need to configure the DbContextPooling service instead of registering a DbContext. It is done as below –
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// normal - register your DbContext Servicea
builder.Services.AddSqlServer<ReadersContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
// alternative - register your DbContext as a Pooled Context
builder.Services.AddDbContextPool<ReadersContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});
Configuring Pool Size
You can also specify the pool size using an overload of the AddDbContextPool() method. Pool size refers to the maximum number of instances that can be retained and managed by the Context Pool. The default value is 1024.
The Context Pool works like this – since the pool size defaults to 1024, it can manage and reuse 1024 instances without creating a new instance. If in any scenario, all the 1024 instances are being used by the requests and another request for an instance comes-in, the ContextPool creates a new instance and returns to the caller – which is similar to a normal DbContext service behavior.
Once DbContextPooling is registered, you can normally inject a DbContext instance where ever needed and the application works as expected. The only difference happens on the behind, which is that the instance returned is from a pool.
DbContextPool is a Singleton Service
DbContextPooling also makes another noticeable difference in the application apart from performance – DbContextPooling service is registered as a Singleton service. This is since it reuses the same instance throughout the application lifecycle and never destroys, the DbContext now becomes a singleton.
This means that you cannot directly inject and use Scoped or Transient services inside a DbContext like before, and the Scoped services injected are retained all over.
For example, the below code doesn’t work! (here MyRequestTenant is a scoped service).
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace ContextPooledReaders.WebAPI;
public class ReadersContext : DbContext
{
private const string _tenantIdKey = "TenantID";
public ReadersContext(DbContextOptions options) : base(options)
{
}
public DbSet<WeatherForecastSnapshot> WeatherForecastSnapshots { get; set; }
public string TenantID
{
get
{
var myRequestTenant = this.GetService<MyRequestTenant>();
return myRequestTenant.TenantID;
}
}
}
How to use Scoped Services with DbContextPooling?
Let’s assume you wish to use a scoped property – such as a TenantID inside the DbContext, which changes with each request. Injecting the TenantID directly into a DbContext now doesn’t work. To achieve this, you create an implementation of the IDbContextFactory interface.
DbContextPooling has a default implementation of the IDbContextFactory, which is how it returns DbContext instances whenever required. We add our own implementation of the IDbContextFactory and here we add our TenantID.
The code looks like below –
public class MyRequestTenant
{
private readonly IHttpContextAccessor _httpContextAccessor;
private const string _tenantIdKey = "TenantID";
public MyRequestTenant(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
// Returns a new TenantID
// Instance to be made a Scoped Service
public string TenantID
{
get
{
var context = _httpContextAccessor.HttpContext;
if(context.Request.Query.ContainsKey(_tenantIdKey))
{
return context.Request.Query[_tenantIdKey];
}
return Guid.NewGuid().ToString();
}
}
}
We have now created our own implementation of the IDbContextFactory, which internally extends the default IDbContextFactory implementation and adds the TenantID to the DbContext thus created.
public class MyDbContextFactory : IDbContextFactory<ReadersContext>
{
private readonly IDbContextFactory<ReadersContext> _pooledFactory;
private readonly MyRequestTenant _tenant;
public MyDbContextFactory(
IDbContextFactory<ReadersContext> pooledFactory,
MyRequestTenant tenant)
{
_pooledFactory = pooledFactory;
_tenant = tenant;
}
public ReadersContext CreateDbContext()
{
var context = _pooledFactory.CreateDbContext();
context.TenantID = _tenant.TenantID;
return context;
}
}
We will now register these services in the pipeline as below –
var builder = WebApplication.CreateBuilder(args);
// register a pooled db context factory
// this calls for default implementation of the IDbContextFactory
// the service is Singleton
builder.Services.AddPooledDbContextFactory<ReadersContext>(
options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// register the TenantID as a scoped service
builder.Services.AddScoped<MyRequestTenant>();
// register our own implementation of the ContextFactory
// it pulls the default PooledFactory and returns a new DbContext
// with TenantID set
builder.Services.AddScoped<MyDbContextFactory>();
// add DbContext thus returned from the Custom Implementation
// as a Scoped service
// you now have a DbContext which is a scoped service
builder.Services.AddScoped(serviceProvider =>
{
var pooledFactory = serviceProvider.GetRequiredService<MyDbContextFactory>();
return pooledFactory.CreateDbContext();
});
The end result is that we have a DbContext instance returned to us, which is scoped in nature but is returned from the Context Pool!
Conclusion
Although DbContext instance is lightweight and offers good performance, it does have its own internal bootstrapping to be done whenever a new instance is created. In scenarios where we have frequent creation and disposal of DbContext instances, we can introduce a Pooled Context where the instances of DbContext are pooled and reused. This ensures that all the bootstrapping is done before hand (when the application first starts) and the application is supplied with already created instances of DbContext class, and is managed by the pool. This is similar to an Object Pool design pattern.
The side effect of this approach is that the DbContext instances are now Singleton services and cannot work with Scoped services. To overcome this, we use an implementation of the IDbContextFactory, where we can inject and use Scoped services and can return DbContext instances managed from the pool.
The performance difference between DbContext and Pooled Context is negligible when used in normal scenarios – this is intended for high performance scenarios where the load is very high.
You can check out the offical MS Documentation where the exact test results are mentioned for more details.