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.
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.
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.
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.
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 throwsInvalidOperationException: 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 contextunder 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.
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.
Gotchas Worth Knowing
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."
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).
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.
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. |