Dependency Injection Lifetimes in ASP.NET Core — Singleton, Scoped, Transient, and the Captive Dependency Trap
ASP.NET Core's built-in dependency injection container is so easy to use that most developers learn just enough of it to register a service and move on. That works — until the day a perfectly reasonable-looking registration produces a bug that only appears under load, or a DbContext that throws because it was disposed, or a service that mysteriously remembers state it shouldn't. Almost every one of these traces back to a single decision the container forces you to make and most people make by reflex: the service lifetime.
There are only three lifetimes, and the concepts are simple. The trouble is what happens when you mix them carelessly. Get the three straight, understand the one trap that combining them creates, and a whole category of subtle .NET bugs disappears from your life.
The three lifetimes, precisely
When you register a service, you choose how long the container keeps and reuses each instance.
Transient services are created every single time they are requested. Ask for one twice — even within the same operation — and you get two separate instances. Register them with AddTransient:
csharp
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();Use Transient for lightweight, stateless services where a fresh instance each time is harmless and cheap.
Scoped services are created once per scope, and in ASP.NET Core a scope is one HTTP request by default. Every component handling the same request shares the same instance; a different request gets a different one. Register with AddScoped:
csharp
builder.Services.AddScoped<IOrderService, OrderService>();This is the right default for most application services, and crucially it is the lifetime of DbContext when you use AddDbContext — one context per request, shared across that request, disposed when the request ends.
Singleton services are created once for the entire lifetime of the application and shared by every request and every thread. Register with AddSingleton:
csharp
builder.Services.AddSingleton<IConfigurationCache, ConfigurationCache>();Use Singleton for genuinely application-wide, expensive-to-build, stateless-or-thread-safe services: caches, configuration, HTTP client factories, and the like.
The trap: captive dependencies
Here is the rule that prevents the most damage, stated plainly: a service may only depend on services whose lifetime is at least as long as its own. A longer-lived service that holds a reference to a shorter-lived one captures it — extending the captive's life far beyond what it was designed for. This is the captive dependency.
The textbook disaster is injecting a Scoped service into a Singleton:
csharp
// DANGER: Singleton captures a Scoped DbContext
public class CacheWarmer // registered as Singleton
{
private readonly AppDbContext _db; // Scoped!
public CacheWarmer(AppDbContext db) => _db = db;
}Walk through what happens. The Singleton is built once, at app startup, and it grabs whatever AppDbContext instance exists at that moment — then holds it forever. But DbContext is meant to live for a single request: it is not thread-safe and it tracks entity state per unit of work. Now a single context is shared across every concurrent request for the life of the app. You get disposed-context exceptions, "a second operation was started on this context" errors under concurrency, and stale or corrupted change-tracking. The bug is intermittent and load-dependent, which is exactly why it's so painful.
The same logic applies, more quietly, to a Transient captured by a Singleton: the "transient" instance is created once when the singleton is built and then lives for the whole application, which defeats the point of making it transient and can pin memory or state you expected to be short-lived.
How the container helps you catch it
The good news: ASP.NET Core can detect a class of these mistakes for you. By default in the Development environment, the host validates scopes, and attempting to resolve a Scoped service from the root provider (or into a Singleton) throws a clear error at startup rather than failing mysteriously later. You can — and in CI, should — make this validation explicit and also validate that every registration can actually be built:
csharp
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // catch captive scoped dependencies
options.ValidateOnBuild = true; // catch broken registrations at startup
});Turning these on means a captive dependency or a missing registration fails fast, at build time, instead of becoming a 2 a.m. production incident.
The right way to use short-lived services from a singleton
Sometimes a singleton genuinely needs to do scoped work — a background cache warmer really does need a DbContext. The fix is not to inject the scoped service directly, but to create a scope on demand and resolve from it, so the short-lived service lives and dies inside that scope:
csharp
public class CacheWarmer
{
private readonly IServiceScopeFactory _scopeFactory;
public CacheWarmer(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task WarmAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// use db here; it is created and disposed within this scope
await db.SaveChangesAsync(ct);
}
}For DbContext specifically, .NET also offers IDbContextFactory<T> (registered via AddDbContextFactory), which hands you a fresh, independent context whenever you ask — ideal for singletons, background services, and anywhere the per-request scope model doesn't fit.
Two more lifetime hazards worth knowing
Disposal and Transient IDisposables. The container disposes the IDisposable services it creates, but a Transient IDisposable resolved from the root provider is tracked by the container and only released when the app shuts down — a slow memory leak. Resolve disposable transients from a scope, not the root, so they're cleaned up when the scope ends.
Thread safety in singletons. Because a singleton is shared across all requests and threads simultaneously, any mutable state inside it must be thread-safe. A singleton holding a plain List<T> or Dictionary<TKey,TValue> that multiple requests write to is a race condition waiting to happen; reach for the concurrent collections or proper synchronization.
A simple decision guide
When you register a service, ask in order: does it hold per-request state or wrap a per-request resource like DbContext? Make it Scoped — this is the correct default for most of your application services. Is it stateless, cheap, and fine to recreate constantly? Transient. Is it expensive to create, genuinely application-wide, and either stateless or carefully thread-safe? Singleton — and then double-check it depends only on other singletons. Keep that ordering in mind, switch on scope validation, and the lifetime decision stops being a coin flip and becomes a deliberate, safe choice.
Frequently asked questions
What is the difference between Transient, Scoped, and Singleton in ASP.NET Core?
Transient services are created every time they're requested. Scoped services are created once per scope — one HTTP request by default — and shared within that request. Singleton services are created once for the application's entire lifetime and shared across all requests and threads. The choice controls how long each instance lives and how widely it's reused.
What is a captive dependency?
A captive dependency occurs when a longer-lived service holds a reference to a shorter-lived one, extending the shorter service's lifetime beyond its design. The classic case is a Singleton capturing a Scoped DbContext: the context, meant to live one request, ends up shared across all requests for the app's life, causing disposal errors, concurrency exceptions, and corrupted change tracking.
Why shouldn't I inject DbContext into a singleton?
Because DbContext is registered as Scoped, is not thread-safe, and tracks state per unit of work. A singleton captures one context instance and shares it across every concurrent request indefinitely, leading to intermittent, load-dependent failures. Instead, inject IServiceScopeFactory and create a scope, or use IDbContextFactory<T> to get a fresh context.
How do I safely use a scoped service inside a singleton?
Inject IServiceScopeFactory, call CreateScope() inside a using block, and resolve the scoped service from that scope so it lives and is disposed within it. For database access specifically, IDbContextFactory<T> (via AddDbContextFactory) provides a fresh, independent DbContext on demand, which suits singletons and background services well.
How can I detect lifetime mistakes automatically?
Enable scope validation. In Development, ASP.NET Core validates scopes by default and throws when a Scoped service is resolved into a Singleton or from the root provider. Set ValidateScopes = true and ValidateOnBuild = true via UseDefaultServiceProvider so captive dependencies and broken registrations fail fast at startup, including in CI.


