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

LifetimeCreatedUse it forDanger
SingletonOnce per containerConfiguration, caches, stateless services, HTTP clientsCapturing a scoped service, or holding mutable state
ScopedOnce per scope, one per HTTP requestDbContext, per-request state, unit of workUsing it after the scope ends, or from a background thread
TransientEvery resolutionCheap stateless helpersAllocating 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 (AddKeyedScoped and [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 IServiceProvider inside 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.

Configuration, options and logging Diagnostics and observability in production

Last refreshed 2026-09-18.