AddSingleton vs AddScoped vs AddTransient: what each promises
Every .NET codebase has a Program.cs with a wall of registrations, and everyone who has worked
in one has played the same quiet game: type builder.Services.Add, hesitate, and pick the
lifetime that feels right. The new service gets AddScoped because the one above it is scoped.
The helper gets AddSingleton because it looks stateless. It mostly works, which is exactly the
problem. The wrong choice does not fail your build or your tests. It waits for production
traffic, then surfaces as a bug that refuses to reproduce on your machine.
The confusion comes from how lifetimes are usually explained: as trivia about how long objects live. That framing makes the three methods sound interchangeable, like small, medium, and large. They are not sizes. A lifetime is a contract about shared state. It decides who else is holding the same instance as you, right now, and on how many threads.
What each lifetime actually promises
Singleton
one instance, every request, every thread
holds ShopContext (scoped) ✗ captive
Request A
Scoped
one per request, disposed with it
transient × 3 injection sites
Request B
Scoped
one per request, disposed with it
transient × 3 injection sites
AddSingleton promises one instance per container. The first resolution creates it, every later
resolution returns the same object, and it lives until the application shuts down. Every request
and every thread shares it simultaneously.
AddScoped promises one instance per scope. ASP.NET Core creates a scope for each HTTP request,
so within a single request, every constructor that asks for the service receives the same
instance, while a concurrent request receives a different one. When the request ends, the scope
is disposed and the instance goes with it.
AddTransient promises a new instance for every resolution. If two constructors in the same
request each ask for the service, they get two different objects. Nothing is shared, ever.
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<ICheckoutService, CheckoutService>();
builder.Services.AddTransient<IReceiptFormatter, ReceiptFormatter>();
// AddDbContext registers the context as scoped by default
builder.Services.AddDbContext<ShopContext>(options =>
options.UseNpgsql(connectionString));
Each promise carries an obligation, and the obligations matter more than the promises. A
singleton is shared across threads, so it must be thread-safe and must never hold per-user state.
A scoped service can hold request state freely, which is why DbContext lives here: it is a unit
of work with a change tracker, and it is explicitly not safe for concurrent use. A transient can
hold whatever it wants because nobody else will ever see it. Its price is an allocation per
resolution, plus one quiet gotcha: a transient that implements IDisposable and gets resolved
from the root provider is tracked by the container until shutdown, which looks exactly like a
memory leak when you finally profile it.
Every lifetime answers one question: who else is holding this instance right now? Singleton answers everyone, on every thread. Scoped answers everyone in this request. Transient answers nobody.
Sensible defaults
Once you read lifetimes as sharing contracts, the defaults mostly pick themselves. Stateless and thread-safe wants to be a singleton: one allocation, no churn, nothing to corrupt. Anything that holds a unit of work or per-request identity wants to be scoped. Small stateful helpers want to be transient, and the allocation cost of that is almost never worth worrying about before you have measured it.
When you genuinely do not know, pick the narrowest lifetime that works and widen it only when a measurement tells you to. Narrow lifetimes fail loudly, as visible allocation churn. Wide ones fail quietly, as state bleeding between users. Loud failures are a gift. It also helps to depend on abstractions at your seams, the case we make in the dependency inversion article, because an interface with a documented lifetime is a sharing contract you can read, not one you have to excavate from the implementation.
The captive dependency
Here is the bug the casual default buys you. A singleton that takes a scoped service through its constructor:
public sealed class PriceCache
{
private readonly ShopContext _db;
public PriceCache(ShopContext db) => _db = db;
public async Task<decimal> GetPriceAsync(int productId) =>
await _db.Products
.Where(p => p.Id == productId)
.Select(p => p.Price)
.SingleAsync();
}
// the bug is this line
builder.Services.AddSingleton<PriceCache>();
The container builds PriceCache exactly once. To do that it needs a ShopContext, so it
resolves one, and that context is now referenced by a singleton. Its scoped lifetime is void. It
will never again be disposed at the end of a request, because it no longer belongs to a request.
The scoped service has been captured, which is why
Mark Seemann named this a captive dependency.
On your machine, nothing happens. Requests arrive one at a time, each one finds the cache
working, and the feature ships. In production, two requests call GetPriceAsync in the same
instant, both operate on the same DbContext, and EF Core throws:
System.InvalidOperationException: A second operation was started on this context
instance before a previous operation completed. This is usually caused by
different threads concurrently using the same instance of DbContext.
That is the loud version, and it is the lucky one. The quiet versions are worse: a change tracker that grows for the life of the process, prices cached at startup and served stale for days, a database connection pinned long past its welcome. None of it reproduces with one developer clicking around a dev environment, because the bug is not in what the code does. It is in who shares it.
Let the container catch it
The default .NET container can detect captives, and in development it should be allowed to:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // scoped-from-root resolution throws
options.ValidateOnBuild = true; // constructor graphs are checked at startup
});
ASP.NET Core already enables both of these when the environment is Development, so the captive above does not wait for concurrent traffic. The application refuses to start:
System.AggregateException: Some services are not able to be constructed
---> System.InvalidOperationException: Error while validating the service
descriptor 'ServiceType: PriceCache Lifetime: Singleton': Cannot consume
scoped service 'ShopContext' from singleton 'PriceCache'.
Startup failure in development is the cheapest possible price for this bug, so resist the
temptation to switch validation off when it complains. It has one blind spot worth knowing:
ValidateOnBuild proves constructor graphs, so a singleton that hides its capture behind a
runtime IServiceProvider.GetService call is only caught when that code path executes. Keep
dependencies in constructors and the container can vouch for the whole graph before the first
request.
The legitimate version of "a singleton needs scoped data" is to create the scope yourself, use it, and throw it away:
public sealed class PriceCache(IDbContextFactory<ShopContext> factory)
{
public async Task<decimal> GetPriceAsync(int productId, CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Products
.Where(p => p.Id == productId)
.Select(p => p.Price)
.SingleAsync(ct);
}
}
IDbContextFactory hands out a fresh context per call. Register it with
builder.Services.AddDbContextFactory<ShopContext>(...), which since EF Core 6 also registers
the scoped ShopContext itself, so it can simply replace the AddDbContext line from earlier.
IServiceScopeFactory does the same job for any scoped service. Either way, the singleton stops
owning something it was never allowed to keep, and the
official lifetime guidelines
stay intact without you having to memorize them.
Rules of thumb
| You are registering | Pick | Because |
|---|---|---|
| Something stateless and thread-safe, like a clock or serializer options | Singleton | One allocation, shared safely by every thread |
Anything holding a DbContext, the current user, or request state |
Scoped | State is isolated to one request at a time |
| A cheap object with per-use state, like a builder or formatter | Transient | Fresh state every time; nothing shared, nothing corrupted |
| A singleton that needs scoped data | IServiceScopeFactory or IDbContextFactory<T> |
Create a scope on demand instead of capturing one forever |
The table is derivable, which is better than memorable. Every row is the same question applied to a different kind of state: who is allowed to see this object at the same time?
Where the instinct comes from
The captive dependency has the same character as the N+1 query: nothing in the diff looks wrong, every test is green, and the failure only exists under conditions your laptop never produces. Code review will not reliably catch it, because the reviewer reads the same innocent constructor you wrote. What catches it is a feedback loop that exercises code the way production does and shows you the cost.
So how do you practice against conditions your laptop never produces? That gap is what
Katabench's grading model is built to close. Submissions are judged by what
they actually do at runtime, with real measurements standing in for the incident report, and the
practice tracks are stocked with exactly this category of mistake: code that
compiles, passes, and is still wrong in a way only load reveals. A lifetime is a two-minute
decision in Program.cs with a failure mode measured in paged-at-3am. The cheap place to get it
wrong, and to learn why, is practice.