Dependency Injection in .NET: Lifetimes, Captive Dependencies, and Other Traps
The built-in dependency injection container in .NET is one of those features that feels finished after a ten-minute tutorial: register your services, inject them through constructors, done. Then six months later you're staring at a bug where one user sees another user's data, and the root cause is a single wrong word in Program.cs — AddSingleton where you meant AddScoped.
Let's build a real mental model of lifetimes, then walk through the traps I've actually seen bite teams in production.
The three lifetimes, precisely
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailBuilder, EmailBuilder>();
- Singleton — one instance for the lifetime of the application. Every consumer, every request, every thread shares it.
- Scoped — one instance per scope. In ASP.NET Core, a scope is created for each HTTP request, so "scoped" effectively means "per request."
- Transient — a new instance every single time the service is resolved, even twice within the same constructor graph.
The intuition I give juniors: singleton is application state, scoped is request state, transient is no state worth sharing.
How to choose
Ask two questions about the class:
- Does it hold state that must not leak between requests or users? Then it must be scoped (or transient).
DbContextis the canonical example — it tracks entities, so sharing it across requests is both a data-correctness and a thread-safety bug. This is whyAddDbContextregisters it scoped. - Is it stateless or safely shareable? Configuration readers, HTTP client wrappers, clocks, serializers — make them singletons and save the allocation churn.
When in doubt between scoped and transient, prefer scoped: it's cheaper (one instance per request instead of many) and the per-request boundary is usually the semantic you actually want.
Trap 1: The captive dependency
This is the big one. A captive dependency happens when a service with a long lifetime injects a service with a shorter one:
// ❌ Scoped service injected into a singleton
builder.Services.AddSingleton<ReportCache>();
public class ReportCache
{
private readonly AppDbContext _db; // scoped!
public ReportCache(AppDbContext db) => _db = db;
}
The singleton is constructed once, capturing a single DbContext instance and holding it forever. Now every request shares one DbContext: stale tracked entities, cross-request data bleed, and DbContext isn't thread-safe, so concurrent requests corrupt its internal state.
ASP.NET Core's development-mode scope validation catches direct cases like this and throws on startup. But it can't catch the lazy variant, which is why you should know the correct pattern — inject IServiceScopeFactory and create a scope per operation:
// ✅ Singleton that uses scoped services correctly
public class ReportCache
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportCache(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task RefreshAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// use db, dispose scope when done
}
}
The same pattern applies inside BackgroundService implementations, which are singletons by nature and one of the most common places I see captive DbContexts.
Trap 2: Disposable transients that live too long
The container disposes services it created — but when it disposes them depends on the scope they were resolved from. A transient IDisposable resolved from the root provider (for example, injected into a singleton) is tracked by the root scope and won't be disposed until the application shuts down. Resolve enough of them and you've built a slow memory leak.
Rules that keep you safe:
- Don't register
IDisposabletypes as transient if singletons consume them. - Never resolve services from the root
IServiceProviderin a loop; create a scope.
Trap 3: Multiple registrations and the "last one wins" surprise
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>();
Injecting INotifier gives you SmsNotifier — the last registration wins. Injecting IEnumerable<INotifier> gives you both. This is actually a feature (it's how you build plugin-style pipelines), but it surprises people during refactors when a stray duplicate registration silently swaps an implementation. Use TryAddScoped in library/extension code so you never accidentally override an application's registration.
Trap 4: Constructor over-injection is a design smell, not a DI problem
If a class needs seven dependencies, the container will happily wire them up — DI never punishes you for a class doing too much. I use a soft limit of four constructor parameters; beyond that, the class usually wants splitting, or a group of parameters wants to become its own service. Fixing this at review time is much cheaper than living with a 900-line "service" class for years.
Trap 5: Service locator in disguise
// ❌ Hides dependencies, defeats the container's checks
public class OrderService
{
public OrderService(IServiceProvider provider)
{
_payments = provider.GetRequiredService<IPaymentGateway>();
}
}
Injecting IServiceProvider and resolving manually turns compile-time-visible dependencies into runtime surprises and makes the class a pain to unit test. The legitimate exceptions are the scope-factory pattern from Trap 1 and true plugin factories. Everywhere else, ask for what you need in the constructor.
A registration review checklist
DbContextand anything holding per-request state: scoped.- Stateless helpers, clients, configuration: singleton.
IDisposable+ short-lived: resolve from a scope you control.- No scoped/transient services injected into singletons — use
IServiceScopeFactory. - Library code registers with
TryAdd*. - Constructors with more than ~4 dependencies get a design conversation, not a rubber stamp.
Lifetimes are one of the rare topics where a small amount of theory prevents entire categories of production incidents. Learn the model once, and wrong-lifetime bugs stop happening to you.
Where to go next
- Fixing the N+1 Query Problem in Entity Framework Core — the most common DbContext performance trap.
- Background Jobs in .NET — where the scope-factory pattern becomes essential.
- Clean Architecture in .NET Without the Overengineering — structuring services so DI stays simple.