Lesson 1 of 8 | Runtime, Dependency Injection, and Async Foundations
Dependency Injection Lifetimes and Captive Dependencies
Why lifetime reasoning matters
Dependency injection questions become senior-level when the interviewer stops asking for definitions and starts asking what can go wrong under load. A strong answer connects object lifetime to ownership, concurrency, disposal, and request boundaries.
The three built-in lifetimes
Transient creates a new instance whenever the container resolves the service. It fits lightweight, stateless collaborators. Transient does not mean thread-safe, and it does not automatically make an expensive dependency cheap.
Scoped creates one instance per scope. In ASP.NET Core the request pipeline creates one scope per request. Entity Framework Core DbContext is normally scoped because a unit of work should have one change tracker and one disposal boundary. Scoped is a convention, not a guarantee that work runs in an HTTP request: background services must create scopes explicitly.
Singleton creates one instance for the application container. A singleton can be called concurrently by many requests, so mutable state must be synchronized or avoided. A singleton should not hold request data, DbContext, HttpContext, or another scoped service.
The captive-dependency failure
A captive dependency exists when a longer-lived component captures a shorter-lived component. The classic case is a singleton constructor that accepts a scoped repository or DbContext. With scope validation enabled, the app fails during startup or resolution. Without validation, the scoped object may effectively live for the process lifetime, causing stale tracking state, cross-request data leakage, concurrency exceptions, and delayed disposal.
Incorrect:
services.AddScoped<AppDbContext>();
services.AddSingleton<InvoiceDispatcher>();
public sealed class InvoiceDispatcher
{
private readonly AppDbContext _db;
public InvoiceDispatcher(AppDbContext db) => _db = db;
}
The repair is not to change DbContext to singleton. Align ownership with the operation. If dispatching belongs to a request, make InvoiceDispatcher scoped. If it is hosted background work, inject IServiceScopeFactory and create a scope per message or batch.
public sealed class InvoiceWorker(
IServiceScopeFactory scopeFactory,
ILogger<InvoiceWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var invoiceId in ReadQueueAsync(stoppingToken))
{
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<InvoiceHandler>();
try
{
await handler.ProcessAsync(invoiceId, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Invoice {InvoiceId} failed", invoiceId);
}
}
}
}
The scope is created for one unit of work and disposed even on failure. The worker itself stays singleton, but it does not retain scoped objects between iterations.
Factories and owned dependencies
A factory is useful when each operation needs a fresh owned resource. IDbContextFactory<TContext> is a good fit for parallel operations, background processing, or UI circuits where a request scope does not model the unit of work. IHttpClientFactory manages handler pooling and DNS refresh; the HttpClient object itself can be short-lived. Avoid wrapping factories only to hide lifetime mistakes.
Options and state
IOptions<T> is singleton-like and reads the configured value. IOptionsSnapshot<T> is scoped and recomputes once per request. IOptionsMonitor<T> is singleton and supports change notifications. Capturing IOptionsSnapshot<T> in a singleton repeats the same lifetime error. Prefer IOptionsMonitor<T> when a singleton must observe reloadable configuration, and keep callbacks small and thread-safe.
Validation that catches problems early
In development and tests, enable container validation:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateOnBuild catches many missing registrations and invalid constructor graphs. ValidateScopes catches singleton-to-scoped capture. Neither proves business correctness, so add a composition-root test that builds the host and resolves critical entry points.
Disposal rules
The container disposes services it creates. Do not manually dispose an injected dependency. If code creates an object through new, a factory, or CreateAsyncScope, that code owns the matching disposal. Registering a pre-created IDisposable instance transfers less predictable ownership; prefer a factory registration so the container owns it.
Interview scenario: tenant-aware cache
Suppose a singleton cache accepts ITenantContext, which is scoped to the request. This is unsafe even if the cache uses ConcurrentDictionary because the captured tenant identity is the real bug. Pass the tenant key into each cache method, or store only tenant-neutral state. Concurrency safety and lifetime correctness are separate concerns.
Review checklist
- Identify the owner and expected lifetime of every dependency.
- Ask whether the component can run outside an HTTP request.
- Check whether mutable singleton state is thread-safe.
- Create and dispose scopes around background units of work.
- Never make
DbContextsingleton to silence scope validation. - Use factories when an operation needs explicit ownership.
- Turn on
ValidateScopesandValidateOnBuildin development and integration tests.
Interview answer template
Start with the rule: a service must not capture a dependency with a shorter lifetime. Explain the concrete failure, name the correct ownership boundary, show how disposal happens, and mention how you would test the composition root. That moves the answer from vocabulary to production engineering.