Dependency Injection in ASP.NET Core: Singleton, Scoped, and Transient
Dependency injection in ASP.NET Core is built into the framework, and the single decision you
make every time you register a service is its lifetime: Singleton, Scoped, or Transient.
Pick the wrong one and you get bugs that are invisible in development and vicious in production — a
database context shared across requests, a "singleton" that quietly holds a stale connection, a
memory leak that only shows up under load. This guide explains what the three ASP.NET Core service
lifetimes actually mean, how the built-in container creates and disposes your objects, and the one
rule — avoiding the captive dependency — that prevents most lifetime bugs.
This is a supporting page in the Devgains .NET cluster. If you're still forming the async mental model, start with the pillar, Async/Await in C# Explained, then come back — most services you inject do async I/O.
Quick answer: what are the three service lifetimes?
ASP.NET Core's DI container can create a service in one of three ways:
- Transient — a new instance every time it's requested. Even within a single request, two injections give you two different objects.
- Scoped — one instance per scope, and in a web app a scope is one HTTP request. Every service in that request that asks for it gets the same object; the next request gets a fresh one.
- Singleton — one instance for the entire application, created once and shared by every request and every thread until the process stops.
The rule of thumb: Scoped is the default you reach for, Singleton is for stateless shared services and expensive-to-build objects, and Transient is for lightweight, stateless helpers.
Why service lifetimes matter
The container isn't just handing you objects — it owns their creation and disposal. Choosing a lifetime is really choosing three things at once:
- How much state is shared. A Singleton is shared across every concurrent request, so any mutable field in it is shared mutable state across threads — a data race waiting to happen.
- When
Dispose()runs. ScopedIDisposableservices are disposed when the request ends; Singletons when the app shuts down; Transients disposed by the container are held until their scope ends, which can pin memory longer than you expect. - Whether a dependency is safe to hold. A long-lived service must not capture a shorter-lived one — the heart of the captive dependency bug below.
Get this right and the container manages object lifecycles for you. Get it wrong and you ship a leak or a race that no unit test will catch.
How to register services
You register services on IServiceCollection in Program.cs, one method per lifetime:
var builder = WebApplication.CreateBuilder(args);
// Transient: new instance every resolution — cheap, stateless helpers.
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
// Scoped: one instance per HTTP request — the common case for
// business/data services that touch per-request state.
builder.Services.AddScoped<IOrderService, OrderService>();
// Singleton: one instance for the whole app — stateless, thread-safe,
// or expensive-to-create shared resources.
builder.Services.AddSingleton<IClock, SystemClock>();
var app = builder.Build();You never call new OrderService(...) yourself. When a controller or another service declares an
IOrderService constructor parameter, the container resolves the whole graph — building the
service and everything it depends on with the correct lifetime — and disposes what it owns when the
scope ends.
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
// The container injects the Scoped OrderService for this request.
public OrdersController(IOrderService orders) => _orders = orders;
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
=> Ok(await _orders.GetAsync(id));
}Singleton vs Scoped vs Transient: comparison
| Transient | Scoped | Singleton | |
|---|---|---|---|
| Instances created | New every resolution | One per HTTP request | One per application |
| Shared across requests | No | No | Yes |
| Shared across threads | No (unless you share it) | No (one request = one thread) | Yes — must be thread-safe |
| Disposed when | Owning scope ends | Request ends | App shuts down |
| Holds per-request state? | Avoid | Yes, safe | Never |
| Good for | Stateless lightweight helpers | Business/data services, DbContext | Config, caches, clocks, HTTP client factories |
| Cost of wrong choice | Extra allocations | Usually safe | Races, leaks, stale state |
Read this table as a risk gradient: Transient is the safest default and Singleton the most dangerous, because sharing one object across every concurrent request is exactly where thread -safety and stale-state bugs live.
The captive dependency trap
This is the mistake that produces the strangest bugs. A captive dependency happens when a longer-lived service captures a shorter-lived one, freezing it past its intended lifetime.
// Singleton — lives for the whole app.
public class OrderCache
{
private readonly AppDbContext _db; // Scoped — meant to live one request!
public OrderCache(AppDbContext db) => _db = db; // BUG: captured forever
}Because OrderCache is a Singleton, it's built once — and the AppDbContext it grabbed is
frozen with it. That DbContext is now used by every request for the life of the app: it's not
thread-safe, it accumulates tracked entities, and it holds a
database connection from the pool that never
returns. You get intermittent DbContext threading exceptions and a slow memory climb that only
good observability will help you trace back
to its source.
The fix is to resolve the short-lived dependency when you need it, via IServiceScopeFactory:
public class OrderCache
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderCache(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task<Order?> LoadAsync(int id)
{
using var scope = _scopeFactory.CreateScope(); // fresh scope
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Orders.FindAsync(id); // disposed with the scope
}
}In the Development environment ASP.NET Core's default provider validates scopes and will
throw on a captured Scoped-in-Singleton at startup — which is exactly why you want that validation
on. Never disable it to make an error go away.
Why EF Core's DbContext is Scoped
The canonical Scoped service is Entity Framework Core's DbContext. AddDbContext<AppDbContext>()
registers it as Scoped on purpose:
- A
DbContextis a unit of work — it tracks the entities changed during one request and saves them together. That maps naturally onto request scope. - It is not thread-safe. One request runs on one logical flow, so a Scoped context is only ever touched by one thread at a time. Share it across requests (Singleton) and concurrent queries corrupt its change tracker.
So the lifetime isn't arbitrary — it falls out of what the object is. When you're unsure of a lifetime, ask "does this object carry per-request state or a non-thread-safe resource?" If yes, it's Scoped.
Best practices
- Default to Scoped for services that do request-bound work, then narrow to Singleton or Transient only with a reason.
- Keep Singletons stateless or thread-safe. No mutable instance fields unless every access is synchronized. Config, caches, and clocks make good Singletons.
- Never inject a Scoped or Transient service into a Singleton. Use
IServiceScopeFactoryto create a scope when you need one (background services do this constantly). - Leave scope validation on in Development so captive dependencies fail loudly at startup.
- Register against interfaces (
AddScoped<IOrderService, OrderService>()) so lifetime and implementation stay swappable and testable. - Let the container own disposal. If a service is
IDisposableand the container created it, don't dispose it yourself — the scope will.
Common mistakes
- Scoped inside Singleton — the captive dependency. The most common lifetime bug.
DbContextas Singleton — threading exceptions and unbounded change-tracker growth under load.- Mutable state in a Singleton — a shared
List<T>or counter mutated without a lock is a race across every concurrent request. - Using
IServiceProvideras a service locator — resolving everything manually withGetService<T>()instead of constructor injection hides the dependency graph and defeats scope validation. - Transient
IDisposableheld by a long-lived object — the container keeps it alive until the outer scope ends, so it isn't as short-lived as the name suggests.
Key takeaways
- Transient = new every time, Scoped = one per request, Singleton = one per app.
- Scoped is the everyday default; Singleton is for stateless/shared/expensive; Transient for lightweight helpers.
- Never capture a shorter lifetime in a longer one — resolve Scoped work through
IServiceScopeFactory. DbContextis Scoped because it's a non-thread-safe unit of work — a template for your own choices.- Keep scope validation on so the framework catches captive dependencies before your users do.
FAQ
What are service lifetimes in ASP.NET Core?
They're the three ways the built-in DI container creates and disposes a service: Transient (a new
instance every resolution), Scoped (one instance per HTTP request), and Singleton (one instance for
the whole application). The lifetime controls how much state is shared and when Dispose() runs.
What is the difference between Scoped and Transient?
A Scoped service is created once per request and reused everywhere in that request; a Transient
service is created fresh on every single resolution, even multiple times within one request. Use
Scoped when a request should share one object (like a DbContext), Transient for stateless helpers.
When should I use a Singleton? Use Singleton for stateless, thread-safe services and for objects that are expensive to build and safe to share — configuration, in-memory caches, clocks, and factories. Never store per-request state in a Singleton, and never inject a Scoped service into one.
Why can't I inject a Scoped service into a Singleton?
Because the Singleton is built once and would freeze that Scoped instance forever — a captive
dependency. The Scoped object (often a DbContext) then leaks across requests and threads. Resolve
it on demand with IServiceScopeFactory.CreateScope() instead.
What lifetime should DbContext use?
Scoped — which is what AddDbContext registers by default. A DbContext is a per-request unit of
work and is not thread-safe, so it must not be shared across requests as a Singleton.
Conclusion
Dependency injection in ASP.NET Core removes the new keyword from your business code and hands
object lifecycles to the container — but only if you tell it the right lifetime. Reach for Scoped
by default, keep Singletons stateless and thread-safe, use Transient for cheap helpers, and
never let a long-lived service capture a short-lived one. Leave scope validation on and the framework
will catch the dangerous cases for you at startup. From here, the .NET cluster goes deeper into the
services you inject and how they run in production — browse the full .NET guides,
and when you ship these services, see how they deploy to
production in the DevOps
guides.
References
- Dependency injection in ASP.NET Core — the framework's DI container and lifetimes.
- Dependency injection guidelines (.NET) — captive dependencies, disposal, and anti-patterns.
- Service lifetimes (.NET) — Transient, Scoped, and Singleton defined.
- DbContext lifetime, configuration, and initialization
— why EF Core registers
DbContextas Scoped.

