What are the three DI lifetimes in ASP.NET Core, and what goes wrong when you inject a scoped service into a singleton?
Singleton is one instance per application, scoped one per request, transient one per resolution. A scoped service injected into a singleton is captured for the life of the process, so a DbContext ends up shared across concurrent requests; scope validation catches it in Development, and IServiceScopeFactory is the fix.
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
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.
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-injection5 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on dependency-injection5 min
- A dashboard has been showing the same temperature for two days and nobody noticed. Walk the path and tell me what would have caught it.hardSame kind of round: scenario6 min
- Everyone in the business calls it the fax queue, and nothing has been faxed since 2014. Do you keep that name in the code?mediumSame kind of round: concept4 min
- How do you split a story that is too big to fit in a Sprint?mediumSame kind of round: concept3 min
- The business insists on a requirement that nobody can test — "the system must be intuitive". What do you write instead?mediumSame kind of round: concept4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min