Service lifetimes in ASP.NET Core: transient, scoped, singleton and the captive dependency trap
What transient, scoped and singleton really mean, how a scoped DbContext gets trapped in a singleton, how scope validation catches it, and how to handle background services, thread safety, keyed services (.NET 8) and HttpClient lifetimes.
The definitions of transient, scoped and singleton fit in three lines. What breaks production apps is mixing them. A singleton that receives a scoped DbContext keeps that one context alive for the life of the app and shares it across every concurrent request. The result is intermittent "second operation started on this context" exceptions, stale data and a change tracker that never stops growing, none of which shows up when you test alone on your machine.
This article covers what each lifetime means in practice and, more importantly, how to keep them from capturing each other: scope validation, background services, thread safety in singletons, keyed services and HttpClient.
The three lifetimes
- Transient: a new instance every time the container resolves it.
- Scoped: one instance per scope. In ASP.NET Core, a scope is an HTTP request (or a Blazor circuit).
- Singleton: one instance for the whole app lifetime, shared by every request and every thread.
- A service must never depend on something with a shorter lifetime. A singleton holding a scoped
DbContextis the classic bug. It's called a captive dependency. - In Development,
WebApplicationBuilderturns on scope validation (ValidateScopesandValidateOnBuild), which catches most of these at startup. In Production it's off unless you turn it on.
Lifetimes in practice
builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();
builder.Services.AddScoped<ICartService, CartService>();
builder.Services.AddSingleton<IExchangeRateCache, ExchangeRateCache>();
Within a single request, if both CheckoutController and DiscountService ask for ICartService, they get the same CartService. That's what you want for per-request state like the current user's cart or a unit of work. Each of them gets its own PriceFormatter. Every request in the app shares one ExchangeRateCache.
My rules of thumb:
- Stateless and cheap: transient or singleton, both fine. I lean singleton for stateless services that are resolved a lot.
- Holds per-request state, or depends on
DbContext: scoped. - Expensive to build and safe to share across threads (caches, configuration, compiled regexes,
HttpClientwith a pooled handler): singleton.
AddDbContext registers the context as scoped by default, and the ASP.NET Core docs say it plainly: services shouldn't use a database context with a lifetime shorter than their own.
The captive dependency
Here's the classic version of the bug:
builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddSingleton<IProductCatalog, ProductCatalog>();
public sealed class ProductCatalog(ShopDbContext db) : IProductCatalog
{
public Task<Product?> FindAsync(int id) => db.Products.FindAsync(id).AsTask();
}
ProductCatalog is created once. The ShopDbContext it receives is created once along with it and is never disposed. That one context is now shared by every concurrent request. DbContext isn't thread-safe, so under load you get InvalidOperationException: A second operation was started on this context instance before a previous operation completed. The change tracker keeps growing, and queries start returning stale tracked entities. It works perfectly on your machine with one user.
The same thing happens, more quietly, with transient services. A transient injected into a singleton lives as long as the singleton does. The container won't warn you about that one.
Scope validation
In Development, WebApplicationBuilder creates the service provider with both ValidateScopes and ValidateOnBuild set to true. The code above fails at startup with a message along the lines of Cannot consume scoped service 'ShopDbContext' from singleton 'IProductCatalog'. That's exactly why it "only breaks in production": production doesn't validate by default.
I turn validation on everywhere, at least in CI and staging:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateOnBuild checks every registration when the provider is built, which adds some startup time. It also has blind spots: open generic registrations aren't validated, and it can't look inside factory lambdas that resolve services by hand.
Breaking the capture
The simplest fix is usually to make the consumer scoped too. If it really needs to be a singleton (say, it holds a cache), give it a way to create short-lived contexts:
builder.Services.AddDbContextFactory<ShopDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddSingleton<IProductCatalog, ProductCatalog>();
public sealed class ProductCatalog(IDbContextFactory<ShopDbContext> contextFactory) : IProductCatalog
{
public async Task<Product?> FindAsync(int id, CancellationToken ct = default)
{
await using var db = await contextFactory.CreateDbContextAsync(ct);
return await db.Products.FindAsync([id], ct);
}
}
Background services need their own scopes
A BackgroundService is registered as a singleton through AddHostedService, and no request scope exists for it. Constructor-injecting a DbContext there is the same captive dependency. Inject IServiceScopeFactory and create a scope for each unit of work:
public sealed class OutboxWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.Id)
.Take(100)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
// publish...
message.ProcessedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Outbox batch failed");
}
}
}
}
I prefer CreateAsyncScope() with await using over CreateScope(). If any service in the scope implements only IAsyncDisposable, disposing the scope synchronously throws. The async scope handles both kinds.
Singletons are shared across threads
A singleton is one object that every request hits at the same time. Any mutable state inside it needs to be thread-safe:
public sealed class ExchangeRateCache : IExchangeRateCache
{
// Not Dictionary<,>: concurrent writes can corrupt it.
private readonly ConcurrentDictionary<string, decimal> _rates = new();
public decimal GetOrAdd(string currency, Func<string, decimal> load) =>
_rates.GetOrAdd(currency, load);
}
Also check what your singleton captures. Injecting IHttpContextAccessor is fine, because it reads the current request on demand. Storing HttpContext or the current user in a field of a singleton is not.
Multiple implementations: keyed services
A related need is registering several implementations of the same interface and picking one by name. The traditional approaches rely on IEnumerable<T> or factory delegates, and both still work. Since .NET 8, the container supports keyed services natively:
builder.Services.AddKeyedSingleton<IPaymentGateway, CardGateway>("card");
builder.Services.AddKeyedSingleton<IPaymentGateway, BankTransferGateway>("transfer");
app.MapPost("/payments/card",
([FromKeyedServices("card")] IPaymentGateway gateway, PaymentRequest request, CancellationToken ct) =>
gateway.ChargeAsync(request, ct));
public sealed class RefundService([FromKeyedServices("transfer")] IPaymentGateway gateway)
{
// ...
}
AddKeyedScoped and AddKeyedTransient exist too. Keys don't have to be strings; any object with proper equality works, and an enum is often a better choice than a magic string. When the key is only known at runtime, GetRequiredKeyedService<T>(key) is available. That's service locator territory, so I keep it inside one small factory class.
HttpClient lifetimes
Creating a new HttpClient() per call can exhaust sockets. Keeping one forever means it never notices DNS changes. Microsoft's guidance gives two options.
Either let IHttpClientFactory manage handlers, with typed clients:
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.example.com/");
});
Typed clients are registered as transient and are meant to be short-lived. Don't capture one in a singleton, or you're back to the stale-DNS problem.
Or use a long-lived client and let the handler recycle connections:
builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
}));
Both are fine. What isn't fine is a singleton holding a factory-created client forever, or a client created and disposed per request.
Checklist
- Before choosing a lifetime, ask what state the service holds and what it depends on.
- Never let a longer-lived service depend on a shorter-lived one. Watch out for transients inside singletons too.
- Turn on
ValidateScopesandValidateOnBuildbeyond Development, at least in CI or staging. - In singletons and hosted services, use
IDbContextFactory<T>orIServiceScopeFactory.CreateAsyncScope(), not an injectedDbContext. - Make singletons thread-safe, and never store request data in them.
- Use keyed services (.NET 8+) when you need named implementations of one interface.
- For
HttpClient, use eitherIHttpClientFactorywith short-lived clients or a long-lived client withPooledConnectionLifetime, never something in between.
- ---
- baslik: "Service lifetimes in ASP.NET Core: transient, scoped, singleton and the captive dependency trap"
- ozet: "What transient, scoped and singleton really mean, how a scoped DbContext gets trapped in a singleton, how scope validation catches it, and how to handle background services, thread safety, keyed services (.NET 8) and HttpClient lifetimes."
- tarih: 2026-08-20
- etiketler:
- - dotnet
- - aspnet-core
- - dependency-injection
- kategoriler:
- - dotnet
- dil: en
- ---
- The definitions of transient, scoped and singleton fit in three lines. What breaks production apps is mixing them. A singleton that receives a scoped `DbContext` keeps that one context alive for the life of the app and shares it across every concurrent request. The result is intermittent "second operation started on this context" exceptions, stale data and a change tracker that never stops growing, none of which shows up when you test alone on your machine.
- This article covers what each lifetime means in practice and, more importantly, how to keep them from capturing each other: scope validation, background services, thread safety in singletons, keyed services and `HttpClient`.
- ## The three lifetimes
- - **Transient**: a new instance every time the container resolves it.
- - **Scoped**: one instance per scope. In ASP.NET Core, a scope is an HTTP request (or a Blazor circuit).
- - **Singleton**: one instance for the whole app lifetime, shared by every request and every thread.
- - A service must never depend on something with a *shorter* lifetime. A singleton holding a scoped `DbContext` is the classic bug. It's called a **captive dependency**.
- - In Development, `WebApplicationBuilder` turns on scope validation (`ValidateScopes` and `ValidateOnBuild`), which catches most of these at startup. In Production it's off unless you turn it on.
- ## Lifetimes in practice
- ```csharp
- builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();
- builder.Services.AddScoped<ICartService, CartService>();
- builder.Services.AddSingleton<IExchangeRateCache, ExchangeRateCache>();
- ```
- Within a single request, if both `CheckoutController` and `DiscountService` ask for `ICartService`, they get the *same* `CartService`. That's what you want for per-request state like the current user's cart or a unit of work. Each of them gets its own `PriceFormatter`. Every request in the app shares one `ExchangeRateCache`.
- My rules of thumb:
- - Stateless and cheap: transient or singleton, both fine. I lean singleton for stateless services that are resolved a lot.
- - Holds per-request state, or depends on `DbContext`: scoped.
- - Expensive to build and safe to share across threads (caches, configuration, compiled regexes, `HttpClient` with a pooled handler): singleton.
- `AddDbContext` registers the context as scoped by default, and the ASP.NET Core docs say it plainly: services shouldn't use a database context with a lifetime shorter than their own.
- ## The captive dependency
- Here's the classic version of the bug:
- ```csharp
- builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(connectionString));
- builder.Services.AddSingleton<IProductCatalog, ProductCatalog>();
- public sealed class ProductCatalog(ShopDbContext db) : IProductCatalog
- {
- public Task<Product?> FindAsync(int id) => db.Products.FindAsync(id).AsTask();
- }
- ```
- `ProductCatalog` is created once. The `ShopDbContext` it receives is created once along with it and is never disposed. That one context is now shared by every concurrent request. `DbContext` isn't thread-safe, so under load you get `InvalidOperationException: A second operation was started on this context instance before a previous operation completed`. The change tracker keeps growing, and queries start returning stale tracked entities. It works perfectly on your machine with one user.
- The same thing happens, more quietly, with transient services. A transient injected into a singleton lives as long as the singleton does. The container won't warn you about that one.
- ### Scope validation
- In Development, `WebApplicationBuilder` creates the service provider with both `ValidateScopes` and `ValidateOnBuild` set to `true`. The code above fails at startup with a message along the lines of *Cannot consume scoped service 'ShopDbContext' from singleton 'IProductCatalog'*. That's exactly why it "only breaks in production": production doesn't validate by default.
- I turn validation on everywhere, at least in CI and staging:
- ```csharp
- builder.Host.UseDefaultServiceProvider(options =>
- {
- options.ValidateScopes = true;
- options.ValidateOnBuild = true;
- });
- ```
- `ValidateOnBuild` checks every registration when the provider is built, which adds some startup time. It also has blind spots: open generic registrations aren't validated, and it can't look inside factory lambdas that resolve services by hand.
- ### Breaking the capture
- The simplest fix is usually to make the consumer scoped too. If it really needs to be a singleton (say, it holds a cache), give it a way to create short-lived contexts:
- ```csharp
- builder.Services.AddDbContextFactory<ShopDbContext>(o => o.UseSqlServer(connectionString));
- builder.Services.AddSingleton<IProductCatalog, ProductCatalog>();
- public sealed class ProductCatalog(IDbContextFactory<ShopDbContext> contextFactory) : IProductCatalog
- {
- public async Task<Product?> FindAsync(int id, CancellationToken ct = default)
- {
- await using var db = await contextFactory.CreateDbContextAsync(ct);
- return await db.Products.FindAsync([id], ct);
- }
- }
- ```
- ## Background services need their own scopes
- A `BackgroundService` is registered as a singleton through `AddHostedService`, and no request scope exists for it. Constructor-injecting a `DbContext` there is the same captive dependency. Inject `IServiceScopeFactory` and create a scope for each unit of work:
- ```csharp
- public sealed class OutboxWorker(
- IServiceScopeFactory scopeFactory,
- ILogger<OutboxWorker> logger) : BackgroundService
- {
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
- while (await timer.WaitForNextTickAsync(stoppingToken))
- {
- try
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
- var pending = await db.OutboxMessages
- .Where(m => m.ProcessedAt == null)
- .OrderBy(m => m.Id)
- .Take(100)
- .ToListAsync(stoppingToken);
- foreach (var message in pending)
- {
- // publish...
- message.ProcessedAt = DateTime.UtcNow;
- }
- await db.SaveChangesAsync(stoppingToken);
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
- {
- logger.LogError(ex, "Outbox batch failed");
- }
- }
- }
- }
- ```
- I prefer `CreateAsyncScope()` with `await using` over `CreateScope()`. If any service in the scope implements only `IAsyncDisposable`, disposing the scope synchronously throws. The async scope handles both kinds.
- ## Singletons are shared across threads
- A singleton is one object that every request hits at the same time. Any mutable state inside it needs to be thread-safe:
- ```csharp
- public sealed class ExchangeRateCache : IExchangeRateCache
- {
- // Not Dictionary<,>: concurrent writes can corrupt it.
- private readonly ConcurrentDictionary<string, decimal> _rates = new();
- public decimal GetOrAdd(string currency, Func<string, decimal> load) =>
- _rates.GetOrAdd(currency, load);
- }
- ```
- Also check what your singleton captures. Injecting `IHttpContextAccessor` is fine, because it reads the current request on demand. Storing `HttpContext` or the current user in a field of a singleton is not.
- ## Multiple implementations: keyed services
- A related need is registering several implementations of the same interface and picking one by name. The traditional approaches rely on `IEnumerable<T>` or factory delegates, and both still work. Since .NET 8, the container supports keyed services natively:
- ```csharp
- builder.Services.AddKeyedSingleton<IPaymentGateway, CardGateway>("card");
- builder.Services.AddKeyedSingleton<IPaymentGateway, BankTransferGateway>("transfer");
- app.MapPost("/payments/card",
- ([FromKeyedServices("card")] IPaymentGateway gateway, PaymentRequest request, CancellationToken ct) =>
- gateway.ChargeAsync(request, ct));
- public sealed class RefundService([FromKeyedServices("transfer")] IPaymentGateway gateway)
- {
- // ...
- }
- ```
- `AddKeyedScoped` and `AddKeyedTransient` exist too. Keys don't have to be strings; any object with proper equality works, and an enum is often a better choice than a magic string. When the key is only known at runtime, `GetRequiredKeyedService<T>(key)` is available. That's service locator territory, so I keep it inside one small factory class.
- ## HttpClient lifetimes
- Creating a `new HttpClient()` per call can exhaust sockets. Keeping one forever means it never notices DNS changes. Microsoft's guidance gives two options.
- Either let `IHttpClientFactory` manage handlers, with typed clients:
- ```csharp
- builder.Services.AddHttpClient<CatalogApiClient>(client =>
- {
- client.BaseAddress = new Uri("https://catalog.example.com/");
- });
- ```
- Typed clients are registered as transient and are meant to be short-lived. Don't capture one in a singleton, or you're back to the stale-DNS problem.
- Or use a long-lived client and let the handler recycle connections:
- ```csharp
- builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler
- {
- PooledConnectionLifetime = TimeSpan.FromMinutes(2)
- }));
- ```
- Both are fine. What isn't fine is a singleton holding a factory-created client forever, or a client created and disposed per request.
- ## Checklist
- - Before choosing a lifetime, ask what state the service holds and what it depends on.
- - Never let a longer-lived service depend on a shorter-lived one. Watch out for transients inside singletons too.
- - Turn on `ValidateScopes` and `ValidateOnBuild` beyond Development, at least in CI or staging.
- - In singletons and hosted services, use `IDbContextFactory<T>` or `IServiceScopeFactory.CreateAsyncScope()`, not an injected `DbContext`.
- - Make singletons thread-safe, and never store request data in them.
- - Use keyed services (.NET 8+) when you need named implementations of one interface.
- - For `HttpClient`, use either `IHttpClientFactory` with short-lived clients or a long-lived client with `PooledConnectionLifetime`, never something in between.