📚 Technical ArticleJuly 28, 2026

Dependency Injection in .NET: Lifetimes, Captive Dependencies, and Other Traps

Understand .NET DI service lifetimes — singleton, scoped, transient — and avoid captive dependencies, memory leaks and threading bugs in ASP.NET Core.

5 min readSenior Level

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.csAddSingleton 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:

  1. Does it hold state that must not leak between requests or users? Then it must be scoped (or transient). DbContext is the canonical example — it tracks entities, so sharing it across requests is both a data-correctness and a thread-safety bug. This is why AddDbContext registers it scoped.
  2. 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 IDisposable types as transient if singletons consume them.
  • Never resolve services from the root IServiceProvider in 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

  • DbContext and 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

Found this helpful?

Last updated: July 28, 20265 min read
Senior Level Content
#csharp#dependency-injection#aspnet-core#architecture