DI lifetimes and the captive dependency trap
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:
| Lifetime | One instance per | Typical use |
|---|---|---|
| Singleton | Application | Caches, clients like ServiceBusClient, configuration |
| Scoped | Request (or scope) | DbContext, unit of work, per-request state |
| Transient | Every resolution | Lightweight, 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.