EF Core in ASP.NET Core: AddDbContext, Scoped Lifetime, Factory and Pooling
We wire DbContext into ASP.NET Core with AddDbContext, watch the scoped lifetime through ContextId, solve the singleton and background service traps, and see with real output when AddDbContextFactory and AddDbContextPool help.
Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.
Guess First: Is It the Same Context?
In an ASP.NET Core application we inject BookstoreContext into two places: the endpoint itself and the BookService the endpoint uses. Then the endpoint prints the identity of both contexts:
app.MapGet("/books/low-stock", (BookService service, CancellationToken cancellationToken)
=> service.GetLowStockAsync(cancellationToken));
app.MapGet("/whoami", (BookstoreContext db, BookService service)
=> $"endpoint: {db.ContextId}, service: {service.ContextId}");
We send two requests to /whoami, one after the other. The question: are the two contexts in the same request the same instance? Does the second request use the first one's context, or does it get a new one? Before answering, let's build this setup.
From OnConfiguring to AddDbContext
Until now we created the context ourselves with new BookstoreContext() and buried the connection string inside OnConfiguring. In a web application both decisions move elsewhere. The DI container creates the context, and the context receives its settings from outside, through the constructor:
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
}
public class BookstoreContext : DbContext
{
public BookstoreContext(DbContextOptions<BookstoreContext> options)
: base(options)
{
}
public DbSet<Book> Books => Set<Book>();
}
DbContextOptions<BookstoreContext> carries the provider, the connection string and every other setting. The OnConfiguring override is gone. The connection string moves to appsettings.json:
{
"ConnectionStrings": {
"Bookstore": "Data Source=bookstore.db"
}
}
We register it in Program.cs:
builder.Services.AddDbContext<BookstoreContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services.AddScoped<BookService>();
AddDbContext registers the context and its options in the container as scoped. Microsoft.EntityFrameworkCore.Sqlite is the only package needed; there is no separate package for the ASP.NET Core integration. To prepare the database at startup, we open a scope and ask it for the context:
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated(); // for the sample; use migrations in a real project
db.Books.AddRange(
new Book { Title = "Dune", Price = 12.5m, Stock = 5 },
new Book { Title = "Dune Messiah", Price = 9.9m, Stock = 2 });
db.SaveChanges();
}
EnsureCreated is here to keep the sample short. In a real project you use migrations; we discussed the risks of migrating at application startup in the migrations post.
Scoped Lifetime: Every Request Gets Its Own Context
Back to the opening question. We ran the application and sent two requests to /whoami:
endpoint: 4e02b9cf-46da-413f-94c0-9ff13123a0e5:0, service: 4e02b9cf-46da-413f-94c0-9ff13123a0e5:0
endpoint: be96b457-7249-4a93-9e46-66e8fc4aeb63:0, service: be96b457-7249-4a93-9e46-66e8fc4aeb63:0
ContextId is a two-part identity: the instance's Guid and, after the colon, a lease number; we will come back to that with pooling. Within the same request, the endpoint and the service got the same instance. The second request got a completely different one. That is exactly what the scoped lifetime means: ASP.NET Core opens a scope for every HTTP request, everyone who asks for the context within that scope gets the same instance, and when the request ends the scope closes and the context is disposed. The output is from EF Core 10; on EF Core 6 only the Guids differ.
This behavior is a perfect fit for EF Core. Remember the change tracker: a context tracks the entities it loaded and writes them in SaveChanges. Collecting the changes made during a request in a single context means finishing the request with a single SaveChanges. It also matters that the context is not carried across requests: one user's tracked data does not leak into another's, and the change tracker does not grow forever. We covered the general logic of lifetimes in the dependency injection post of the ASP.NET Core Web API series; here we see its EF Core counterpart.
Injecting the Context into a Singleton
Imagine you are writing a price cache. A single instance is enough for the whole application, so you register it as a singleton and inject the context into it:
public class BookCache
{
private readonly BookstoreContext _db;
public BookCache(BookstoreContext db) => _db = db; // a scoped context inside a singleton
}
builder.Services.AddSingleton<BookCache>();
Starting in the Development environment, the application stops before the first request even arrives:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: BookCache Lifetime: Singleton ImplementationType: BookCache': Cannot consume scoped service 'BookstoreContext' from singleton 'BookCache'.)
This is the very captive dependency we saw in the Web API series. Since the singleton never dies, the context it captured never dies either. Every request shares the same context, the same change tracker and the same connection. Scope validation is on in the Development environment, so the error shows up immediately. In Production this validation is off by default; the application starts, and the problem surfaces under the first real load as strange data and threading errors. If a singleton needs the database, it should take the factory we are about to see, or a scope factory, not the context.
DbContext in a Background Service
A BackgroundService is also a singleton; it is registered with AddHostedService and lives as long as the application. So the same rule applies: you cannot take the context in the constructor. Instead you take an IServiceScopeFactory and open your own scope for every round of work:
public class LowStockReporter : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<LowStockReporter> _logger;
public LowStockReporter(IServiceScopeFactory scopeFactory, ILogger<LowStockReporter> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = _scopeFactory.CreateScope(); // a new scope and a new context for every round
var db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
var count = await db.Books.CountAsync(b => b.Stock < 3, stoppingToken);
_logger.LogInformation("Low stock books: {Count}, context {ContextId}", count, db.ContextId);
}
}
}
Keeping the application running for 11 seconds, the log shows two rounds:
info: LowStockReporter[0]
Low stock books: 1, context 4018e1f1-5fe7-43ae-9f27-3a1b04f7e698:0
info: LowStockReporter[0]
Low stock books: 1, context 527ad837-5042-4848-912c-381bc75e7197:0
Every round works with a new context. Here the scope does by hand what an HTTP request does for you: it opens at the start and closes with using at the end of the round, taking the context with it. It may be tempting to get the context once outside the loop and use it forever. Do not: the change tracker grows every round, and data read an hour ago is still believed to be "current". We also passed stoppingToken to the queries; when the application shuts down, a long query can be cut short. We discussed carrying the CancellationToken all the way to the database separately in the Web API series.
AddDbContextFactory
There are cases where, instead of opening a scope, you want to simply say "create a context for me". A background service is one of them. Blazor Server is another: there a scope lives for the entire user connection, and you do not want to hold a scoped context for hours. That is what AddDbContextFactory is for:
builder.Services.AddDbContextFactory<BookstoreContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services.AddHostedService<LowStockFactoryReporter>();
The service takes an IDbContextFactory<BookstoreContext> and creates a new context every round:
public class LowStockFactoryReporter : BackgroundService
{
private readonly IDbContextFactory<BookstoreContext> _contextFactory;
private readonly ILogger<LowStockFactoryReporter> _logger;
public LowStockFactoryReporter(IDbContextFactory<BookstoreContext> contextFactory,
ILogger<LowStockFactoryReporter> logger)
{
_contextFactory = contextFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var db = await _contextFactory.CreateDbContextAsync(stoppingToken); // disposing it is your job
var count = await db.Books.CountAsync(b => b.Stock < 3, stoppingToken);
_logger.LogInformation("Low stock books: {Count}, context {ContextId}", count, db.ContextId);
}
}
}
You own the context the factory creates; the DI container will not dispose it for you. That is why the using is required. Since the factory is registered as a singleton, it can safely be injected into singletons. There is also a convenience that came with EF Core 6: AddDbContextFactory registers the context itself as scoped too. In this sample we only registered the factory, yet the /whoami endpoint could still take BookstoreContext directly, and the endpoint and the service saw the same instance within a request. So registering the factory does not break normal usage in endpoints.
AddDbContextPool
Creating a context is cheap, but not free. Every request sets up a new instance, a few internal services and option objects. In very high-traffic applications, EF Core can reset disposed contexts and keep them in a pool instead of throwing them away:
builder.Services.AddDbContextPool<BookstoreContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));
The same two /whoami requests produced this output this time:
endpoint: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:3, service: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:3
endpoint: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:4, service: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:4
Both requests used the same instance; only the lease number changed. Each request leases the context from the pool, when the request ends the context is reset and returned to the pool, and the next request leases it again. The earlier leases were used by the seed scope at startup and by our first request that checked whether the application was up. The pool's default size is 1024; you can change it with the poolSize parameter.
Pooling has rules. The context's constructor must take only DbContextOptions; you cannot inject another scoped service into the constructor, because the context is created once and reused again and again. Fields you add to the context yourself, a tenant id for example, are not reset between leases either, and one request's value can leak into the next. EF Core resets the change tracker and its own state, not yours. The gain is usually small, too; it makes a difference only in applications that receive thousands of requests per second and have a measured bottleneck. We will briefly return to this in the performance post.
Common Pitfalls
Parallel queries on the same context. The thought "if I run both queries at the same time it will be faster" turns into this code:
var booksTask = db.Books.ToListAsync();
var countTask = db.Books.CountAsync();
await Task.WhenAll(booksTask, countTask); // two queries on the same context at once
On SQLite this code works, because the async methods of Microsoft.Data.Sqlite actually run synchronously; the second query starts after the first one has finished. On SQL Server the two queries really overlap. When we used the same context from two threads truly at the same time, we got this exception even on SQLite:
InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
The context is not thread-safe. A context does one operation at a time: await each query, then move to the next. If you really need parallelism, get a separate context from the factory for each piece of work.
Keeping the context in a static field or a singleton. This is the hand-written version of the captive dependency; not even DI validation can save you from it.
Work that escapes the request through async void or Task.Run. If a "fire and forget" job started from an endpoint touches the context after the request has ended and the scope has closed, you get an ObjectDisposedException. Hand work that outlives the request to a background service or a queue.
Relying on OnConfiguring with pooling. Keep the configuration in the lambda you pass to AddDbContextPool; do not try to apply per-request settings inside the context.
A Small Experiment
★☆☆ Register BookService with AddTransient instead of AddScoped and call /whoami again. Do the endpoint and the service still see the same context? Why?
★★☆ Set ASPNETCORE_ENVIRONMENT to Production and run the application with BookCache. Does it start? Add a property to BookCache that returns the context's ContextId and confirm that you see the same value in two requests.
★★★ Add public string? TenantId { get; set; } to the context running with AddDbContextPool. Set the value in one endpoint, read it in another and print it together with the lease number. Once you see the value carried across requests, think about how you would fix it.
We wired the context into the right place; now it is time to look at how fast it runs. In the next post we will cover N+1 queries, projection into DTOs, unnecessary tracking, indexes, compiled queries and measuring queries.