Dependency injection and the generic host
Lifetimes decide correctness, not just performance: a captured scoped service inside a singleton is a real bug that only shows up in production.
The three lifetimes
| Lifetime | Created | Use it for | Danger |
|---|---|---|---|
| Singleton | Once per container | Configuration, caches, stateless services, HTTP clients | Capturing a scoped service, or holding mutable state |
| Scoped | Once per scope, one per HTTP request | DbContext, per-request state, unit of work | Using it after the scope ends, or from a background thread |
| Transient | Every resolution | Cheap stateless helpers | Allocating on every resolution in a hot path |
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IReportCache, InMemoryReportCache>();
builder.Services.AddScoped<IReportRepository, SqlReportRepository>();
builder.Services.AddTransient<IReportFormatter, CsvReportFormatter>();
// An HTTP client, which is the correct way to own a handler
builder.Services.AddHttpClient<IRatesClient, RatesClient>(c =>
{
c.BaseAddress = new Uri("https://rates.example.com/");
c.Timeout = TimeSpan.FromSeconds(5);
}).AddStandardResilienceHandler();
// Background work belongs in a hosted service, not a fire-and-forget task
builder.Services.AddHostedService<ReportRefreshWorker>();
var host = builder.Build();
await host.RunAsync();The bugs this design invites
// WRONG: a singleton holding a scoped service. The scope is gone, and the
// DbContext is now shared across every request, which is a data race.
public sealed class BadCache
{
private readonly AppDbContext _db;
public BadCache(AppDbContext db) => _db = db; // throws at startup
}
// RIGHT: inject the factory, and create a scope per operation.
public sealed class GoodCache
{
private readonly IServiceScopeFactory _scopes;
public GoodCache(IServiceScopeFactory scopes) => _scopes = scopes;
public async Task<Report?> GetAsync(int id, CancellationToken ct)
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Reports.FindAsync(new object[] { id }, ct);
}
}
// RIGHT for a hosted service: its own scope for each iteration
public sealed class ReportRefreshWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
public ReportRefreshWorker(IServiceScopeFactory scopes) => _scopes = scopes;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopes.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IReportRepository>();
await repo.RefreshAsync(stoppingToken);
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}- All three lifetimes can be replaced by any shorter one, so a constructor taking a scoped dependency from a singleton is an error the container detects at build time and rejects.
- Inject a factory or a scope factory rather than a captured instance whenever the work outlives the request.
- Keyed services (
AddKeyedScopedand[FromKeyedServices("eu")]) remove the awkward pattern of registering several implementations of one interface and picking one with a switch. - Validate the container at startup. In development the default host already does this, which is why the bug often only appears after deployment.
- Resolving from
IServiceProviderinside business logic is a service-locator smell: it hides the dependency and makes the class untestable. - A transient that depends on a singleton and holds per-call state is safe; a singleton that depends on a transient traps the first instance forever.
⚠️
The most expensive DI bug is not a crash — it is a singleton that quietly accumulates state across requests, such as a list that grows or a cached user identifier. It passes every test that runs one request at a time and fails under concurrency in production.
FAQ
Is AddSingleton thread safe?
Registration is thread safe, but your class is not automatically. A singleton must be safe for concurrent use, or it must hold no mutable state.
Why does the container say a scoped service cannot be resolved from the root provider?
Because you resolved from the root, outside any scope. Create a scope with IServiceScopeFactory or inject the service into a scoped consumer instead.
Related
Configuration, options and logging Diagnostics and observability in production
Last refreshed 2026-09-18.