Why lifetime bugs are the hardest DI bugs to catch
Lifetime mismatches in .NET Core DI are insidious. The app starts fine, unit tests pass, and the bug only surfaces under concurrent load when a shared DbContext corrupts EF's change tracker — or a stale config value from a Singleton serves wrong prices to every customer. This article explains the three lifetimes, when each is right, and how to detect mismatches before they reach production.
The three lifetimes at a glance
// Singleton — one instance for the entire app lifetime:
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();
// Scoped — one instance per HTTP request (or per explicit scope):
builder.Services.AddScoped<AppDbContext>();
// Transient — new instance on every resolve:
builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();
| Lifetime | Created | Disposed | Thread-safe required? |
|---|---|---|---|
| Singleton | App start | App shutdown | Yes |
| Scoped | Per request | End of request | No (one request = one thread) |
| Transient | Per resolve | End of scope | No (own instance) |
Singleton — shared, thread-safe, long-lived
Use Singleton for services that are stateless or explicitly thread-safe, expensive to create, or meant to be shared across all requests.
// Good singletons:
builder.Services.AddSingleton<IHttpClientFactory>(); // pool management — thread-safe
builder.Services.AddSingleton<IMemoryCache, MemoryCache>(); // thread-safe by design
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis"]));
// Thread-safe counter:
public class RequestCounter
{
private long _count;
public void Increment() => Interlocked.Increment(ref _count);
public long Get() => Interlocked.Read(ref _count);
}
// Unsafe singleton — shared mutable state without synchronization:
public class OrderCache
{
private List<Order> _orders = new(); // not thread-safe!
public void Add(Order o) => _orders.Add(o); // race condition under concurrent requests
}
If a Singleton holds mutable state, protect it with Interlocked, lock, or concurrent
collections (ConcurrentDictionary, ConcurrentQueue).
Scoped — the right home for DbContext
Scoped services live for exactly one HTTP request. Two classes that both inject the same
Scoped service within the same request share the same instance — which is exactly what you
want for a DbContext unit of work.
builder.Services.AddScoped<AppDbContext>();
// Both services get the same AppDbContext within one request:
public class OrderService
{
public OrderService(AppDbContext db) { _db = db; }
}
public class InventoryService
{
public InventoryService(AppDbContext db) { _db = db; }
// Same instance as OrderService — shared transaction possible
}
// Creating a scope manually (needed outside requests):
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(); // own connection; disposed at end of using
}
The "scope" in ASP.NET Core maps to an HTTP request. In background services, Blazor circuits,
and gRPC calls, you create scopes explicitly with IServiceScopeFactory.
Transient — cheap and stateless
Transient services are created anew on every resolve. They're safe for non-thread-safe objects because each consumer gets its own instance. The trade-off is allocation cost.
builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();
// Demonstration:
public class SignupService
{
public SignupService(IEmailValidator v1, IEmailValidator v2)
{
Console.WriteLine(ReferenceEquals(v1, v2)); // false — different instances
}
}
Avoid Transient for expensive-to-construct services or services holding resources like connections — you'll create and dispose them on every injection, multiplying overhead.
Captive dependencies — the most dangerous lifetime bug
A captive dependency occurs when a longer-lived service holds a shorter-lived service in its constructor. The short-lived service is "captured" and effectively becomes long-lived.
// Singleton captures a Scoped service:
public class ProductCache // Singleton
{
private readonly AppDbContext _db; // AppDbContext is Scoped
public ProductCache(AppDbContext db)
{
_db = db;
// _db now lives forever — request-specific EF change tracker shared across ALL requests
// Symptoms: stale data, entity tracking conflicts, ObjectDisposedException
}
}
This is silent in development and only manifests under concurrent load or across requests.
// Fix: inject IServiceScopeFactory; create a scope per operation:
public class ProductCache
{
private readonly IServiceScopeFactory _factory;
public ProductCache(IServiceScopeFactory factory) => _factory = factory;
public async Task<IReadOnlyList<Product>> GetAllAsync()
{
using var scope = _factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Products.AsNoTracking().ToListAsync();
// scope and db disposed at end of using block
}
}
The valid captive directions: Singleton can hold Singleton. Scoped can hold Scoped or Singleton. Transient can hold anything. The invalid direction: any longer-lived service capturing a shorter-lived one.
IServiceScopeFactory — the tool for background work
Background services (IHostedService, BackgroundService) are registered as Singletons.
They can't inject Scoped services directly. IServiceScopeFactory creates an explicit scope
per work unit.
public class DataSyncJob : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public DataSyncJob(IServiceScopeFactory factory) => _scopeFactory = factory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// Fresh scope per sync cycle — own DbContext, own transaction:
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();
await sync.SyncPendingOrdersAsync(ct);
await db.SaveChangesAsync(ct);
} // scope, db, and sync disposed here
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}
IServiceScopeFactory is itself Singleton — safe to inject into any lifetime.
Disposal — who calls Dispose?
The container automatically disposes IDisposable and IAsyncDisposable services when
their scope ends. You should not call Dispose on injected services manually.
// The container calls DisposeAsync at request end:
public class ReportWriter : IAsyncDisposable
{
private readonly StreamWriter _writer;
public ReportWriter() => _writer = new StreamWriter("report.csv");
public async ValueTask DisposeAsync() => await _writer.DisposeAsync();
}
builder.Services.AddScoped<ReportWriter>();
// DisposeAsync called automatically when the HTTP request scope ends
// Root-scope transient IDisposable memory leak:
// Resolving a Transient IDisposable from app.Services (root scope) means it won't
// be disposed until app shutdown — always use a child scope for one-off resolutions.
using var scope = app.Services.CreateScope();
var writer = scope.ServiceProvider.GetRequiredService<ReportWriter>();
// Disposed when scope is disposed
ValidateScopes — catch bugs at startup
builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
bool isDev = ctx.HostingEnvironment.IsDevelopment();
options.ValidateScopes = isDev; // throws if Scoped is captured by Singleton
options.ValidateOnBuild = isDev; // throws if any dependency can't be resolved
});
With ValidateScopes = true, the example captive dependency throws
InvalidOperationException at app.Build() — not silently on the 10th concurrent request.
Add an integration test that builds the real container with these options enabled to make scope validation part of CI:
[Fact]
public void ServiceGraph_IsValid()
{
var services = new ServiceCollection();
Startup.ConfigureServices(services);
using var provider = services.BuildServiceProvider(
new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
Assert.NotNull(provider); // build succeeds = graph is valid
}
Lifetimes in Blazor Server
Blazor Server changes what "Scoped" means. A Scoped service in Blazor Server lives for the SignalR circuit (browser tab session), not for an HTTP request. This makes Scoped effectively per-user-session — much longer than ASP.NET Core Scoped.
// In Blazor Server:
builder.Services.AddScoped<IUserState, UserState>();
// One instance per browser tab — persists across component navigations
// Singleton in Blazor Server = shared across ALL connected browser tabs:
builder.Services.AddSingleton<ISharedCache, MemoryCache>();
// All users share this — only safe for truly thread-safe, shared state
Recap
Singleton lives for the app, Scoped lives for the request, and Transient lives per resolve.
Captive dependencies — Singletons capturing Scoped or Transient services — are the most
dangerous lifetime bug: silent in dev, corrupting in production. Use IServiceScopeFactory
in Singletons and background services that need Scoped resources. The container owns disposal
for IDisposable services — never call Dispose on injected services yourself. Enable
ValidateScopes and ValidateOnBuild in development to turn lifetime mismatches into startup
errors rather than production incidents.