DI lifetimes and the captive dependency trap

ยท 2 min read

A singleton that holds on to a scoped service keeps it alive forever. It's one of the easiest bugs to create in ASP.NET Core and one of the hardest to spot.

The built-in dependency injection container in .NET has three lifetimes:

LifetimeOne instance perTypical use
SingletonApplicationCaches, clients like ServiceBusClient, configuration
ScopedRequest (or scope)DbContext, unit of work, per-request state
TransientEvery resolutionLightweight, stateless helpers

The rule that matters: a service must not depend on anything with a shorter lifetime than its own.

The captive dependency

builder.Services.AddDbContext<OrdersDb>();           // scoped
builder.Services.AddSingleton<OrderCache>();         // singleton

public class OrderCache(OrdersDb db) { /* ... */ }   // captures a scoped DbContext

The first time OrderCache is created, it receives a DbContext. Because the cache lives forever, so does that DbContext. Now one context is shared by every request on every thread. DbContext isn't thread-safe, its change tracker grows without limit, and you get intermittent errors that never show up in a quick local test.

The scoped service has been taken captive by the singleton. Transient services can be captured the same way: they just become singletons in disguise.

Let the container catch it

ASP.NET Core turns on scope validation in the Development environment, so resolving a scoped service from a singleton there throws an exception straight away. You can turn it on everywhere, and also have every registration checked at startup:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

ValidateOnBuild builds every registered service when the app starts, so a missing or mismatched registration fails at startup rather than on the first request that needs it.

The fix: create a scope when you need one

If a long-lived service genuinely needs a scoped dependency, inject IServiceScopeFactory and create a scope for each unit of work:

public class OrderCacheRefresher(IServiceScopeFactory scopes) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<OrdersDb>();
            // load what you need, then the scope disposes the DbContext
        }
    }
}

This is the standard pattern for hosted services, which are always singletons.

Choosing a lifetime

  • Singleton if the service is thread-safe and expensive to create, or holds shared state on purpose.
  • Scoped if it holds per-request state or wraps something scoped like a DbContext.
  • Transient for small stateless services. They're cheap, but remember that anything holding them keeps them alive.

Takeaway

Never inject a shorter-lived service into a longer-lived one. Turn on ValidateScopes and ValidateOnBuild so the container tells you when you do, and use IServiceScopeFactory when a singleton really needs scoped work.