cswork / libraryOpen practice workspace

Articles

.NET Dependency Injection Interview Questions: Captive Dependencies, Scopes, and Disposal

Trace .NET dependency injection lifetimes through a hosted worker, captive DbContext, per-job scopes, disposal failures, and scope-validation tests.

A dependency injection graph can compile, resolve, and still be wrong. The most revealing .NET interview exercise places a scoped database context inside a long-lived service, then asks why one request's state appears in another or why a disposable object remains alive too long. The answer has to follow instance ownership and scope boundaries, not just recite “singleton, scoped, transient.”

Official platform facts: Microsoft documents service lifetimes, scope validation, hosted-service scoping, and container disposal. Candidate reports: no company-specific interview sequence is claimed here. Original exercise and inference: the worker and request trace below are invented for practice; whether a particular container configuration throws is checked against its validation settings and environment.

What is the lifetime of the object, not just its registration?

Microsoft's service-lifetime documentation distinguishes transient, scoped, and singleton registrations. A transient is created when requested; a scoped service is one instance per scope; a singleton is one instance for the root provider's lifetime. In an ASP.NET Core request, a scope is normally created for that request. A background worker is not itself a request and does not inherit a magical request scope.

The word “transient” does not promise an object disappears immediately after a method call. If a singleton constructor receives a transient dependency, that particular transient instance remains referenced by the singleton. If a disposable transient is resolved from the root container, the container may retain it for disposal until root shutdown. You must trace who owns the object and which scope created it. Registration labels are necessary but insufficient.

Think of a request handler with a scoped DbContext. Two resolutions within the same request scope can refer to the same context; a different request scope receives a different instance. If a singleton service captures that context in its constructor, the instance can outlive the intended request and carry tracked state across operations. Microsoft's DI guidelines call this a captive dependency. The problem is an object graph's effective lifetime, not merely a syntactic AddSingleton line.

An interviewer may ask, “Why does it work locally?” Scope validation often catches singleton-to-scoped capture in development, but validation can be configured differently. When disabled, resolution from the root can promote a scoped dependency's effective lifetime to root lifetime. Do not assert that every build will throw automatically. State the observed configuration, enable scope validation in a test or development environment, and verify behavior.

Trace a worker that leaks scoped state

Original practice scenario: InvoiceWorker is registered as a hosted service and processes one customer invoice every minute. InvoiceRepository is scoped and depends on a scoped EF Core DbContext. The worker constructor takes InvoiceRepository and stores it. The first job records customer A; the second job handles customer B. The symptom is an unexpected tracked entity or a disposed-context exception after an attempted manual cleanup.

The lifetime graph explains the risk: hosted service → long-lived worker → captured scoped repository → captured context. Even if the repository is declared scoped, its instance is held as long as the worker. Manually calling Dispose on the repository or context while the worker retains it creates a different bug: the next iteration may use a disposed object. A new context per operation scope is the design to test.

The corrected shape injects IServiceScopeFactory into the worker, creates a scope inside each operation, resolves the repository from that scope's ServiceProvider, awaits the work, and disposes the scope when the operation ends. Microsoft's hosted-service guidance uses this pattern. If the resolved service requires asynchronous disposal, use an appropriate async scope and await its disposal according to the APIs in the deployed runtime. A local using or await using makes ownership visible.

public sealed class InvoiceWorker(IServiceScopeFactory scopes) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var scope = scopes.CreateAsyncScope();
            var job = scope.ServiceProvider.GetRequiredService<InvoiceJob>();
            await job.ProcessNextAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

This snippet is a teaching sketch, not production scheduling code. One subtle issue: the delay is inside the await using scope, so the scoped dependencies remain alive through the wait. In a code review, move the delay after the scope has ended. The mistake is intentional: an interview-ready candidate should catch it rather than assume CreateAsyncScope alone makes the lifetime correct. A safer version puts work in a separate method whose scope ends before waiting, or braces the scope inside the loop before Task.Delay.

For example, the corrected loop can use { await using var scope = scopes.CreateAsyncScope(); var job = scope.ServiceProvider.GetRequiredService<InvoiceJob>(); await job.ProcessNextAsync(stoppingToken); } and then call await Task.Delay(...) after that block. The braces make the disposal boundary explicit. In real worker code, also decide what a failed job means: should the exception be logged and the worker continue, retried by a queue with an idempotency key, or allowed to stop the host? Scope disposal runs on exit, but it is not itself an error-recovery policy.

Resolution pointExpected effective lifetimeInterview check
Request scope resolves scoped repositoryOne instance in that requestNo cross-request state
Singleton constructor captures scoped repositoryPotentially root-length capture or validation errorEnable scope validation
Worker creates a scope per jobOne instance per job scopeScope ends after awaited job
Disposable transient resolved from rootMay be held until root disposalAvoid root resolution for short-lived work

