Service Lifetimes in .NET DI: Singleton, Scoped, Transient
Registering a service is only half the story — how long it lives is the other half.
In lesson 124 you learned to register services with lines like builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>(). But there were three possible words there — AddSingleton, AddScoped, AddTransient — and they are not interchangeable. Pick the wrong one, and you can end up with a database connection shared across every user in your app, a configuration object that never picks up changes, or — the classic gotcha — a background service silently holding onto stale, disposed data forever.
In this lesson, you'll learn what each of the three service lifetimes actually means, when to reach for each one, and how to spot and avoid the "captive dependency" mistake that trips up almost every developer the first time they meet DI lifetimes.
What Is It?
The Simple Explanation
A service lifetime tells the DI container: "when someone asks for this service, should I hand them a brand new object every time, reuse the same object for a while, or reuse the exact same object forever?" It's a policy about instance reuse, decided once at registration time.
The Technical Definition
.NET's built-in container supports exactly three lifetimes:
Transient
- A new instance every single time it's resolved
- Registered with
AddTransient<T>() - Cheapest to reason about — no sharing, no state to worry about
Scoped
- One instance per scope (e.g. one HTTP request)
- Registered with
AddScoped<T>() - Same instance reused within that scope, fresh instance for the next
Singleton
- One instance for the entire application lifetime
- Registered with
AddSingleton<T>() - Created once (either eagerly or on first resolution), reused forever
A quick way to remember
- Transient = every ask gets a new one
- Scoped = every unit of work gets one shared instance
- Singleton = the whole app shares one instance
Why Does It Exist?
The Problem — "New Object" Isn't Always Right
If the container always created a brand new instance for every dependency, you'd have serious problems:
- A database connection created fresh for every single class that needs one, even within the same request — wasteful and potentially unable to share a transaction.
- An in-memory cache that gets recreated on every resolution would be useless — it would never actually cache anything across calls.
- A per-request "current user" context object created once per app and shared across every user would leak one user's data into another's request — a serious bug and security issue.
The Solution — Let the Registration Declare Reuse Policy
By letting you choose Transient, Scoped, or Singleton per service, the container gives you precise control: expensive, stateless, thread-safe services can be shared broadly (Singleton); per-unit-of-work state (like "the current request") gets its own bubble (Scoped); and lightweight, cheap-to-create, possibly-mutable objects get a fresh instance every time (Transient).
Big Picture
Resolve #1 Resolve #2 Resolve #3 (within one scope)
Transient: Instance A Instance B Instance C ← always new
Scoped: Instance A Instance A Instance A ← same, this scope
Singleton: Instance A Instance A Instance A ← same, forever
--- new scope begins ---
Resolve #1 Resolve #2 (within the NEW scope)
Transient: Instance D Instance E ← still always new
Scoped: Instance B Instance B ← NEW instance for new scope
Singleton: Instance A Instance A ← still the very first one
Notice: Transient never repeats. Scoped repeats within a scope but resets for a new scope. Singleton never resets, ever — same object from app startup to shutdown.
How It Works
- The framework creates a new
IServiceScopeautomatically for every incoming HTTP request. - All scoped services resolved while handling that request share one instance; the next request gets a new scope, and new instances.
using IServiceScope scope = host.Services.CreateScope();
var worker = scope.ServiceProvider.GetRequiredService<OrderProcessor>();
- There's no HTTP request to auto-create a scope, so you create one explicitly with
CreateScope()— typically once per "unit of work" (e.g. once per queue message processed, once per batch job run).
- It's built once — either the first time it's requested, or eagerly if you register an already-constructed instance — and lives in the root container for the process's entire lifetime.
Simple Example
A tiny service that reports its own identity via a GUID makes the lifetime differences visible:
public class InstanceIdReporter
{
public Guid Id { get; } = Guid.NewGuid();
}
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<InstanceIdReporter>();
using var host = builder.Build();
using var scope1 = host.Services.CreateScope();
var a = scope1.ServiceProvider.GetRequiredService<InstanceIdReporter>();
var b = scope1.ServiceProvider.GetRequiredService<InstanceIdReporter>();
Console.WriteLine(a.Id == b.Id); // False — Transient: always a new instance
Change only the registration line to AddScoped<InstanceIdReporter>(), and a.Id == b.Id becomes true within the same scope — but resolving from a second, separate scope gives a different Id again. Change it to AddSingleton<InstanceIdReporter>(), and every resolution, from any scope, anywhere in the app, returns the exact same Id.
Real-World Example
A notification worker processes a queue of orders. It needs a database context (should not be shared across concurrent messages — Scoped), an HttpClient-based pricing API client (safe to share, expensive to build repeatedly — often Singleton via IHttpClientFactory, covered in lesson 130), and a small DTO mapper (cheap, stateless, no reason to share — Transient).
builder.Services.AddScoped<OrderDbContext>(); // one per unit of work
builder.Services.AddSingleton<IPricingApiClient, PricingApiClient>(); // shared, thread-safe
builder.Services.AddTransient<IOrderDtoMapper, OrderDtoMapper>(); // cheap, stateless
public class OrderQueueWorker(IServiceScopeFactory scopeFactory)
{
public async Task ProcessMessageAsync(OrderMessage message)
{
// Each message gets its own scope, so its own fresh OrderDbContext —
// messages processed concurrently never share a DbContext.
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
var mapper = scope.ServiceProvider.GetRequiredService<IOrderDtoMapper>();
var order = mapper.ToEntity(message);
db.Orders.Add(order);
await db.SaveChangesAsync();
}
}
This is a very common real-world pattern: a long-running Singleton worker that deliberately creates a new scope per unit of work so that Scoped dependencies (like a DbContext, which is not safe to share across concurrent operations) get fresh instances each time.
Analogy
Transient is like a disposable paper cup at the hotel bar — you get a brand new one every single time you order a drink, no matter how many times you ask.
Scoped is like your hotel room key — issued fresh when you check in (start of a "stay" — a scope), reused for everything during that one stay, and completely invalidated for the next guest (the next scope) who checks in.
Singleton is like the hotel's front desk — there's exactly one, it opened when the hotel opened, and every single guest, across every stay, interacts with that same front desk for the entire time the hotel operates.
Under the Hood
builder.Services.AddSingleton<ReportCacheService>();
builder.Services.AddScoped<OrderDbContext>();
public class ReportCacheService(OrderDbContext db) // Scoped injected into Singleton
{
// ...
}
ReportCacheServiceis built exactly once, the first time it's resolved — as part of that construction, the container also resolves anOrderDbContextfor it, right then.- That single
OrderDbContextinstance gets permanently "captured" inside the Singleton's field, and is reused for the lifetime of the whole app. - A
DbContextis meant to represent one short unit of work and is explicitly not thread-safe for concurrent use — but now every request in the app is sharing that one captured instance, concurrently. Crashes, data corruption, and "why is this DbContext disposed" exceptions follow.
- Never inject a Scoped or Transient service directly into a Singleton's constructor.
- Instead, inject
IServiceScopeFactory(orIServiceProvider) into the Singleton, and create a new scope — and resolve the Scoped dependency from it — each time you actually need it, exactly as shown in the worker example above.
The built-in container actually helps here: by default (in ASP.NET Core apps) it performs scope validation and throws an exception at startup if it detects a Scoped service being injected directly into a Singleton — turning a subtle runtime bug into a loud, immediate failure.
Common Confusion
"Singleton" here isn't the Singleton design pattern
The classic Gang-of-Four Singleton pattern uses a private constructor and a static field to guarantee exactly one instance can ever exist, anywhere, enforced by the type itself. A DI Singleton lifetime is much softer: the container promises to hand out the same instance whenever it is asked — but nothing stops you from writing new MyService() elsewhere in code and getting a second instance outside the container's control. It's "one instance per container," not "one instance, period."
"Scoped" doesn't only mean "per HTTP request"
ASP.NET Core happens to create one scope per request automatically, which is why people equate "Scoped" with "per-request." But the real definition is "per IServiceScope." In a worker service or console app, you decide what a scope represents — often "per queue message" or "per batch job."
Common Mistakes
Mistake 1 — The captive dependency (covered above)
Injecting a Scoped or Transient service straight into a Singleton's constructor. Inject IServiceScopeFactory and create scopes on demand.
Mistake 2 — Making everything Singleton "for performance"
Registering everything as Singleton because "fewer object allocations must be faster." This introduces hidden shared mutable state and thread-safety bugs, and risks captive dependencies.
Default to Transient or Scoped for anything with per-operation state; reserve Singleton for genuinely stateless, thread-safe, or intentionally-shared services (caches, configuration snapshots, HTTP client wrappers).
Mistake 3 — Assuming Transient means "cheap" regardless of what's inside it
Registering an expensive-to-construct object (one that opens a network connection, loads a large file) as Transient, not realizing it gets rebuilt every single time it's resolved — including deep inside a request handling many transient dependencies.
Match the lifetime to the actual construction cost and statefulness of the service, not just habit.
When Should I Use It?
- Transient — lightweight, stateless services with no expensive setup (mappers, validators, small helper classes). Default choice when in doubt and the service holds no state.
- Scoped — anything representing "one unit of work": a database context, a per-request current-user context, a unit-of-work/transaction wrapper.
- Singleton — expensive-to-build, thread-safe, genuinely shareable state: configuration snapshots, in-memory caches, an
HttpClientwrapper (viaIHttpClientFactory), a connection pool.
Mental Model
Scoped = one room key per stay
Singleton = one front desk for the whole hotel
Remember:
· Never let a Singleton hold a captured reference to a Scoped or Transient service.
· When a Singleton needs Scoped data, it should create a scope on demand via
IServiceScopeFactory, not capture one dependency forever.· Lifetime mismatches are a runtime/concurrency problem, not a compile-time one — the container can only catch some of them for you.
Key Takeaway
- Transient: new instance every resolution. Scoped: one instance per scope. Singleton: one instance for the app's whole lifetime.
- A scope is created automatically per HTTP request in ASP.NET Core, and explicitly via
CreateScope()elsewhere (console apps, workers). - The captive dependency mistake — injecting a Scoped/Transient service into a Singleton — silently pins short-lived state for the entire app lifetime and causes concurrency bugs.
- Fix captive dependencies by injecting
IServiceScopeFactoryinto the Singleton and creating scopes on demand, rather than capturing the dependency in the constructor. - Choose lifetimes based on statefulness and thread-safety, not "what feels efficient."
Check Your Understanding
You've seen the three lifetimes and the captive dependency trap. Let's see if you can reason through some scenarios.
1. Two different HTTP requests each resolve a service registered with AddScoped<T>(). What should you expect?
Show answer
Correct: B
Why B is correct: ASP.NET Core creates a new scope per request. Scoped means "one instance per scope" — so within one request, repeated resolutions share an instance, but a different request (different scope) gets a fresh one.
Why A is incorrect: That describes Singleton behavior, not Scoped.
Why C is incorrect: That describes Transient behavior.
Why D is incorrect: The container doesn't stop handing out instances after the first request.
Reinforcement: Scoped = shared within a scope, reset between scopes.
2. What exactly is a "captive dependency"?
Show answer
Correct: B
Why B is correct: When a Singleton is constructed once, any Scoped or Transient dependency injected into its constructor gets resolved at that moment and stored in a field — effectively becoming "trapped" at Singleton lifetime, even though it was meant to be short-lived.
Why A is incorrect: Constructor size is a separate design concern (possibly a sign of too many responsibilities), not what "captive dependency" refers to.
Why C is incorrect: That's a missing-registration error, an unrelated failure mode.
Why D is incorrect: That describes a circular dependency, a different DI problem entirely.
Reinforcement: Captive dependency = a short-lived service accidentally living as long as a Singleton.
3. A Singleton NotificationDispatcher needs to use a Scoped OrderDbContext each time it dispatches a batch. What's the correct approach?
Show answer
Correct: C
Why C is correct: This is exactly the documented fix for the captive dependency problem — the Singleton stays a Singleton, but it obtains fresh Scoped instances on demand by creating a scope each time it needs one, instead of capturing a single instance forever.
Why A is incorrect: This is precisely the captive dependency mistake — it would capture one OrderDbContext for the app's whole lifetime.
Why B is incorrect: Making a DbContext a Singleton is dangerous — it's not thread-safe and represents one unit of work; sharing it across all requests risks data corruption.
Why D is incorrect: Changing NotificationDispatcher's own lifetime doesn't address the underlying problem of how it obtains the Scoped dependency, and may not fit its intended shared, long-lived role at all.
Reinforcement: IServiceScopeFactory is the standard escape hatch for a Singleton that needs short-lived, per-operation dependencies.
4. Why is it risky to register everything as Singleton purely to avoid the overhead of creating new objects?
Show answer
Correct: B
Why B is correct: Sharing one instance across the whole app means any mutable state inside it is now shared across every concurrent operation — a recipe for race conditions, and it's easy to accidentally end up with a captured Scoped dependency along the way.
Why A is incorrect: Singletons are typically faster per-resolution (no reconstruction) — the concern isn't speed, it's correctness under concurrency.
Why C is incorrect: There's no such compiler restriction; you can register as many Singletons as you like.
Why D is incorrect: Singletons use constructor injection exactly like any other lifetime.
Reinforcement: Lifetime choice is a correctness decision (statefulness, thread-safety), not primarily a performance one.
You now understand the three DI lifetimes and can spot — and avoid — the captive dependency trap before it bites you in production.