Scopes Outside a Request
Create a DI scope per unit of work in BackgroundService and singletons with CreateAsyncScope, so scoped services are fresh and disposed.
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDb>();ASP.NET Core creates a scope for every HTTP request and disposes it when the response ends. Nothing creates scopes for you anywhere else: a BackgroundService, a timer callback, a message consumer, a singleton cache refresher or start-up seeding code. Hosted services are singletons, so they cannot take a DbContext or any other scoped service in their constructor. Doing so throws under scope validation, or silently creates a captive dependency without it.
The pattern
Inject IServiceScopeFactory (a singleton, safe anywhere). For each unit of work (one message, one batch, one timer tick) call await using var scope = scopeFactory.CreateAsyncScope();, resolve the scoped services from scope.ServiceProvider, do the work and let the scope dispose. Disposal of the scope disposes every scoped and transient disposable it created, in reverse order, and returns the DbContext connection to the pool.
What the unit of work should be
Scope per message is the default. A scope for the lifetime of the worker is the captive dependency problem again: the change tracker grows without bound, stale entities are served from it and memory climbs until the pod is killed. A scope per batch is fine when the batch is bounded and commits together.
CreateAsyncScope vs CreateScope
CreateScope() returns an IServiceScope that you dispose synchronously. If any service in it implements only IAsyncDisposable, synchronous disposal throws InvalidOperationException telling you to use DisposeAsync. CreateAsyncScope() returns an AsyncServiceScope that supports await using and disposes asynchronous services correctly. Use the async form in async code, which is nearly all background code.
| Situation | Scope |
|---|---|
| Queue consumer, one message at a time | One scope per message |
| Parallel processing | One scope per concurrent item; never share one |
| Periodic job | One scope per tick |
| Start-up seeding (single instance or idempotent data only) | One scope around the work, before Run |
Schema migrations belong in a separate deploy step (a migration bundle or a Kubernetes Job), not in every replica's startup, where pods race each other and a long migration holds back readiness.
Production failure modes
- Resolving from
IServiceProviderinjected into a singleton: that is the root provider, so scoped services come from the root scope and are not disposed until the app shuts down, so they behave like singletons and accumulate. - Sharing one scope across
Parallel.ForEachAsyncworkers: the sharedDbContextthrows on concurrent use. - An exception escaping
ExecuteAsyncstops the host by default (BackgroundServiceExceptionBehavior.StopHost), so catch per message, log and continue. - Ambient request data such as
IHttpContextAccessor.HttpContextis null in a background scope; pass the tenant or user explicitly with the message.
Example
When to use it
- Process each OrderPlaced message in its own scope so every message gets a fresh DbContext and a clean change tracker
- Expire unpaid orders every 5 minutes from a BackgroundService, creating and disposing one scope per timer tick
- Send 2,000 invoice emails with Parallel.ForEachAsync at 4 workers, each worker item resolving its own BillingDb
More examples
A periodic job with one scope per tick
The scope lives for one tick, so each run gets a fresh <code>ShopDb</code> and its connection goes back to the pool before the timer waits again. The try/catch keeps one failed run (a database restart, a timeout) from escaping <code>ExecuteAsync</code>, which by default would stop the whole host. The filter tests <code>stoppingToken</code> rather than the exception type, because an HTTP or database timeout also surfaces as an <code>OperationCanceledException</code> and must not end the worker.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHostedService<ExpireUnpaidOrders>();
builder.Build().Run();
public sealed class Order
{
public Guid Id { get; set; }
public string Status { get; set; } = "Pending";
public DateTimeOffset CreatedAt { get; set; }
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public sealed class ExpireUnpaidOrders(IServiceScopeFactory scopes, TimeProvider clock, ILogger<ExpireUnpaidOrders> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), clock);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// A fresh DbContext per tick: no change tracker that grows for days.
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
var cutoff = clock.GetUtcNow().AddHours(-24);
var expired = await db.Orders
.Where(o => o.Status == "Pending" && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Expired"), stoppingToken);
logger.LogInformation("Expired {Count} unpaid orders", expired);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Expiring unpaid orders failed; retrying next tick");
}
}
}
}Parallel work needs a scope per item
Scoped means one instance per scope, not per thread. Sharing a scope across <code>Parallel.ForEachAsync</code> workers shares one <code>DbContext</code>, which fails with <code>A second operation was started on this context instance</code> or, worse, corrupts tracked state. One scope per item gives each worker its own context; cap the parallelism below the connection pool size.
using Microsoft.EntityFrameworkCore;
public sealed class Invoice
{
public Guid Id { get; set; }
public string Number { get; set; } = "";
public bool Sent { get; set; }
}
public sealed class BillingDb(DbContextOptions<BillingDb> options) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
}
public sealed class InvoiceSender(IServiceScopeFactory scopes)
{
public async Task SendAllAsync(IReadOnlyList<Guid> invoiceIds, CancellationToken ct)
{
// Wrong: one scope (one DbContext) shared by four concurrent workers.
// await using var shared = scopes.CreateAsyncScope();
// var db = shared.ServiceProvider.GetRequiredService<BillingDb>();
await Parallel.ForEachAsync(invoiceIds, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (id, token) =>
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BillingDb>();
var invoice = await db.Invoices.SingleAsync(i => i.Id == id, token);
invoice.Sent = true;
await db.SaveChangesAsync(token);
});
}
}Sync Dispose fails on async-only services
A scope disposed synchronously cannot await <code>DisposeAsync</code>, so it throws <code>'ExportFileWriter' type only implements IAsyncDisposable. Use DisposeAsync to dispose the container.</code> The same happens for a root <code>ServiceProvider</code> in a <code>using</code> statement. <code>CreateAsyncScope</code> with <code>await using</code> avoids it and costs nothing extra.
var services = new ServiceCollection();
services.AddScoped<ExportFileWriter>();
using var provider = services.BuildServiceProvider();
try
{
using (var scope = provider.CreateScope())
{
scope.ServiceProvider.GetRequiredService<ExportFileWriter>();
} // synchronous Dispose of the scope
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"CreateScope: {ex.Message}");
}
await using (var scope = provider.CreateAsyncScope())
{
scope.ServiceProvider.GetRequiredService<ExportFileWriter>();
}
Console.WriteLine("CreateAsyncScope disposed it cleanly");
// Implements only IAsyncDisposable, like many stream and client wrappers.
public sealed class ExportFileWriter : IAsyncDisposable
{
public async ValueTask DisposeAsync() => await Task.Delay(1);
}
Discussion