ASP.NET Core'da servis ömürleri: transient, scoped, singleton ve captive dependency tuzağı
Transient, scoped ve singleton gerçekte ne demek, scoped bir DbContext singleton içinde nasıl esir kalır, scope doğrulaması bunu nasıl yakalar; background service'ler, thread safety, keyed service'ler (.NET 8) ve HttpClient ömürleri nasıl yönetilir.
Transient, scoped ve singleton'ın tanımı üç satıra sığar. Production'da asıl patlayan şey bunları birbirine karıştırmak. Scoped bir DbContext alan singleton, o tek context'i uygulama boyunca yaşatır ve eş zamanlı bütün isteklerle paylaşır. Sonuç ara ara gelen "second operation started on this context" hataları, bayat veri ve hiç durmadan büyüyen bir change tracker olur. Kendi makinenizde tek başınıza test ederken bunların hiçbiri görünmez.
Bu yazıda her ömrün pratikte ne anlama geldiğini ve, daha önemlisi, birbirlerini esir almalarını nasıl önleyeceğinizi anlatıyorum: scope doğrulaması, background service'ler, singleton'larda thread safety, keyed service'ler ve HttpClient.
Üç servis ömrü
- Transient: container her çözümlediğinde yeni bir instance.
- Scoped: her scope için bir instance. ASP.NET Core'da scope bir HTTP isteğidir (Blazor'da bir circuit).
- Singleton: uygulama boyunca tek bir instance. Tüm istekler ve tüm thread'ler aynı nesneyi paylaşır.
- Bir servis, kendisinden daha kısa ömürlü bir şeye asla bağımlı olmamalı. Scoped bir
DbContext'i tutan singleton klasik hatadır. Buna captive dependency deniyor, yani kısa ömürlü bağımlılığın uzun ömürlü bir servisin içinde "esir" kalması. - Development ortamında
WebApplicationBuilderscope doğrulamasını (ValidateScopesveValidateOnBuild) açar ve bu hataların çoğunu uygulama açılırken yakalar. Production'da siz açmadıkça kapalıdır.
Ömürler pratikte
builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();
builder.Services.AddScoped<ICartService, CartService>();
builder.Services.AddSingleton<IExchangeRateCache, ExchangeRateCache>();
Aynı istek içinde hem CheckoutController hem DiscountService ICartService isterse ikisi de aynı CartService'i alır. Kullanıcının sepeti ya da unit of work gibi istek başına durum için istediğiniz tam olarak budur. PriceFormatter'dan ise her biri kendi kopyasını alır. ExchangeRateCache'i uygulamadaki bütün istekler paylaşır.
Benim pratik kurallarım:
- Durumsuz ve ucuzsa transient de olur, singleton da. Çok sık çözümlenen durumsuz servislerde singleton'a yatkınım.
- İstek başına durum tutuyorsa ya da
DbContext'e bağımlıysa scoped. - Oluşturması pahalı ve thread'ler arasında güvenle paylaşılabiliyorsa singleton: cache'ler, konfigürasyon, derlenmiş regex'ler, pooled handler'lı
HttpClient.
AddDbContext context'i varsayılan olarak scoped kaydeder. ASP.NET Core dokümantasyonu da açıkça söylüyor: servisler kendi ömürlerinden kısa ömürlü bir veritabanı context'i kullanmamalı.
Captive dependency
Hatanın klasik hali şöyle:
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 bir kez oluşturuluyor. Ona verilen ShopDbContext de onunla birlikte bir kez oluşuyor ve hiç dispose edilmiyor. Artık tek bir context eş zamanlı bütün isteklerce paylaşılıyor. DbContext thread-safe değil. Yük altında InvalidOperationException: A second operation was started on this context instance before a previous operation completed hatasını alırsınız. Change tracker sürekli büyür, sorgular bayat takip edilen entity'ler döndürmeye başlar. Tek kullanıcıyla kendi makinenizde ise kusursuz çalışır.
Aynı şey transient servislerde daha sessiz bir şekilde olur. Singleton'a enjekte edilen bir transient, singleton ne kadar yaşarsa o kadar yaşar. Container bu konuda sizi uyarmaz.
Scope doğrulaması
Development'ta WebApplicationBuilder, service provider'ı ValidateScopes ve ValidateOnBuild ikisi de true olacak şekilde oluşturur. Yukarıdaki kod açılışta Cannot consume scoped service 'ShopDbContext' from singleton 'IProductCatalog' benzeri bir mesajla patlar. "Sadece production'da bozuluyor" durumunun nedeni tam olarak bu: production varsayılan olarak doğrulama yapmıyor.
Ben doğrulamayı her yerde, en azından CI ve staging'de açıyorum:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateOnBuild, provider oluşturulurken bütün kayıtları kontrol eder, bu da açılış süresine bir miktar ekler. Kör noktaları da var: open generic kayıtlar doğrulanmaz, servisleri elle çözümleyen factory lambda'larının içine de bakamaz.
Bağımlılığı çözmek
En basit çözüm genellikle tüketen servisi de scoped yapmak. Gerçekten singleton olması gerekiyorsa (diyelim ki bir cache tutuyor) ona kısa ömürlü context'ler üretmenin bir yolunu verin:
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 service'ler kendi scope'unu açmalı
Bir BackgroundService, AddHostedService üzerinden singleton olarak kaydedilir ve onun için bir istek scope'u yoktur. Oraya constructor ile DbContext enjekte etmek aynı captive dependency demek. IServiceScopeFactory enjekte edin ve her iş birimi için bir scope açın:
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");
}
}
}
}
CreateScope() yerine await using ile CreateAsyncScope() kullanmayı tercih ediyorum. Scope içindeki servislerden biri sadece IAsyncDisposable uyguluyorsa scope'u senkron dispose etmek exception fırlatır. Async scope iki durumu da halleder.
Singleton'lar thread'ler arasında paylaşılır
Singleton, bütün isteklerin aynı anda eriştiği tek bir nesnedir. İçindeki değişebilir her durum thread-safe olmalı:
public sealed class ExchangeRateCache : IExchangeRateCache
{
// Dictionary<,> değil: eş zamanlı yazmalar onu bozabilir.
private readonly ConcurrentDictionary<string, decimal> _rates = new();
public decimal GetOrAdd(string currency, Func<string, decimal> load) =>
_rates.GetOrAdd(currency, load);
}
Singleton'ınızın neyi yakaladığına da bakın. IHttpContextAccessor enjekte etmek sorun değil, çünkü o anki isteği ihtiyaç anında okur. HttpContext'i ya da o anki kullanıcıyı singleton'ın bir field'ında saklamak ise sorun.
Birden fazla implementasyon: keyed service'ler
İlgili bir ihtiyaç da aynı interface'in birden fazla implementasyonunu kaydedip birini isimle seçmek. Geleneksel yaklaşımlar IEnumerable<T>'ye ya da factory delegate'lerine dayanıyor, ikisi de hâlâ çalışıyor. .NET 8'den beri container keyed service'leri doğrudan destekliyor:
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 ve AddKeyedTransient de mevcut. Anahtarların string olması gerekmiyor. Eşitliği düzgün tanımlanmış her nesne olur, sihirli string yerine bir enum çoğu zaman daha iyi bir seçim. Anahtar ancak çalışma anında belli oluyorsa GetRequiredKeyedService<T>(key) var. Bu service locator bölgesi, o yüzden onu tek bir küçük factory sınıfının içinde tutuyorum.
HttpClient ömürleri
Her çağrıda new HttpClient() oluşturmak socket'leri tüketebilir. Tek bir tanesini sonsuza kadar tutmak ise DNS değişikliklerini hiç fark etmemesi demek. Microsoft'un önerisi iki seçenek sunuyor.
Ya handler'ları IHttpClientFactory yönetsin, typed client'larla:
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.example.com/");
});
Typed client'lar transient kaydedilir ve kısa ömürlü olmaları beklenir. Birini bir singleton içinde yakalarsanız bayat DNS sorununa geri dönersiniz.
Ya da uzun ömürlü bir client kullanın ve bağlantıları handler yenilesin:
builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
}));
İkisi de doğru. Doğru olmayan, factory'nin ürettiği bir client'ı sonsuza kadar tutan bir singleton ya da her istekte oluşturulup dispose edilen bir client.
Kontrol listesi
- Lifetime seçmeden önce servisin hangi durumu tuttuğunu ve neye bağımlı olduğunu sorun.
- Uzun ömürlü bir servisin kısa ömürlü bir servise bağımlı olmasına asla izin vermeyin. Singleton içindeki transient'lara da dikkat edin.
ValidateScopesveValidateOnBuild'i Development dışında da, en azından CI ya da staging'de açın.- Singleton'larda ve hosted service'lerde enjekte edilmiş
DbContextyerineIDbContextFactory<T>ya daIServiceScopeFactory.CreateAsyncScope()kullanın. - Singleton'ları thread-safe yapın ve içlerinde asla istek verisi saklamayın.
- Tek bir interface'in isimli implementasyonlarına ihtiyacınız varsa keyed service'leri (.NET 8+) kullanın.
HttpClientiçin ya kısa ömürlü client'larlaIHttpClientFactory'yi ya daPooledConnectionLifetimeayarlı uzun ömürlü bir client'ı seçin. İkisinin arası olmaz.
- ---
- baslik: "ASP.NET Core'da servis ömürleri: transient, scoped, singleton ve captive dependency tuzağı"
- ozet: "Transient, scoped ve singleton gerçekte ne demek, scoped bir DbContext singleton içinde nasıl esir kalır, scope doğrulaması bunu nasıl yakalar; background service'ler, thread safety, keyed service'ler (.NET 8) ve HttpClient ömürleri nasıl yönetilir."
- tarih: 2026-08-20
- etiketler:
- - dotnet
- - aspnet-core
- - dependency-injection
- kategoriler:
- - dotnet
- ---
- Transient, scoped ve singleton'ın tanımı üç satıra sığar. Production'da asıl patlayan şey bunları birbirine karıştırmak. Scoped bir `DbContext` alan singleton, o tek context'i uygulama boyunca yaşatır ve eş zamanlı bütün isteklerle paylaşır. Sonuç ara ara gelen "second operation started on this context" hataları, bayat veri ve hiç durmadan büyüyen bir change tracker olur. Kendi makinenizde tek başınıza test ederken bunların hiçbiri görünmez.
- Bu yazıda her ömrün pratikte ne anlama geldiğini ve, daha önemlisi, birbirlerini esir almalarını nasıl önleyeceğinizi anlatıyorum: scope doğrulaması, background service'ler, singleton'larda thread safety, keyed service'ler ve `HttpClient`.
- ## Üç servis ömrü
- - **Transient**: container her çözümlediğinde yeni bir instance.
- - **Scoped**: her scope için bir instance. ASP.NET Core'da scope bir HTTP isteğidir (Blazor'da bir circuit).
- - **Singleton**: uygulama boyunca tek bir instance. Tüm istekler ve tüm thread'ler aynı nesneyi paylaşır.
- - Bir servis, kendisinden *daha kısa* ömürlü bir şeye asla bağımlı olmamalı. Scoped bir `DbContext`'i tutan singleton klasik hatadır. Buna **captive dependency** deniyor, yani kısa ömürlü bağımlılığın uzun ömürlü bir servisin içinde "esir" kalması.
- - Development ortamında `WebApplicationBuilder` scope doğrulamasını (`ValidateScopes` ve `ValidateOnBuild`) açar ve bu hataların çoğunu uygulama açılırken yakalar. Production'da siz açmadıkça kapalıdır.
- ## Ömürler pratikte
- ```csharp
- builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();
- builder.Services.AddScoped<ICartService, CartService>();
- builder.Services.AddSingleton<IExchangeRateCache, ExchangeRateCache>();
- ```
- Aynı istek içinde hem `CheckoutController` hem `DiscountService` `ICartService` isterse ikisi de *aynı* `CartService`'i alır. Kullanıcının sepeti ya da unit of work gibi istek başına durum için istediğiniz tam olarak budur. `PriceFormatter`'dan ise her biri kendi kopyasını alır. `ExchangeRateCache`'i uygulamadaki bütün istekler paylaşır.
- Benim pratik kurallarım:
- - Durumsuz ve ucuzsa transient de olur, singleton da. Çok sık çözümlenen durumsuz servislerde singleton'a yatkınım.
- - İstek başına durum tutuyorsa ya da `DbContext`'e bağımlıysa scoped.
- - Oluşturması pahalı ve thread'ler arasında güvenle paylaşılabiliyorsa singleton: cache'ler, konfigürasyon, derlenmiş regex'ler, pooled handler'lı `HttpClient`.
- `AddDbContext` context'i varsayılan olarak scoped kaydeder. ASP.NET Core dokümantasyonu da açıkça söylüyor: servisler kendi ömürlerinden kısa ömürlü bir veritabanı context'i kullanmamalı.
- ## Captive dependency
- Hatanın klasik hali şöyle:
- ```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` bir kez oluşturuluyor. Ona verilen `ShopDbContext` de onunla birlikte bir kez oluşuyor ve hiç dispose edilmiyor. Artık tek bir context eş zamanlı bütün isteklerce paylaşılıyor. `DbContext` thread-safe değil. Yük altında `InvalidOperationException: A second operation was started on this context instance before a previous operation completed` hatasını alırsınız. Change tracker sürekli büyür, sorgular bayat takip edilen entity'ler döndürmeye başlar. Tek kullanıcıyla kendi makinenizde ise kusursuz çalışır.
- Aynı şey transient servislerde daha sessiz bir şekilde olur. Singleton'a enjekte edilen bir transient, singleton ne kadar yaşarsa o kadar yaşar. Container bu konuda sizi uyarmaz.
- ### Scope doğrulaması
- Development'ta `WebApplicationBuilder`, service provider'ı `ValidateScopes` ve `ValidateOnBuild` ikisi de `true` olacak şekilde oluşturur. Yukarıdaki kod açılışta *Cannot consume scoped service 'ShopDbContext' from singleton 'IProductCatalog'* benzeri bir mesajla patlar. "Sadece production'da bozuluyor" durumunun nedeni tam olarak bu: production varsayılan olarak doğrulama yapmıyor.
- Ben doğrulamayı her yerde, en azından CI ve staging'de açıyorum:
- ```csharp
- builder.Host.UseDefaultServiceProvider(options =>
- {
- options.ValidateScopes = true;
- options.ValidateOnBuild = true;
- });
- ```
- `ValidateOnBuild`, provider oluşturulurken bütün kayıtları kontrol eder, bu da açılış süresine bir miktar ekler. Kör noktaları da var: open generic kayıtlar doğrulanmaz, servisleri elle çözümleyen factory lambda'larının içine de bakamaz.
- ### Bağımlılığı çözmek
- En basit çözüm genellikle tüketen servisi de scoped yapmak. Gerçekten singleton olması gerekiyorsa (diyelim ki bir cache tutuyor) ona kısa ömürlü context'ler üretmenin bir yolunu verin:
- ```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 service'ler kendi scope'unu açmalı
- Bir `BackgroundService`, `AddHostedService` üzerinden singleton olarak kaydedilir ve onun için bir istek scope'u yoktur. Oraya constructor ile `DbContext` enjekte etmek aynı captive dependency demek. `IServiceScopeFactory` enjekte edin ve her iş birimi için bir scope açın:
- ```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");
- }
- }
- }
- }
- ```
- `CreateScope()` yerine `await using` ile `CreateAsyncScope()` kullanmayı tercih ediyorum. Scope içindeki servislerden biri sadece `IAsyncDisposable` uyguluyorsa scope'u senkron dispose etmek exception fırlatır. Async scope iki durumu da halleder.
- ## Singleton'lar thread'ler arasında paylaşılır
- Singleton, bütün isteklerin aynı anda eriştiği tek bir nesnedir. İçindeki değişebilir her durum thread-safe olmalı:
- ```csharp
- public sealed class ExchangeRateCache : IExchangeRateCache
- {
- // Dictionary<,> değil: eş zamanlı yazmalar onu bozabilir.
- private readonly ConcurrentDictionary<string, decimal> _rates = new();
- public decimal GetOrAdd(string currency, Func<string, decimal> load) =>
- _rates.GetOrAdd(currency, load);
- }
- ```
- Singleton'ınızın neyi yakaladığına da bakın. `IHttpContextAccessor` enjekte etmek sorun değil, çünkü o anki isteği ihtiyaç anında okur. `HttpContext`'i ya da o anki kullanıcıyı singleton'ın bir field'ında saklamak ise sorun.
- ## Birden fazla implementasyon: keyed service'ler
- İlgili bir ihtiyaç da aynı interface'in birden fazla implementasyonunu kaydedip birini isimle seçmek. Geleneksel yaklaşımlar `IEnumerable<T>`'ye ya da factory delegate'lerine dayanıyor, ikisi de hâlâ çalışıyor. .NET 8'den beri container keyed service'leri doğrudan destekliyor:
- ```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` ve `AddKeyedTransient` de mevcut. Anahtarların string olması gerekmiyor. Eşitliği düzgün tanımlanmış her nesne olur, sihirli string yerine bir enum çoğu zaman daha iyi bir seçim. Anahtar ancak çalışma anında belli oluyorsa `GetRequiredKeyedService<T>(key)` var. Bu service locator bölgesi, o yüzden onu tek bir küçük factory sınıfının içinde tutuyorum.
- ## HttpClient ömürleri
- Her çağrıda `new HttpClient()` oluşturmak socket'leri tüketebilir. Tek bir tanesini sonsuza kadar tutmak ise DNS değişikliklerini hiç fark etmemesi demek. Microsoft'un önerisi iki seçenek sunuyor.
- Ya handler'ları `IHttpClientFactory` yönetsin, typed client'larla:
- ```csharp
- builder.Services.AddHttpClient<CatalogApiClient>(client =>
- {
- client.BaseAddress = new Uri("https://catalog.example.com/");
- });
- ```
- Typed client'lar transient kaydedilir ve kısa ömürlü olmaları beklenir. Birini bir singleton içinde yakalarsanız bayat DNS sorununa geri dönersiniz.
- Ya da uzun ömürlü bir client kullanın ve bağlantıları handler yenilesin:
- ```csharp
- builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler
- {
- PooledConnectionLifetime = TimeSpan.FromMinutes(2)
- }));
- ```
- İkisi de doğru. Doğru olmayan, factory'nin ürettiği bir client'ı sonsuza kadar tutan bir singleton ya da her istekte oluşturulup dispose edilen bir client.
- ## Kontrol listesi
- - Lifetime seçmeden önce servisin hangi durumu tuttuğunu ve neye bağımlı olduğunu sorun.
- - Uzun ömürlü bir servisin kısa ömürlü bir servise bağımlı olmasına asla izin vermeyin. Singleton içindeki transient'lara da dikkat edin.
- - `ValidateScopes` ve `ValidateOnBuild`'i Development dışında da, en azından CI ya da staging'de açın.
- - Singleton'larda ve hosted service'lerde enjekte edilmiş `DbContext` yerine `IDbContextFactory<T>` ya da `IServiceScopeFactory.CreateAsyncScope()` kullanın.
- - Singleton'ları thread-safe yapın ve içlerinde asla istek verisi saklamayın.
- - Tek bir interface'in isimli implementasyonlarına ihtiyacınız varsa keyed service'leri (.NET 8+) kullanın.
- - `HttpClient` için ya kısa ömürlü client'larla `IHttpClientFactory`'yi ya da `PooledConnectionLifetime` ayarlı uzun ömürlü bir client'ı seçin. İkisinin arası olmaz.