← Open in the full interactive course (progress tracking, search & more)

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

Scoped

Singleton

A quick way to remember

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:

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

THREE RESOLUTIONS OF THE SAME SERVICE, THREE LIFETIMES
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

WHAT "A SCOPE" ACTUALLY IS
1. IN ASP.NET CORE
2. IN A CONSOLE APP / WORKER SERVICE (NO AUTOMATIC HTTP REQUEST SCOPE)
using IServiceScope scope = host.Services.CreateScope();
var worker = scope.ServiceProvider.GetRequiredService<OrderProcessor>();
3. SINGLETON HAS NO SCOPE AT ALL

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

The Hotel

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

CAPTIVE DEPENDENCIES — THE #1 LIFETIME MISTAKE
THE SETUP
builder.Services.AddSingleton<ReportCacheService>();
builder.Services.AddScoped<OrderDbContext>();

public class ReportCacheService(OrderDbContext db) //  Scoped injected into Singleton
{
    // ...
}
WHAT ACTUALLY HAPPENS
THE FIX

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?

Rule of thumb: A service is only safe as a Singleton if it is either stateless, or its shared state is explicitly designed to be thread-safe. If you're not sure, default to Scoped or Transient — it's much easier to promote a service to Singleton later than to debug a subtle concurrency bug caused by an accidental one.

Mental Model

Transient = a fresh cup every time
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.