ASP.NET Core Web APIs · Minimal APIs Done Right
Dependency Injection Lifetimes and Captive Dependencies
Register services as singleton, scoped or transient, and avoid the captive dependency bug where a singleton holds a scoped service.
What you'll build
Three services with different lifetimes and an endpoint that reports which instances it received across two requests, plus the captive dependency mistake (a singleton holding a scoped service), which ASP.NET Core's scope validation catches at startup in development.
The idea
- Singleton: one instance for the app. Scoped: one per request. Transient: a new one every time it's injected.
- Database contexts and anything holding per-request state are scoped.
- A singleton that depends on a scoped service captures the first instance forever: it's shared across requests and threads, a source of data leaks and threading bugs.
ValidateScopes(on by default in Development) andValidateOnBuilddetect captive dependencies when the app starts.
The code
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<SingletonId>();
builder.Services.AddScoped<ScopedId>();
builder.Services.AddTransient<TransientId>();
var app = builder.Build();
app.MapGet("/ids", (SingletonId s, ScopedId a, ScopedId b, TransientId t1, TransientId t2) =>
$"singleton={s.Id} scoped={a.Id}/{b.Id} transient={t1.Id}/{t2.Id}");
app.Urls.Add("http://127.0.0.1:0");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
Console.WriteLine("request 1: " + await http.GetStringAsync("/ids"));
Console.WriteLine("request 2: " + await http.GetStringAsync("/ids"));
await app.StopAsync();
var bad = WebApplication.CreateBuilder();
bad.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });
bad.Services.AddScoped<ScopedId>();
bad.Services.AddSingleton<ReportCache>(); // singleton that needs a scoped service
try { bad.Build(); }
catch (AggregateException e) { Console.WriteLine("startup check: " + e.InnerExceptions[0].Message.Split(": ")[^1]); }
class Counter { static int next; public static int Next() => Interlocked.Increment(ref next); }
class SingletonId { public int Id { get; } = Counter.Next(); }
class ScopedId { public int Id { get; } = Counter.Next(); }
class TransientId { public int Id { get; } = Counter.Next(); }
class ReportCache(ScopedId scoped) { public int Id => scoped.Id; }
Output
request 1: singleton=1 scoped=2/2 transient=3/4 request 2: singleton=1 scoped=5/5 transient=6/7 startup check: Cannot consume scoped service 'ScopedId' from singleton 'ReportCache'.
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Registering
DbContextas a singleton (or injecting it into one): shared across requests, not thread-safe. - Disabling scope validation in production builds instead of fixing the registration.
- Resolving scoped services from the root provider in background services; create a scope (
IServiceScopeFactory.CreateScope()).
Key points
- Singleton per app, scoped per request, transient per injection.
- Never let a singleton depend on a scoped service.
- Turn on
ValidateOnBuildandValidateScopesto catch mistakes at startup.
Further reading: Dependency injection in ASP.NET Core · Service lifetimes
Practice questions
What is a captive dependency?
Answer
A service with a longer lifetime holding one with a shorter lifetime, such as a singleton that receives a scoped DbContext and keeps using that first instance across requests.
Which lifetime should a DbContext have?
Answer
Scoped (one per request), which AddDbContext registers by default; DbContext isn't thread-safe and must not be shared.
How does a background service use scoped services?
Answer
It injects IServiceScopeFactory and creates a scope for each unit of work, resolving scoped services from that scope.
Open in the app to mark lessons complete, take notes and track your progress.