DbContext and Registration
Register a DbContext with AddDbContext or AddDbContextPool, wire its connection string, and keep one context per request.
builder.Services.AddDbContextPool<ShopDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));A DbContext is a unit of work: it owns a change tracker, a connection to one database and the compiled model. You derive one class per database, expose a DbSet<T> per aggregate you query, and take DbContextOptions<TContext> in the constructor so the host decides the provider and connection string, not the class.
Registration
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(cs)) registers ShopDb and DbContextOptions<ShopDb>, both scoped by default. In a web API the scope is the request, so every handler and service in one request shares one context and one change tracker, and the container disposes it when the response ends. UseSqlServer and UseSqlite work the same way; only the provider package changes.
The connection string comes from configuration: builder.Configuration.GetConnectionString("Shop") reads ConnectionStrings:Shop, which an environment variable ConnectionStrings__Shop or a Kubernetes Secret overrides. GetConnectionString returns null when the key is missing, and the provider only fails at the first query, so throw at startup instead.
Why scoped, and what breaks
A context is not thread-safe. Two queries started on the same instance without awaiting the first fail with A second operation was started on this context instance before a previous operation completed. The usual causes are Task.WhenAll over several queries, a fire-and-forget task that captured the request's context, and a singleton that took a context in its constructor (a captive dependency). For parallel work or work outside a request, use IDbContextFactory<T> and create a short-lived context per unit of work. A context kept alive for minutes also grows: every tracked entity stays in memory until the context is disposed.
Pooling
AddDbContextPool keeps up to poolSize context instances (default 1024) and resets their state when a request ends, which saves the allocation and setup of each context's internal services. It helps at thousands of requests per second and is invisible below that. It is not the connection pool: ADO.NET drivers pool connections separately (Npgsql and SqlClient both default to Max Pool Size=100). Pooled contexts have rules: the constructor may take only the options, nothing scoped can be injected, and any field you add survives into the next request, so a tenant id stored in a field leaks across tenants.
| Registration | Lifetime | Use it for |
|---|---|---|
AddDbContext | Scoped context | The default for web APIs |
AddDbContextPool | Scoped, instances reused | High request rates, contexts with no per-request state |
AddDbContextFactory | Factory, you own each context | Background services, parallel queries |
AddPooledDbContextFactory | Factory over a pool | The same, at high volume |
Options worth setting
EnableRetryOnFailure() retries transient errors (failover, dropped connections) but changes how explicit transactions must be written. EnableSensitiveDataLogging() writes parameter values to logs; enable it only in Development. UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) makes read-mostly contexts cheaper by default.
Example
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("Shop")
?? throw new InvalidOperationException("ConnectionStrings:Shop is not configured.");
builder.Services.AddDbContextPool<ShopDb>(options =>
{
options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure(maxRetryCount: 3));
if (builder.Environment.IsDevelopment())
{
// Parameter values in logs: useful locally, a data leak in production.
options.EnableSensitiveDataLogging();
}
}, poolSize: 256);
var app = builder.Build();
app.MapGet("/products/{id:int}", async (int id, ShopDb db, CancellationToken ct) =>
await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct) is { } product
? Results.Ok(product)
: Results.NotFound());
app.MapPost("/products", async (NewProduct input, ShopDb db, CancellationToken ct) =>
{
var product = new Product { Sku = input.Sku, Name = input.Name, Price = input.Price };
db.Products.Add(product);
await db.SaveChangesAsync(ct);
return Results.Created($"/products/{product.Id}", product);
});
app.Run();
// A pooled context takes only its options: no other constructor parameters, no per-request fields.
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
public sealed class Product
{
public int Id { get; set; }
public required string Sku { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
}
public sealed record NewProduct(string Sku, string Name, decimal Price);When to use it
- Register ShopDb with UseNpgsql and a connection string from ConnectionStrings__Shop supplied by a Kubernetes Secret
- Switch a catalogue API serving 3,000 requests per second from AddDbContext to AddDbContextPool after profiling context setup cost
- Run the queries behind an admin dashboard in parallel with IDbContextFactory instead of Task.WhenAll on one context
More examples
Connection strings in configuration
The connection string carries driver settings as well as the address: <code>Maximum Pool Size</code> caps connections per process, <code>Timeout</code> is how long to wait for a connection from the pool and <code>Command Timeout</code> is the default per-command limit in seconds. Multiply the pool size by the replica count and keep it under the server's <code>max_connections</code>. Raising <code>Microsoft.EntityFrameworkCore.Database.Command</code> to Information locally prints each SQL statement.
{
"ConnectionStrings": {
// Overridden in each environment by ConnectionStrings__Shop from a secret store.
"Shop": "Host=localhost;Port=5432;Database=shop;Username=shop_app;Password=<from-secret-store>;Maximum Pool Size=50;Timeout=15;Command Timeout=30"
},
"Logging": {
"LogLevel": {
"Default": "Information",
// Information here logs every SQL command with its duration.
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}Parallel queries with IDbContextFactory
Each local function creates and disposes its own context, so the two queries run on two connections at the same time. Use this only when the queries are slow and independent: each one takes a connection from the pool, so a dashboard with eight parallel queries holds eight connections per request. The factory is a singleton, which is also why it is the right tool inside singletons and hosted services.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPooledDbContextFactory<ShopDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();
// Broken version: Task.WhenAll on one injected ShopDb throws
// "A second operation was started on this context instance".
app.MapGet("/dashboard", async (IDbContextFactory<ShopDb> factory, CancellationToken ct) =>
{
async Task<int> CountOpenOrders()
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Orders.CountAsync(o => o.Status == "Open", ct);
}
async Task<decimal> RevenueToday()
{
await using var db = await factory.CreateDbContextAsync(ct);
var since = DateTime.UtcNow.Date;
return await db.Orders.Where(o => o.PlacedAt >= since).SumAsync(o => o.Total, ct);
}
var open = CountOpenOrders();
var revenue = RevenueToday();
await Task.WhenAll(open, revenue);
return Results.Ok(new { openOrders = open.Result, revenueToday = revenue.Result });
});
app.Run();
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public sealed class Order
{
public int Id { get; set; }
public required string Status { get; set; }
public DateTime PlacedAt { get; set; }
public decimal Total { get; set; }
}A scoped context in a background service
A hosted service is a singleton, so injecting <code>ShopDb</code> into its constructor would capture one context for the life of the process: its change tracker would grow forever and one broken connection would poison every later run. Creating a scope per iteration gives the same lifetime a request gets, and the scope disposes the context when the iteration ends. The <code>try</code>/<code>catch</code> matters as much: by default an unhandled exception in a <code>BackgroundService</code> stops the host, so one database failover would take the whole process down.
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddHostedService<ExpireUnpaidOrders>();
builder.Build().Run();
public sealed class ExpireUnpaidOrders(IServiceScopeFactory scopes, ILogger<ExpireUnpaidOrders> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// A new scope per run: a fresh context, an empty change tracker, disposed at the end.
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
var cutoff = DateTime.UtcNow.AddHours(-24);
var expired = await db.Orders
.Where(o => o.Status == "AwaitingPayment" && o.PlacedAt < cutoff)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Expired"), stoppingToken);
logger.LogInformation("Expired {Count} unpaid orders", expired);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Unhandled, this would stop the whole host; log it and try again next tick.
logger.LogError(ex, "Expiring unpaid orders failed");
}
}
}
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public sealed class Order
{
public int Id { get; set; }
public required string Status { get; set; }
public DateTime PlacedAt { get; set; }
}
Discussion