What are the three DI lifetimes in ASP.NET Core, and what goes wrong when you inject a scoped service into a singleton?
ASP.NET Core DI lifetimes matter because a singleton that captures a scoped service turns per-request state into process-wide state. Captive dependencies cause stale data, threading bugs and disposed object failures.
What the interviewer is scoring
- Whether you can define a scope in terms of the request boundary rather than reciting the three keyword names
- Does the candidate explain the concrete failure mode of a captured DbContext, not just say it is shared
- That they know scope validation is a Development-only default and therefore not a production safety net
- Whether IServiceScopeFactory is reached for instinctively, with the scope disposed per unit of work
- Whether the candidate volunteers that transient-into-singleton is the same bug with no diagnostic
Answer
Short answer
Do not inject scoped services into singletons. Keep dependencies the same lifetime or shorter from the caller's perspective, and create explicit scopes only for background work that genuinely needs scoped services.
The three lifetimes
AddSingleton creates one instance for the whole application. It is built on first resolution (or immediately, if you registered an instance) and lives until the host shuts down, at which point the root provider disposes it. Every request, every background thread and every other service that asks for it gets the same object, so it must be thread-safe.
AddScoped creates one instance per scope, and hands the same instance to everything resolved within that scope. AddTransient creates a fresh instance at every resolution point, so a class with three transient dependencies of the same type gets three distinct objects.
The word that carries the weight is "scope". In ASP.NET Core a scope is not a vague notion of "the current context" — it is a concrete IServiceScope object that the framework creates when a request arrives and disposes when the response completes. Everything resolved during that request shares it, which is what makes scoped the natural lifetime for per-request state: a DbContext and its change tracker, the resolved tenant, a unit-of-work. Outside a request there is no ambient scope, which is why the same lifetime that is effortless inside a controller becomes a real design question inside a background worker.
Why scoped-into-singleton is a bug
Consider a singleton cache that wants to refresh itself from the database.
builder.Services.AddDbContext<OrdersContext>(...); // scoped by default
builder.Services.AddSingleton<OrderCache>();
public class OrderCache
{
private readonly OrdersContext _db; // captured forever
public OrderCache(OrdersContext db) => _db = db;
}
A dependency cannot be shorter-lived than the object holding it. Once OrderCache stores that reference, the OrdersContext is reachable for the life of the process, so its registered lifetime becomes a fiction: it was configured as scoped but behaves as a singleton. That is what "captive dependency" means — the longer-lived service has taken the shorter-lived one captive.
For DbContext the consequences are specific and unpleasant. It is documented as not thread-safe, so two concurrent requests touching the cache can overlap on the same context and produce InvalidOperationException: A second operation was started on this context instance before a previous operation completed. Under lighter concurrency you get worse-than-crashing behaviour instead: the change tracker never resets, so every entity ever loaded stays referenced and the process leaks memory that looks like a slow production creep. Queries return tracked instances from the first load rather than current rows, so the application serves stale data. And a failure that puts the context into a bad state has no request boundary to clean it up.
The framework tells you, but only in Development
Host.CreateApplicationBuilder and WebApplicationBuilder configure the container with ValidateScopes and ValidateOnBuild enabled when the environment is Development. Scope validation makes the container refuse to hand a scoped service to a singleton, throwing Cannot consume scoped service 'OrdersContext' from singleton 'OrderCache', and refuse to resolve a scoped service from the root provider at all. ValidateOnBuild walks the registrations at startup so you get the error when the app boots rather than on the first request that touches the offending path.
The important qualification is that these are defaults tied to IHostEnvironment, not invariants. In Staging or Production the checks are off, and the same graph silently constructs and misbehaves. Treat the Development exception as a build-time linter you happen to run locally, and never as a guarantee that the deployed process is safe.
Injecting the factory instead
The fix is to inject IServiceScopeFactory, which is itself a singleton and safe to hold, and to create a scope around each unit of work.
public class OrderCache(IServiceScopeFactory scopeFactory)
{
public async Task RefreshAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope(); // disposal ends the scope
var db = scope.ServiceProvider.GetRequiredService<OrdersContext>();
_snapshot = await db.Orders.AsNoTracking().ToListAsync(ct);
}
}
The scope, not the singleton, now owns the context, and disposing it disposes the context and releases the change tracker. Note the shape: a scope per operation, never one scope created in the constructor, which just reintroduces the original bug with extra ceremony. BackgroundService and any other IHostedService are the usual place this comes up, because hosted services are registered as singletons and run outside the request pipeline entirely. The related case worth knowing is middleware: a middleware class is instantiated once for the application, so scoped services belong in the InvokeAsync parameters, which the pipeline resolves from the request scope, rather than in the constructor.
Transient held by a singleton has no diagnostic
Scope validation says nothing about transients, because there is no rule being broken from the container's point of view. The lifetime promotion is identical, though: a transient stored in a singleton field is created once and lives forever, so a class you wrote assuming a fresh instance per use is now shared mutable state accessed concurrently.
There is a second effect that catches people out. The container tracks IDisposable instances it creates and disposes them when the owning scope ends. Transients resolved from the root provider are owned by the root, so their disposal is deferred to application shutdown. A singleton that resolves a disposable transient from the root provider on every call accumulates those instances for the process lifetime, which presents as an unbounded managed leak with no obvious culprit. This is the argument for keeping disposable services out of the transient lifetime unless you know who disposes them.
Scope validation only fails in Development, so a captive dependency that reaches Production does not throw — it turns into stale reads, concurrency exceptions and a memory graph that never shrinks.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Why must scoped services be injected into a middleware's InvokeAsync rather than its constructor?
- A BackgroundService needs a repository on every timer tick. How do you wire it?
- What does ValidateOnBuild change about when a lifetime mistake is reported?
- When is registering a service as transient rather than scoped a mistake in its own right?
Related questions
- Why does @Transactional sometimes do nothing at all, even though the annotation is right there?mediumAlso on dependency-injection4 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardAlso on dependency-injection6 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumAlso on dependency-injection5 min
- Messages arrive with a type field and each type needs a different object built to process it. How do you design the creation so you do not end up with a growing switch statement?mediumAlso on dependency-injection6 min