~/handys11
Browse the docs

dotnet/Ecosystem & Infrastructure

DI lifetimes & captive dependencies

intermediate

TL;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 IServiceProvider and 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 “one AppDbContext per 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 AppDbContext captures the first instance it’s ever given and reuses it forever — one DbContext shared across every request, concurrently. DbContext isn’t thread-safe, so that’s an InvalidOperationException at 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. ValidateScopes is exactly the setting that turns this into a hard failure in Development instead of a subtle production bug.
  • HttpClient — don’t new one 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 InvokeAsync parameters instead (see the middleware pipeline article).
  • Never call BuildServiceProvider() inside ConfigureServices (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”.

Further reading