DI Lifetimes Cheat Sheet: Transient, Scoped, Singleton and Captive Dependencies
Transient, Scoped and Singleton in one page: who may depend on whom, captive dependencies, scope validation, and the IServiceScopeFactory pattern for background work, with a runnable .NET 10 demo.
Learning outcome
By the end of this cheat sheet you should be able to pick the right lifetime for a service, explain what a captive dependency is and why it is a bug, and show how a singleton or background service gets scoped work done safely with IServiceScopeFactory. These are standard questions in ASP.NET Core interviews because lifetime bugs compile cleanly and fail later, under load or across requests.
Intuition
A lifetime answers one question: how long does the container keep handing out the same instance?
- Transient: a new instance every time it is resolved.
- Scoped: one instance per scope. In ASP.NET Core, a scope is one HTTP request.
- Singleton: one instance for the life of the root container (the app).
The golden rule: do not capture a dependency beyond the lifetime and sharing contract it was designed for. A singleton may capture a transient designed for that use. A singleton that holds a scoped DbContext keeps that one context alive for the whole app. It is "captured".
Quick reference: the three lifetimes
| Lifetime | Registration | Instance per | Typical use | Watch out for |
|---|---|---|---|---|
| Transient | AddTransient<IFoo, Foo>() | Every resolve | Lightweight, stateless services | Disposable transients resolved from the root are kept until the root is disposed |
| Scoped | AddScoped<IFoo, Foo>() | Scope (one HTTP request in ASP.NET Core) | DbContext, unit of work, per-request caches | Resolving from the root provider; using one across threads |
| Singleton | AddSingleton<IFoo, Foo>() | Root container | Config snapshots, caches, HttpClient handlers, clocks | Must be thread-safe; must not capture scoped or transient state |
AddDbContext<T>() registers the context as scoped by default.
Who may depend on whom
| Consumer ↓ / Dependency → | Transient | Scoped | Singleton |
|---|---|---|---|
| Transient | OK | OK (lives as long as the scope) | OK |
| Scoped | OK | OK | OK |
| Singleton | Allowed, but the transient becomes effectively a singleton | Captive dependency: rejected by scope validation | OK |
The container only validates singleton → scoped. A singleton that captures a transient is not reported, but the transient now lives as long as the singleton. If that transient was not designed to be shared or thread-safe, that is a bug too.
Scope validation (how the bug is caught)
ServiceProviderOptions.ValidateScopes = true: throws when a scoped service is resolved from the root provider, or when a singleton depends on a scoped service.ServiceProviderOptions.ValidateOnBuild = true: validates constructible registration graphs at build time. WithValidateScopes = true, detectable captive dependencies fail at startup. Open generics are excluded, and constructors/factory bodies are not executed to prove all runtime behavior.- With
WebApplication.CreateBuilder/Host.CreateApplicationBuilder, both are turned on when the environment is Development. They are off by default in Production, so a captive dependency can slip through if you never run in Development.
The fix: create a scope per unit of work
Singletons, BackgroundServices and timer callbacks have no request scope. Inject IServiceScopeFactory (a singleton) and create a scope for each unit of work:
public sealed class OutboxWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using (var scope = scopeFactory.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ... one batch of work with a fresh DbContext ...
await db.SaveChangesAsync(stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}- One scope per message or batch, not one scope for the whole worker lifetime (that recreates the captive problem).
- Prefer
CreateAsyncScope()withawait usingwhen a scoped service implementsIAsyncDisposable, asDbContextdoes. - Don't share one scope's services across parallel tasks.
DbContextis not thread-safe.
Other facts interviewers like
- Disposal: the container disposes the
IDisposable/IAsyncDisposableinstances it creates, when their scope (or the root) is disposed. It does not dispose instances you construct yourself and register withAddSingleton(new Foo()). - Last registration wins for
GetService<IFoo>().GetServices<IFoo>()returns all of them in registration order.TryAddand its lifetime helpers only register when the same service type and key are absent;TryAddEnumerablealso considers implementation type. - Keyed services (.NET 8+):
AddKeyedScoped<IFoo, Foo>("blue"), then inject with[FromKeyedServices("blue")]. - Options:
IOptions<T>is a singleton snapshot,IOptionsSnapshot<T>is scoped (re-read per request), andIOptionsMonitor<T>is a singleton with change notifications. InjectingIOptionsSnapshot<T>into a singleton is a captive dependency. - Middleware: conventional middleware is constructed once (effectively a singleton). Inject scoped services into
InvokeAsync(HttpContext, IMyScoped)instead of the constructor.IMiddlewareimplementations are resolved per request with their registered lifetime. HttpClient: useIHttpClientFactory/ typed clients. A typed client is registered as transient, and capturing it in a singleton keeps its handler beyond the rotation the factory intends.
Failure modes
- A singleton cache takes
AppDbContextin its constructor. Every request shares one context: concurrency exceptions, stale data, unbounded change-tracker growth. app.Services.GetRequiredService<AppDbContext>()at startup: resolving scoped from the root. Useusing var scope = app.Services.CreateScope();instead.- A
BackgroundServicecreates one scope inExecuteAsyncand loops forever inside it. That is still a captured context. - Unawaited
Task.Runwork that captures request-scoped services can outlive the request and access them after disposal (ObjectDisposedException). IServiceProvideris injected everywhere and used as a service locator. Dependencies become hidden and validation can't see them.
Interview drill
Answer each in one or two sentences:
- What is a captive dependency, and which one does the built-in container detect?
- Why does a captive dependency usually work in a demo and then fail in production?
- How do you use a
DbContextfrom aBackgroundService? - What does
ValidateOnBuildadd on top ofValidateScopes? - Which options interface is safe to inject into a singleton when you want reloadable config?
- Who disposes a service, and when?
Model answer for 1: a longer-lived service holding a shorter-lived one, so the shorter-lived one is kept alive beyond its intended scope. The built-in container rejects singleton → scoped when scope validation is on; it does not flag singleton → transient.
Revision checklist
- Can I state the three lifetimes in one line each?
- Can I draw the "who may depend on whom" table from memory?
- Do I know scope validation is on by default only in Development?
- Can I write the
IServiceScopeFactory+CreateAsyncScopepattern without notes? - Can I explain why middleware constructors must not take scoped services?
Worked executable example
The program shows the three lifetimes, a rejected root resolve, a captive dependency failing at build time, and the IServiceScopeFactory fix. Each DoWork call gets its own scoped instance.
Run this example
Use the .NET 10 SDK. In a new empty folder, save the project below as Example.csproj and the listing as Program.cs. Run dotnet run --project Example.csproj.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
</Project>Program.cs
using System;
using Microsoft.Extensions.DependencyInjection;
var options = new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true };
// 1) Lifetimes: same instance or a new one?
var services = new ServiceCollection();
services.AddTransient<TransientDep>();
services.AddScoped<ScopedDep>();
services.AddSingleton<SingletonDep>();
using var root = services.BuildServiceProvider(options);
using (var scopeA = root.CreateScope())
using (var scopeB = root.CreateScope())
{
var a = scopeA.ServiceProvider;
var b = scopeB.ServiceProvider;
Console.WriteLine($"Transient same in scope A: {ReferenceEquals(a.GetRequiredService<TransientDep>(), a.GetRequiredService<TransientDep>())}");
Console.WriteLine($"Scoped same in scope A: {ReferenceEquals(a.GetRequiredService<ScopedDep>(), a.GetRequiredService<ScopedDep>())}");
Console.WriteLine($"Scoped same across A/B: {ReferenceEquals(a.GetRequiredService<ScopedDep>(), b.GetRequiredService<ScopedDep>())}");
Console.WriteLine($"Singleton same across A/B: {ReferenceEquals(a.GetRequiredService<SingletonDep>(), b.GetRequiredService<SingletonDep>())}");
}
// 2) Resolving a scoped service from the root provider is rejected by scope validation.
try { root.GetRequiredService<ScopedDep>(); }
catch (InvalidOperationException) { Console.WriteLine("Root resolve of scoped: InvalidOperationException"); }
// 3) Captive dependency: singleton -> scoped is rejected when the provider is built.
var bad = new ServiceCollection();
bad.AddScoped<ScopedDep>();
bad.AddSingleton<CaptiveSingleton>();
try { bad.BuildServiceProvider(options); }
catch (AggregateException ex) { Console.WriteLine($"Captive dependency at build: {ex.InnerExceptions[0].GetType().Name}"); }
// 4) The fix: the singleton creates a short-lived scope per unit of work.
var good = new ServiceCollection();
good.AddScoped<ScopedDep>();
good.AddSingleton<Worker>();
using var goodRoot = good.BuildServiceProvider(options);
var worker = goodRoot.GetRequiredService<Worker>();
Console.WriteLine($"Two work items share a ScopedDep: {worker.DoWork() == worker.DoWork()}");
sealed class TransientDep { }
sealed class ScopedDep { public Guid Id { get; } = Guid.NewGuid(); }
sealed class SingletonDep { }
sealed class CaptiveSingleton(ScopedDep dep) { public ScopedDep Dep { get; } = dep; }
sealed class Worker(IServiceScopeFactory scopeFactory)
{
public Guid DoWork()
{
using var scope = scopeFactory.CreateScope();
return scope.ServiceProvider.GetRequiredService<ScopedDep>().Id;
}
}Expected output
Transient same in scope A: False Scoped same in scope A: True Scoped same across A/B: False Singleton same across A/B: True Root resolve of scoped: InvalidOperationException Captive dependency at build: InvalidOperationException Two work items share a ScopedDep: False
Related on TechInterviewNotes
- Dependency Injection Lifetimes and Captive Dependencies (Production-Grade .NET Backend Interview Path, lesson 1)
- SOLID with Composition, Dependency Injection, and Honest Boundaries (Learn C# and .NET 10, lesson 12)
- How an ASP.NET Core Request Actually Flows
Sources
Microsoft Learn: Dependency injection in ASP.NET Core
Microsoft Learn: Service lifetimes (.NET dependency injection)
Microsoft Learn: Dependency injection guidelines (captive dependency, scope validation, disposal)
Microsoft Learn: Use scoped services within a BackgroundService