Problem Context
Dependency injection is the backbone of every modern ASP.NET Core app โ and the source of more subtle bugs than any other framework feature. The container registers services, resolves graphs, and disposes scopes for you, but the lifetime decisions are yours. Pick wrong and you get captive dependencies, leaked DbContexts, or singletons holding per-request state across users.
.NET 8 added keyed services (AddKeyedSingleton, [FromKeyedServices]), making "give me the Postgres impl" or "the small-model client" first-class. .NET 9 polished the API further. This guide is the operational view: pick lifetimes correctly, use keyed services where they belong, and avoid the four anti-patterns that cause production incidents.
- You injected
DbContextinto a singleton service and don't know why production occasionally returns wrong data - You have
IServiceProvidersprinkled through your code as a "lookup bag" - You need two implementations of
IPaymentProvider(Stripe and PayPal) and have a factory class shimming around the container - You see
BuildServiceProvider()called from insideConfigureServicesin a code review
DI lifetimes, keyed services, and the BackgroundService scope pattern โ the .NET 9 way.
Concept Explanation
The built-in container has three lifetimes. Transient: new instance every resolve. Scoped: one instance per scope (one HTTP request in ASP.NET Core). Singleton: one instance for the app's lifetime. Pick the longest lifetime that's safe โ and a service can never depend on a shorter-lived one (a singleton can't hold a scoped DbContext).
flowchart TB
A["WebApplication.CreateBuilder"] --> B["builder.Services"]
B --> C["AddSingleton - 1 per app"]
B --> D["AddScoped - 1 per request"]
B --> E["AddTransient - 1 per resolve"]
B --> F["AddKeyed* (.NET 8) - by string/enum key"]
C --> G["IClock, IConfiguration, IMemoryCache"]
D --> H["DbContext, current user services"]
E --> I["lightweight, per-call helpers"]
F --> J["multiple impls of one interface"]
style C fill:#dc2626,color:#fff,stroke:#991b1b
style D fill:#059669,color:#fff,stroke:#047857
style E fill:#0891b2,color:#fff,stroke:#0e7490
style F fill:#4f46e5,color:#fff,stroke:#4338ca
The captive dependency rule: a longer-lived service that holds a reference to a shorter-lived one captures it. Inject DbContext(scoped) into a singleton and that singleton holds the same DbContext forever โ across every request, every user. That's a data integrity bug. Enable scope validation (WebApplication.CreateBuilder does this in development by default) and the container throws at startup if you do this.
Implementation
Step 1: Lifetime quick reference
var builder = WebApplication.CreateBuilder(args);
// SINGLETON โ stateless, thread-safe, holds no per-request state
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddSingleton<IPriceCalculator, PriceCalculator>();
// SCOPED โ per-request, often holds a DbContext or current-user info
builder.Services.AddScoped<IUserContext, HttpUserContext>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connStr)); // scoped by default
// TRANSIENT โ cheap, stateless, often a wrapper or lightweight helper
builder.Services.AddTransient<IEmailMessageBuilder, EmailMessageBuilder>();
// HOSTED SERVICE โ singleton background work
builder.Services.AddHostedService<OrderRetryWorker>();
var app = builder.Build();Step 2: Keyed services (.NET 8+)
// Multiple implementations of one contract, distinguished by a key
builder.Services.AddKeyedScoped<IPaymentProvider, StripeProvider>("stripe");
builder.Services.AddKeyedScoped<IPaymentProvider, PaypalProvider>("paypal");
// Resolve via constructor parameter
public class CheckoutService(
[FromKeyedServices("stripe")] IPaymentProvider stripe,
[FromKeyedServices("paypal")] IPaymentProvider paypal,
IUserContext user)
{
public Task ChargeAsync(Order o, CancellationToken ct) =>
user.PreferredProvider switch
{
"paypal" => paypal.ChargeAsync(o, ct),
_ => stripe.ChargeAsync(o, ct),
};
}
// Resolve via service locator (rare โ prefer constructor)
public class Worker(IServiceProvider sp)
{
public Task RunAsync(string key) =>
sp.GetRequiredKeyedService<IPaymentProvider>(key).ChargeAsync(/*...*/);
}
// Minimal API endpoint
app.MapPost("/charge/{provider}",
([FromKeyedServices("stripe")] IPaymentProvider stripe, Order o, CancellationToken ct)
=> stripe.ChargeAsync(o, ct));
// HttpClientFactory keyed registration (.NET 8+)
builder.Services.AddHttpClient("openai", c => c.BaseAddress = new Uri("https://api.openai.com"))
.AddAsKeyed();
// Then: [FromKeyedServices("openai")] HttpClient clientKeyed services replace 90% of the hand-written factory shims that wrapped the container with their own switch statements. One contract, many implementations, picked by key.
Step 3: BackgroundService and the scope pattern
// WRONG โ captive dependency. DbContext is scoped, BackgroundService is singleton.
public class BadWorker(AppDbContext db) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct) { /* ... */ }
}
// RIGHT โ inject IServiceScopeFactory, create a scope per iteration
public class OrderRetryWorker(IServiceScopeFactory scopeFactory, ILogger<OrderRetryWorker> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var orders = scope.ServiceProvider.GetRequiredService<IOrderService>();
try
{
var pending = await db.Orders.Where(o => o.Status == "Retry").ToListAsync(ct);
foreach (var o in pending) await orders.RetryAsync(o, ct);
}
catch (Exception ex)
{
log.LogError(ex, "Retry pass failed");
}
}
}
}Every iteration of a long-running worker that needs scoped services must create its own scope. Without it, the DbContext change tracker grows unbounded across iterations and you eventually OOM. With it, each tick is clean.
Step 4: Options pattern with DI
// Bind config section to a strongly-typed options class
public class StripeOptions
{
public required string ApiKey { get; init; }
public required string WebhookSecret { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
builder.Services
.AddOptions<StripeOptions>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart();
// Inject IOptions<T> (singleton snapshot), IOptionsSnapshot<T> (per-request, hot reload),
// or IOptionsMonitor<T> (live changes via callback)
public class StripeProvider(IOptions<StripeOptions> opts, HttpClient http)
{
private readonly StripeOptions _config = opts.Value;
}Pitfalls
1. Captive dependencies. Singleton holding scoped = bug. Scope validation in CreateApplicationBuildercatches this in development; make sure it's on. In production, validation is off by default for performance.
2. Service locator anti-pattern. Injecting IServiceProvider and calling GetService<T>everywhere hides dependencies and breaks testing. Use it only in framework-level glue (background workers, factories) โ never in domain or application services.
3. BuildServiceProvider in ConfigureServices. Calling services.BuildServiceProvider() before the host builds creates a second container โ the singletons you resolve from it are different from the ones the app uses. Analyzer ASP0000 warns; respect it.
4. IDisposable on transients. Transient services that implement IDisposable are disposed at the end of the scope they were resolved in (or never, if resolved from the root). For unbounded loops, this leaks. Either make them scoped or wrap them in using manually.
5. Constructor doing real work. DI calls your constructor every time the service resolves (transient/scoped). Constructors should assign fields and validate arguments โ nothing more. Move I/O and heavy init to InitializeAsync or a factory.
Practical Takeaways
- Default to scoped for stateful services, singleton for stateless ones, transient for cheap helpers. Justify any deviation.
- Use keyed services (.NET 8+) instead of hand-rolled factories when you need multiple implementations of one contract.
- In
BackgroundService, injectIServiceScopeFactoryand create a scope per work iteration. Never injectDbContextdirectly into a hosted service. - Bind configuration with
AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart(). Failures show up at startup, not at the first request. - Trust the container โ don't pass
IServiceProvideraround. If you find yourself doing it, your design has a missing abstraction. - Keep scope validation enabled in development (default in
WebApplication.CreateBuilder). Captive dependencies caught at startup beat 3am incident calls.

