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.

Syntaxawait 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.

SituationScope
Queue consumer, one message at a timeOne scope per message
Parallel processingOne scope per concurrent item; never share one
Periodic jobOne 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 IServiceProvider injected 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.ForEachAsync workers: the shared DbContext throws on concurrent use.
  • An exception escaping ExecuteAsync stops the host by default (BackgroundServiceExceptionBehavior.StopHost), so catch per message, log and continue.
  • Ambient request data such as IHttpContextAccessor.HttpContext is null in a background scope; pass the tenant or user explicitly with the message.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.