dotnet/Ecosystem & Infrastructure
DI lifetimes & captive dependencies
intermediateTL;DR
builder.Services.AddSingleton<IClock, SystemClock>(); // one, forever
builder.Services.AddScoped<AppDbContext>(); // one per request
builder.Services.AddTransient<IEmailBuilder, EmailBuilder>(); // new every time
// The rule that prevents most DI bugs:
// never inject a SHORTER-lived service into a LONGER-lived one.
// singleton ← scoped = captive dependency (bug)
// singleton ← transient = captive too — it just hides better
// Make the container catch it in Development:
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true;
});
How it works
The built-in container (Microsoft.Extensions.DependencyInjection) is not a
magic box — it’s a graph of factories keyed by service type, and a lifetime is
just an instruction for how long a factory’s output is cached before a new one
is produced.
- Singleton: the factory runs once, on first resolution (or eagerly if you
register an instance directly). The result is cached on the root
IServiceProviderand handed to every caller for the lifetime of the app. - Scoped: the factory runs once per
IServiceScope. ASP.NET Core creates exactly one scope per HTTP request, which is what “oneAppDbContextper request” actually means under the hood — and why two concurrent requests never end up sharing one. - Transient: the factory runs on every single resolution — no caching, no scope awareness.
Disposal follows the scope that created the instance
Whoever creates a scope owns disposing everything IDisposable/
IAsyncDisposable that scope produced. The request’s scope disposes its
scoped (and transient) services when the request ends; the root provider
disposes singletons only when the host shuts down. That’s why a transient
IDisposable resolved straight from the root provider — outside any
request scope, e.g. in a hosted service’s constructor — is never disposed
until shutdown: a real, if slow, memory leak.
Catching it before it ships
ValidateScopes makes the container throw the moment a scoped service is
resolved from the root provider instead of a request scope. ValidateOnBuild
walks the whole dependency graph at Build() time and throws immediately for
anything unresolvable, instead of waiting for the first request that happens
to need it. Both are cheap enough to enable unconditionally in Development
and turn a class of DI bugs into failed startups instead of 3am incidents.
Background services need their own scope
IHostedService/BackgroundService instances are themselves singletons —
they live for the app’s lifetime, so they can’t constructor-inject a scoped
AppDbContext directly. Instead they inject IServiceScopeFactory, call
CreateScope() around each unit of work, and resolve scoped services from
that scope. It’s the same mechanism ASP.NET Core uses internally to create
the per-request scope in the first place.
Gotchas
- Captive dependency. A singleton that constructor-injects a scoped
AppDbContextcaptures the first instance it’s ever given and reuses it forever — oneDbContextshared across every request, concurrently.DbContextisn’t thread-safe, so that’s anInvalidOperationExceptionat best and silent cross-request data bleed at worst. - Resolving a scoped service from the root provider “works” — that’s the
trap. It returns an instance without throwing, but silently promotes it
to singleton-like lifetime: cached on the root, never disposed per request.
ValidateScopesis exactly the setting that turns this into a hard failure in Development instead of a subtle production bug. HttpClient— don’tnewone per call (each holds a connection pool; churning through them under load exhausts sockets) and don’t register one as a naive long-lived singleton either (it never picks up DNS changes).IHttpClientFactory(AddHttpClient<T>()) exists precisely to give you pooled, periodically recycled handlers without either failure mode.- Constructor injection into middleware is singleton-flavored. ASP.NET
Core constructs a conventional middleware instance once, at startup — so a
constructor dependency on a scoped service is a captive dependency in
disguise. Scoped dependencies belong on the
InvokeAsyncparameters instead (see the middleware pipeline article). - Never call
BuildServiceProvider()insideConfigureServices(or anywhere outside the composition root). It spins up a second container with its own singleton instances, disconnected from the one ASP.NET Core actually uses to serve requests — now you have two “singletons”.