ASP.NET Core DI: Service Lifetimes Guide

Every ASP.NET Core app is built on the dependency injection container, and every DI bug I've ever chased came down to a lifetime mismatch. This guide explains singleton, scoped, and transient properly, then walks through the captive dependency trap that silently breaks DbContexts — and the right way to use scoped services from background work.

1

What the Container Does

ASP.NET Core ships with a built-in dependency injection container. You register each service once at startup — an interface, its implementation, and a lifetime — and from then on you never write new for it again. Anything the framework creates (controllers, Razor Pages, minimal API handlers, middleware, hosted services) declares what it needs as constructor parameters, and the container supplies them, along with everything they need, recursively.

The lifetime is the part that bites. It answers one question: when does the container hand out a new instance versus reuse an existing one? Get it wrong and nothing fails at compile time — the app just quietly shares state it shouldn't, or holds onto things it should have thrown away.

2

Registering Services

Registration happens in Program.cs, before builder.Build(). The three core methods differ only in the lifetime they assign:

var builder = WebApplication.CreateBuilder(args);

// One instance for the whole application lifetime.
builder.Services.AddSingleton<IClock, SystemClock>();

// One instance per HTTP request (per "scope").
builder.Services.AddScoped<IOrderService, OrderService>();

// A fresh instance every single time it's asked for.
builder.Services.AddTransient<IEmailBuilder, EmailBuilder>();

// AddDbContext registers the context as SCOPED — one DbContext
// per request. That default is the setup for the trap in Section 4.
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

Consuming a service is just a constructor parameter — no attributes, no service-locator calls. If the container can't satisfy a parameter it throws at startup (or first resolution), naming the exact type it couldn't build, which makes missing registrations one of the easier DI errors to fix.

3

The Three Lifetimes

A scope in a web app is almost always an HTTP request: ASP.NET Core creates one when a request arrives and disposes it when the response finishes. With that in mind:

Lifetime Instance created Best for
Singleton Once, on first use; lives until the app shuts down. Must be thread-safe — every request shares it concurrently. Stateless services, configuration, caches, HttpClient factories, anything expensive to build.
Scoped Once per request; disposed with the request. The same instance is shared by everything in that request. Per-request state: DbContext, units of work, anything that carries the current user or transaction.
Transient Every time it's resolved — two constructor parameters of the same type get two instances. Lightweight, stateless helpers where sharing would be a surprise rather than a feature.

A useful default posture: scoped for anything that touches a database or the current request, singleton for stateless machinery, transient when in doubt about state. The expensive mistake isn't picking transient where scoped would do — it's the direction the next section covers.

4

The Captive Dependency Trap

A captive dependency is a shorter-lived service injected into a longer-lived one. The classic case: a scoped DbContext injected into a singleton. The singleton is built once, so the "per-request" context it captured is created once too — and then held captive for the life of the application.

// Registered as a singleton…
builder.Services.AddSingleton<ReportCache>();

public class ReportCache
{
    private readonly AppDbContext _db;   // …capturing a SCOPED service. Bug.

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

Nothing about this fails to compile, and depending on the environment it may not even fail to run:

  • In Development, the default host enables ValidateScopes, so the first resolution throws InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'ReportCache'. This is the good outcome — it names the exact pair.
  • In Production, scope validation is off by default for performance, so the container quietly resolves the context from the root scope. Now one DbContext — a class that is not thread-safe and expects a short life — serves every request concurrently. The symptoms are the nasty, intermittent kind: InvalidOperationException: A second operation was started on this context under load, stale tracked entities, and memory that grows with every entity the change tracker ever saw.
// Worth turning on everywhere unless the startup cost is measured and matters:
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;   // catch captive scoped services at resolution
    options.ValidateOnBuild = true;  // catch missing registrations at startup
});

The rule that prevents the whole category: a service may only depend on services with a lifetime equal to or longer than its own. Singletons take singletons; scoped can take scoped and singletons; transient can take anything.

5

Doing It Right: Scopes in Background Work

The trap usually appears because a singleton legitimately needs database access — a BackgroundService is the everyday example (hosted services are singletons by nature). The fix isn't to make the context a singleton; it's to create your own scope for each unit of work, exactly like ASP.NET Core does for each request:

public class OrderSweeper : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;   // singleton-safe

    public OrderSweeper(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // One scope per sweep = one fresh DbContext per sweep,
            // disposed deterministically at the end of the using block.
            await using (var scope = _scopeFactory.CreateAsyncScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                await db.Orders
                        .Where(o => o.Status == OrderStatus.Stale)
                        .ExecuteDeleteAsync(stoppingToken);
            }

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

IServiceScopeFactory is itself a singleton, so injecting it into a singleton is always legal. The pattern generalizes to anything long-lived that periodically needs short-lived services: message-queue consumers, timers, SignalR hub helpers. Scope per unit of work, resolve inside the scope, let disposal clean up.

6

Gotchas Worth Knowing

The container disposes what it creates — at the lifetime's end

Services implementing IDisposable are disposed automatically when their lifetime ends: scoped at the end of the request, singletons at shutdown. The sharp edge is transient disposables resolved from the root provider (e.g. inside a singleton): the root holds a reference to dispose them later, so each resolution accumulates until shutdown — a slow leak that looks like "memory creeps up over days."

Multiple registrations are additive
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>();

// Injecting INotifier            -> SmsNotifier (last registration wins)
// Injecting IEnumerable<INotifier> -> BOTH, in registration order
// TryAddScoped<INotifier, …>()   -> only registers if none exists yet

Library authors should prefer TryAdd* so the host app's own registration wins; app authors should know IEnumerable<T> is how you consume "all registered implementations" (validators, pipeline steps, rule sets).

Middleware constructors are singletons in disguise

Conventional middleware is constructed once, so injecting a scoped service into its constructor is the captive trap again. Inject scoped services as parameters of the Invoke/InvokeAsync method instead — that resolves them from the current request's scope, fresh each time.

7

Reference

API Takeaway
AddSingleton / AddScoped / AddTransient App lifetime / request lifetime / new every resolution. Depend only on equal-or-longer lifetimes.
AddDbContext<T> Registers the context as scoped. Never let a singleton capture it.
IServiceScopeFactory Singleton-safe gateway to scoped services: CreateAsyncScope() per unit of work, resolve inside, dispose via await using.
ValidateScopes / ValidateOnBuild Turns captive dependencies and missing registrations into loud startup errors. On by default only in Development.
TryAddScoped / TryAddSingleton Registers only when the service isn't registered yet — the polite default for libraries.
IEnumerable<T> Injects every registration of T, in registration order.
GetRequiredService<T> Resolves or throws with the missing type's name — prefer it over GetService<T> + null check.