When you register a service with .NET's dependency injection container you choose a lifetime — transient, scoped, or singleton — and that choice decides how many instances exist and how long they live. Get it right and you never think about it again; get it wrong and you leak one user's data into another's request. This tutorial demonstrates all three lifetimes with a runnable app whose output makes the differences impossible to miss, then covers the rules for mixing them.

If DI itself is new to you, start with how to implement dependency injection in .NET.
The demonstration
Three identical classes, one per lifetime — each instance gets a GUID at construction, so identical GUIDs mean the same instance:
public class TransientCounter { public Guid Id { get; } = Guid.NewGuid(); }
public class ScopedCounter { public Guid Id { get; } = Guid.NewGuid(); }
public class SingletonCounter { public Guid Id { get; } = Guid.NewGuid(); }
builder.Services.AddTransient<TransientCounter>();
builder.Services.AddScoped<ScopedCounter>();
builder.Services.AddSingleton<SingletonCounter>();
One endpoint asks for two of each in a single request:
app.MapGet("/", (TransientCounter t1, TransientCounter t2,
ScopedCounter s1, ScopedCounter s2,
SingletonCounter g1, SingletonCounter g2) => Results.Json(new
{
transient = new { first = t1.Id, second = t2.Id, same = t1.Id == t2.Id },
scoped = new { first = s1.Id, second = s2.Id, same = s1.Id == s2.Id },
singleton = new { first = g1.Id, second = g2.Id, same = g1.Id == g2.Id },
}));
Actual output from the first request:
{
"transient": { "first": "47e16018…", "second": "99ca9d6a…", "same": false },
"scoped": { "first": "c1124d09…", "second": "c1124d09…", "same": true },
"singleton": { "first": "667c4837…", "second": "667c4837…", "same": true }
}
And the second request: the singleton GUID was still 667c4837… — the same object as request one — while the scoped GUID changed to a fresh 3b5c37e4….
What each lifetime means
Transient — new instance every injection
Both t1 and t2 differ within one request. Use for lightweight, stateless services where sharing buys nothing. Cost: allocation per resolution — negligible for most services.
Scoped — one instance per request
s1 and s2 were the same object; the next request got a new one. This is the workhorse lifetime for anything that carries per-request state — which is why AddDbContext registers your EF Core context as scoped: one unit of work per request, disposed when the response ends.
Singleton — one instance for the application
Same object across every injection and every request until the process exits. Use for caches, configuration holders, HttpClient-style factories — and make them thread-safe, because concurrent requests share the one instance.
The captive dependency trap
The one rule that bites: never inject a shorter-lived service into a longer-lived one. A singleton that takes a scoped service in its constructor captures it forever — the "per-request" DbContext becomes one shared context for the whole app, and you've built a data-corruption machine.
// ❌ singleton capturing a scoped DbContext
public class ReportCache(StoreContext db) { … }
builder.Services.AddSingleton<ReportCache>();
ASP.NET Core catches many of these at startup in development (InvalidOperationException: Cannot consume scoped service…), but only for services it builds — be deliberate anyway. When a singleton genuinely needs scoped work, create the scope explicitly:
public class ReportCache(IServiceScopeFactory scopeFactory)
{
public async Task RefreshAsync()
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<StoreContext>();
// use db for this operation only
}
}
Background services (IHostedService) live as singletons, so this scope-factory pattern is the standard way they talk to the database.
Lifetimes and IDisposable
The container disposes what it creates — on its schedule:
- Scoped/transient services implementing
IDisposableare disposed when their scope ends (the end of the HTTP request). - Singletons are disposed when the application shuts down.
- The corollary: a transient disposable resolved from the root provider (not inside a scope) lives — and stays undisposed — until shutdown. Resolve disposables inside scopes, which controllers and endpoints already do for you.
Factory registrations and multiple implementations
Registration takes a factory lambda when construction needs logic:
builder.Services.AddScoped<IPricingService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return config.GetValue<bool>("Sale:Active")
? new PrimeDayPricingService()
: new PricingService();
});
Registering the same interface repeatedly appends — and IEnumerable<T> injects them all:
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SlackNotifier>();
public class OrderService(IEnumerable<INotifier> notifiers) { … } // gets both
That's the built-in pattern for plug-in pipelines (validators, handlers, exporters). A single INotifier parameter would receive the last registration. The TryAdd* variants (TryAddScoped etc.) register only if the interface isn't already present — the polite form used by library Add…() extension methods so your overrides win.
Scope validation — let the container catch mistakes
Development builds validate scopes on startup; make production strict too if you'd rather crash at boot than corrupt at runtime:
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true; // scoped-into-singleton throws
o.ValidateOnBuild = true; // every registration constructible at startup
});
ValidateOnBuild also catches missing dependencies that would otherwise surface on the first request that needs them — a startup failure in CI instead of a 500 at 2am.
Choosing, in one table
| Lifetime | New instance | Right for | Watch out |
|---|---|---|---|
| Transient | every injection | stateless helpers | many allocations in hot paths |
| Scoped | per HTTP request | DbContext, per-request state | unavailable outside a request without a scope |
| Singleton | once per app | caches, config, clients | must be thread-safe; captive dependencies |
Default to scoped for anything touching data, singleton for genuinely shared state, transient when in doubt for small stateless services — and let the GUID demo above settle any argument about what's actually happening.
Comments (0)
No comments yet — be the first to share your thoughts.