TekinAli ⎇ main

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 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

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.
  • 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.
tekinali.db · salt okunur · ● bağlı

SQLite 3.53.3 · 8 tablo, 2 view.

Soldaki bir tabloya tıkla, aşağıdan bir sorgu seç ya da kendin yaz. Yardım: \?

↑↓ seç · Enter aç · Esc kapat