Where exactly does disposal happen?

The container disposes objects it creates when their owning scope ends: scoped and disposable transient instances at scope end, singletons at root provider disposal. The guidelines explicitly warn against code disposing a service merely because it received that service through DI. If the container created it, disposing it manually can break another consumer or cause a second disposal path. If your code constructs an object itself and passes it as an existing instance in registration, ownership can differ; document that choice.

An HTTP response finishing usually ends its request scope. A worker operation ends only when the explicit scope is disposed. A long-running stream may intentionally keep a request scope longer than an ordinary request, so do not assume “request” equals a fixed number of milliseconds. In a nested service graph, scopes are not hierarchical child scopes whose instances automatically flow from the parent. A new scope resolves new scoped services.

For a class that is transient but implements IDisposable, consider whether it really belongs in the container if it must be released before its scope ends. Microsoft's guidance recommends a factory pattern for truly short-lived disposables. A using around a container-resolved instance is not a reliable substitute because the container still owns its disposal. Also distinguish IAsyncDisposable resources, where cleanup may require awaiting I/O; the sync and async scope APIs are not interchangeable if cleanup is asynchronous.

Can a singleton use a scoped service safely?

Yes, if it creates a scope for a bounded operation, resolves the service inside that scope, and does not store the resolved instance after scope disposal. A singleton's field can safely hold IServiceScopeFactory; it should not hold an InvoiceRepository taken from a temporary scope. Returning an IQueryable, lazy enumerable, or callback that closes over the scoped context after the scope ends is a more subtle escape. The visible method may have returned before query execution. Materialize results or restructure ownership so the consuming operation stays inside the scope.

Likewise, injecting a singleton into a scoped service is generally allowed, but the singleton must be thread-safe if multiple requests use it concurrently. The container's internal resolution can be thread-safe while your singleton's mutable dictionary is not. Do not call it safe merely because AddSingleton succeeded. A cache, counter, or mutable configuration snapshot requires its own concurrency policy.

Sometimes a factory, IDbContextFactory, or separate command object is a better fit than creating scopes inside application code. The point of the interview answer is not to promote service-locator calls everywhere. Constructor injection keeps ordinary dependencies explicit; a scope factory is justified at boundaries such as hosted services where a long-lived component must initiate short-lived work.

Ask one more lifetime question about concurrency. If a worker launches several jobs at once, each job should own its own scope. Sharing a single scoped DbContext across concurrent jobs risks both cross-job state and unsupported concurrent use. A loop that creates a scope and then starts a fire-and-forget task may dispose the scope before that task finishes. Await or explicitly manage every operation until the scope's work has completed. This race is a different failure from singleton capture, but the same ownership diagram reveals it.

How would you test the lifetime boundary?

Build a minimal host with ValidateScopes and, where appropriate, build validation enabled. Assert that resolving the bad singleton graph fails in the intended configuration. Then test two separate scopes: within one scope a scoped service resolves to the same instance; across scopes it resolves to different instances. Instrument creation and disposal with IDs in a fake service so the test checks when disposal occurs, not only whether a method returned.

For the worker, process two jobs with distinct customer IDs and confirm each job receives a fresh repository/context scope. Add a test that throws during job processing; the scope must still dispose. Add cancellation during an awaited operation; cleanup should still run, and cancellation should not be swallowed as success. These tests are more valuable than a single unit test asserting that GetRequiredService returns non-null.

In a production incident, look for object retention, tracked entities crossing tenants, unexpected root-scope resolution, and shutdown-only disposal. Examine registration code and constructor chains, not just the top-level service. A memory graph or logs of instance IDs can reveal that a supposedly scoped service was retained by a singleton. Fix the ownership boundary, then monitor whether memory and cross-request errors change.

An answer that survives follow-up questions

“I would draw the dependency graph and mark the scope that creates each object. A singleton must not capture a scoped repository or DbContext; validation can catch this, but the result depends on configuration. For a hosted service, I would create and dispose an operation scope, resolve scoped work inside it, await completion, and ensure no lazy result escapes that scope. I would let the container dispose what it owns, and test instance IDs and cleanup across two jobs and a failure.”

If an interviewer asks why the sample worker still keeps resources alive too long, point to the Task.Delay before scope disposal and move it outside the scoped block. That concrete observation demonstrates more than memorizing lifetime names. The same habit helps when reviewing a Hand-Write a Bounded Cache for a Database-Backed Backend Service: state that lives globally must not accidentally drag a request-bound context with it.

Sources and further reading