02.0c7b4bdd numaralı fikir

Singleton, Scoped, Transient: .NET'te Hangisini Ne Zaman Kullanmalıyız?

Singleton, Scoped ve Transient, .NET Dependency Injection sisteminin en temel konularından biri olsa da gerçek projelerde aralarındaki farkı doğru uygulamak her zaman kolay değil. Bir servisin ne zaman oluşturulduğunu, ne kadar süre yaşadığını ve hangi nesnelerle aynı örneği paylaştığını anlamak, doğru yaşam döngüsünü seçmenin temelini oluşturuyor. Bu yazıda üç yaklaşımı çalışan kod örnekleri, nesne kimlikleri ve gerçek proje senaryolarıyla karşılaştırdım. Ayrıca DbContext, BackgroundService, IDisposable ve thread safety üzerinden yanlış servis kaydının nasıl sorunlara yol açabileceğini inceledim.

İçindekiler
Singleton, Scoped, Transient: .NET'te Hangisini Ne Zaman Kullanmalıyız?

Yıllardır .NET geliştiriyorum. Farklı projelerde çalıştım, servisler yazdım, mimari kararlar aldım. Buna rağmen bazen yeni bir servis kaydederken durup düşünmem gerekiyor: Bunu Scoped mı yapmalıydım, yoksa Transient olması daha mı doğru? Bence bunda garip bir şey yok. Çünkü Singleton, Scoped ve Transient isimlerini bilmek başka, gerçek bir uygulamada hangi seçeneğin doğru olacağını anlamak başka. Üçünün de ne yaptığını birkaç cümlede anlatabiliriz. Fakat iş bir servisin başka bir servise bağımlı olduğu, nesnelerin durum tuttuğu veya bir arka plan işinin saatlerce çalıştığı senaryolara geldiğinde ezberlediğimiz tanımlar tek başına yeterli olmuyor. Örneğin AddTransient ile kaydettiğimiz bir nesne neden beklediğimizden uzun süre yaşıyor? Scoped olarak kaydettiğimiz bir servis neden her metot çağrısında yeniden oluşturulmuyor? Bir Singleton servisin içine DbContext enjekte edersek ne olur? Bu yazıda tam olarak bunları ele alacağız. Önce temel davranışları kodla gözlemleyecek, sonra gerçek projelerde karşılaşabileceğimiz durumlara geçeceğiz. Hedefim üç metodu ezberletmek değil. Bir sonraki projenizde servis kaydederken neden belirli bir yaşam döngüsünü seçtiğinizi açıklayabilecek bir bakış açısı kazandırmak.

Önce şu üç kavramı birbirinden ayıralım

ASP.NET Core uygulamalarında servislerimizi genellikle Program.cs içerisinde kaydediyoruz:

builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<ICacheService, CacheService>();

Bu kayıtlar, DI container'a hangi arayüzün hangi sınıfla karşılanacağını ve oluşturulan nesnelerin nasıl yönetileceğini söylüyor. Burada üç ayrı soruyu birbirine karıştırmamak gerekiyor:

  • Oluşturulma: Nesne ne zaman ve kaç kez oluşturuluyor?
  • Paylaşım: Farklı bileşenler aynı nesne örneğini mi kullanıyor?
  • Yaşam döngüsü: Nesne ne kadar süre tutuluyor ve yönetilen kaynakları ne zaman serbest bırakılıyor?

Bu ayrım önemli. Çünkü yeni bir nesne oluşturulmasıyla mevcut bir nesnenin kullanımının devam etmesi aynı şey değil. Önce temel tabloya bakalım.

Yaşam döngüsü Aynı scope içinde yeniden çözümleme Yeni scope'ta çözümleme
Transient Yeni örnek Yeni örnek
Scoped Aynı örnek Yeni örnek
Singleton Aynı örnek Aynı örnek

Tablodaki Singleton davranışı, aynı DI container üzerinden yapılan çözümlemeler için geçerlidir. Uygulamada birden fazla container oluşturursanız her container kendi Singleton örneğini yönetebilir. Scoped sütunundaki davranış ise aynı scope'un korunmasına bağlıdır. ASP.NET Core'da her HTTP isteği için varsayılan olarak bir scope oluşturulur. Ancak scope kavramı yalnızca HTTP isteklerinden ibaret değildir. Şimdi bu tabloyu ezberlemek yerine gerçekten ne anlama geldiğini görelim.

1. Transient: Yeni örnek ne zaman oluşturulur?

Transient, DI container üzerinden her servis çözümlemesinde yeni bir örnek oluşturulmasını sağlar.

builder.Services.AddTransient<OperationService>();

Bir nesnenin oluşturulduğunu takip etmek için basit bir sınıf yazalım:

public sealed class OperationService
{
    public Guid Id { get; } = Guid.NewGuid();
}

Şimdi aynı scope içerisinde bu servisi iki kez çözelim:

using var scope = app.Services.CreateScope();

var first = scope.ServiceProvider
    .GetRequiredService<OperationService>();

var second = scope.ServiceProvider
    .GetRequiredService<OperationService>();

Console.WriteLine(first.Id);
Console.WriteLine(second.Id);
Console.WriteLine(ReferenceEquals(first, second));

Beklediğimiz sonuç şu:

a1b2c3d4-...
e5f6a7b8-...
False

GUID değerleri örnektir. Asıl önemli olan, ReferenceEquals sonucunun False olmasıdır. Çünkü iki değişken farklı nesneleri gösteriyor. Burada özellikle aynı scope'u kullandık. Transient bir servisin farklı örnekler oluşturması için yeni bir HTTP isteğine veya yeni bir scope'a ihtiyacı yok. Aynı scope içerisinde yapılan iki ayrı çözümleme de iki ayrı nesne üretebilir.

Peki bir servise iki kez erişmek de iki nesne oluşturur mu?

Hayır. İşte en çok karıştırılan noktalardan biri bu. Şöyle bir servisimiz olsun:

public sealed class ReportService
{
    private readonly OperationService _operation;

    public ReportService(OperationService operation)
    {
        _operation = operation;
    }

    public Guid GetOperationId() => _operation.Id;
}

DI kayıtlarımızı şöyle yapalım:

builder.Services.AddTransient<OperationService>();
builder.Services.AddTransient<ReportService>();

Şimdi ReportService bir kez oluşturulduğunda, constructor içerisindeki OperationService bağımlılığı da çözümlenir. Oluşturulan ReportService, bu bağımlılığı kendi alanında tutar.

var report = scope.ServiceProvider
    .GetRequiredService<ReportService>();

Console.WriteLine(report.GetOperationId());
Console.WriteLine(report.GetOperationId());

İki çağrıda da aynı GUID değerini görürüz. Neden? Çünkü GetOperationId() metodu DI container'dan yeni bir servis istemiyor. Daha önce oluşturulmuş nesneyi kullanıyor. Bence Transient kavramını anlamanın en önemli adımlarından biri bu: Transient, nesneye her erişimde değil, servis her yeniden çözümlendiğinde yeni örnek oluşturur.

Transient hangi durumlarda mantıklı?

Örneğin her çağrıda ayrı bir nesne olarak kullanılmasını istediğimiz, kendi içinde uzun süreli durum tutmayan bir metin biçimlendirme servisini düşünelim.

builder.Services.AddTransient<ITextFormatter, TextFormatter>();

Burada her çözümlemede yeni bir örnek oluşturulması doğal bir tercih olabilir. Ancak Transient demek, sınıfın mutlaka küçük, ucuz veya durumsuz olması gerektiği anlamına gelmez. Bunlar tasarım değerlendirmeleridir. Servis her çözümlemede yeniden oluşturulacağı için maliyetli nesnelerde bu tercihin sonuçlarını ayrıca düşünmek gerekir. Bir diğer konu da bağımlılıkların yaşam döngüsüdür. Transient bir nesne bir başka servis tarafından tutuluyorsa, onu tutan nesnenin ömrü boyunca yaşamaya devam edebilir. Bir sonraki bölümde bu davranışın neden önemli olduğunu daha iyi göreceğiz.

2. Scoped: Aynı iş birimi boyunca aynı örnek

Scoped, aynı DI scope içerisinde yapılan çözümlemelerde aynı servis örneğinin kullanılmasını sağlar.

builder.Services.AddScoped<OperationService>();

Az önceki kodu değiştirmeden çalıştırdığımızı varsayalım. Tek fark, OperationService kaydının Scoped olması:

using var scope = app.Services.CreateScope();

var first = scope.ServiceProvider
    .GetRequiredService<OperationService>();

var second = scope.ServiceProvider
    .GetRequiredService<OperationService>();

Console.WriteLine(first.Id);
Console.WriteLine(second.Id);
Console.WriteLine(ReferenceEquals(first, second));

Bu kez sonuç şöyle olur:

a1b2c3d4-...
a1b2c3d4-...
True

İki çözümleme de aynı scope üzerinden yapıldığı için aynı nesne örneğini elde ederiz. Yeni bir scope oluşturduğumuzda ise durum değişir:

using var anotherScope = app.Services.CreateScope();

var third = anotherScope.ServiceProvider
    .GetRequiredService<OperationService>();

Console.WriteLine(third.Id);
Console.WriteLine(ReferenceEquals(first, third));

Yeni GUID değerini ve False sonucunu görürüz. Çünkü yeni scope, Scoped servis için yeni bir örnek oluşturulmasını gerektirir.

Scoped neden web uygulamalarında bu kadar yaygın?

Bir HTTP isteğinin başından sonuna kadar birden fazla servis çalışabilir. Controller, uygulama servisi ve repository gibi bileşenler aynı iş akışının parçası olabilir. Örneğin kullanıcı bilgilerini işleyen iki servis düşünelim:

builder.Services.AddScoped<ICurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IUserService, UserService>();

Aynı request scope içerisindeki OrderService ve UserService, ICurrentUserContext bağımlılığını çözdüklerinde aynı örneği alırlar. Bu, o istek sırasında ortak bir bağlamın kullanılmasını sağlayabilir. Tabii burada tuttuğumuz verinin gerçekten istek kapsamında paylaşılması gereken bir veri olması şart. Örneğin kullanıcıya özel bir bilgi yanlışlıkla Singleton bir serviste tutulursa, farklı isteklerin aynı nesneye erişmesi nedeniyle veri izolasyonu sorunları yaşanabilir. Böyle bir durumda yalnızca performansı değil, verinin hangi sınırlar içerisinde paylaşılabileceğini de değerlendirmek gerekir.

Scoped ile HTTP isteği aynı şey değil

Bu ayrımı özellikle vurgulamak istiyorum. Scoped servisin yaşam döngüsü HTTP isteğine değil, DI scope'una bağlıdır. ASP.NET Core bir HTTP isteği için scope oluşturur ve istek tamamlandığında bu scope'u kapatır. Fakat uygulamanın başka bölümlerinde de IServiceScopeFactory aracılığıyla yeni scope'lar oluşturabiliriz. Örneğin bir dosya işleme sürecinde her dosyayı ayrı bir iş birimi olarak ele almak isteyebiliriz. Her dosya için ayrı scope oluşturursak o iş biriminde kullanılan Scoped servisler birbirinden ayrılır. Bu yaklaşım özellikle arka plan işlerinde, kuyruktan alınan mesajların işlendiği sistemlerde ve uzun süre çalışan worker uygulamalarında önem kazanır.

Scoped ne zaman tercih edilmeli?

  • Bir HTTP isteği veya belirli bir iş birimi boyunca aynı örneği paylaşmak istediğimizde.
  • Aynı iş akışındaki servislerin ortak bir bağımlılığı kullanması gerektiğinde.
  • Entity Framework Core gibi Scoped yaşam döngüsüyle yaygın biçimde kullanılan bileşenlerle çalışırken.
  • Birbirinden bağımsız işlerin kendi servis örneklerine sahip olmasını istediğimizde.

Burada akılda tutulması gereken ifade basit: Aynı scope içerisinde aynı örnek, farklı scope'larda farklı örnek. Fakat Scoped servislerin eş zamanlı erişime karşı otomatik olarak güvenli hâle gelmediğini de unutmamalıyız. Aynı scope içerisindeki bir nesneye birden fazla iş parçacığı erişiyorsa, sınıfın kendi thread safety kurallarını ayrıca değerlendirmemiz gerekir.

3. Singleton: Aynı nesnenin uygulama boyunca paylaşılması

Singleton, aynı DI container tarafından çözümlenen servis için tek bir örneğin kullanılmasını sağlar.

builder.Services.AddSingleton<OperationService>();

Bu kez servis iki farklı scope üzerinden çözümlense bile aynı nesne örneğini alırız:

using var firstScope = app.Services.CreateScope();
using var secondScope = app.Services.CreateScope();

var first = firstScope.ServiceProvider
    .GetRequiredService<OperationService>();

var second = secondScope.ServiceProvider
    .GetRequiredService<OperationService>();

Console.WriteLine(first.Id);
Console.WriteLine(second.Id);
Console.WriteLine(ReferenceEquals(first, second));

Her iki GUID aynı olur ve son satır True döndürür. Bu örnek Singleton'ın temel davranışını gösteriyor. Nesne bir kez oluşturulduktan sonra aynı container üzerinden yapılan sonraki çözümlemelerde yeniden kullanılmaya devam eder. Ancak burada bir yanılgıya düşmemek gerekiyor: Singleton olmak, bir nesnenin mutlaka uygulama açılır açılmaz oluşturulması anlamına gelmez. Türüne ve kayıt şekline bağlı olarak Singleton örneği ilk ihtiyaç duyulduğunda oluşturulabilir. Önemli olan, container'ın oluşturduğu örneği sonraki çözümlemelerde paylaşmasıdır.

Singleton seçmek performans açısından her zaman daha iyi mi?

Hayır. En azından ben bir servisi yalnızca daha az nesne oluşturmak için Singleton yapmayı doğru bir yaklaşım olarak görmüyorum. Örneğin bir cache servisi uygulama genelinde paylaşılan verileri tutmak için Singleton olarak tasarlanabilir. Ancak bu durumda aynı cache nesnesine birden fazla istek erişebilir. Dolayısıyla içeride kullanılan koleksiyonların, güncelleme işlemlerinin ve eş zamanlı erişimin güvenli biçimde yönetilmesi gerekir. Şöyle bir örnek düşünelim:

public sealed class RequestCounter
{
    public int Count { get; private set; }

    public void Increment()
    {
        Count++;
    }
}

Singleton olarak kaydedelim:

builder.Services.AddSingleton<RequestCounter>();

Bu nesneye erişen bütün istekler aynı sayacı kullanır. Ancak Count++ işlemi eş zamanlı erişimde güvenli değildir. Birden fazla istek aynı anda artırma yaptığında güncellemeler çakışabilir. Basit bir sayaç için Interlocked kullanılabilir:

public sealed class RequestCounter
{
    private int _count;

    public int Count => Volatile.Read(ref _count);

    public void Increment()
    {
        Interlocked.Increment(ref _count);
    }
}

Burada atomik artırma işlemi kullanıyoruz. Yine de bu kodun bütün sayaç tasarımları için yeterli olduğu sonucuna varamayız; gereksinime göre okuma, sıfırlama ve diğer işlemlerin davranışı da düşünülmelidir. Ayrıca bu örnek yalnızca tek bir uygulama süreci içindeki sayacı yönetir. Uygulamayı birden fazla sunucuda çalıştırıyorsak her sunucunun kendi Singleton örneği olacaktır. Tüm sunucuların ortak sayaca ihtiyacı varsa dağıtık bir çözüm gerekir.

Singleton ne zaman tercih edilmeli?

  • Uygulama genelinde paylaşılması gereken servislerde.
  • Eş zamanlı erişimi güvenli şekilde yönetilmiş cache veya yardımcı bileşenlerde.
  • Gerçekten uygulama genelinde tek örnek olarak yönetilmesi gereken nesnelerde.

Singleton seçerken kendime şu soruyu sormayı önemli buluyorum: Bu nesneyi bütün isteklerle paylaşmak gerçekten doğru mu, yoksa yalnızca tekrar oluşturma maliyetinden kaçınmaya mı çalışıyorum? İkinci durumdaysak, kararımızı tekrar gözden geçirmekte fayda var.

4. Üç yaşam döngüsünü aynı senaryoda karşılaştıralım

Şimdiye kadar her birini ayrı ayrı inceledik. Fakat aralarındaki farkı daha net görmek için üçünü aynı uygulamada kullanalım. Önce ortak bir arayüz oluşturalım:

public interface IOperation
{
    Guid Id { get; }
}

public sealed class Operation : IOperation
{
    public Guid Id { get; } = Guid.NewGuid();
}

Gerçek bir projede her yaşam döngüsü için ayrı sınıflar kullanabiliriz. Burada kod tekrarını azaltmak için aynı sınıfı üç farklı şekilde kaydedelim:

builder.Services.AddTransient<TransientOperation>();
builder.Services.AddScoped<ScopedOperation>();
builder.Services.AddSingleton<SingletonOperation>();

Bu sınıfların her birinin Id özelliğinin constructor sırasında oluşturulduğunu düşünelim. Aynı scope içerisinde her servisi iki kez çözümlersek beklenen davranış şöyledir:

Servis İlk çözümleme İkinci çözümleme
Transient Yeni örnek Yeni örnek
Scoped Yeni örnek Aynı örnek
Singleton Yeni örnek Aynı örnek

Yeni bir scope oluşturduğumuzda ise Scoped ve Transient servisler için yeni örnekler elde ederiz. Singleton aynı container üzerinden çözülmeye devam ettiği için önceki örneğini kullanır. Bu test, üç yaşam döngüsünün temel davranışını görmemizi sağlar. Fakat önemli bir ayrıntı var: Servisin bir sınıfa enjekte edilmesi ile container üzerinden tekrar çözülmesi aynı şey değildir. Bir sınıf constructor'ında bir Transient bağımlılık almışsa o bağımlılığı alan sınıf, referansı kendi içerisinde tutabilir. DI container her metot çağrısında araya girip yeni nesne oluşturmaz. Bu yüzden yaşam döngüsünü değerlendirirken yalnızca AddTransient, AddScoped veya AddSingleton satırına bakmak yeterli değildir. Nesnelerin nasıl çözümlendiğini ve birbirlerini ne kadar süre tuttuğunu da anlamak gerekir.

5. DbContext neden Singleton olmamalı?

Entity Framework Core kullanan projelerde servis yaşam döngülerinin önemini en net görebildiğimiz örneklerden biri DbContext. Çoğumuz şu kaydı kullanıyoruz:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection"));
});

AddDbContext, varsayılan olarak Scoped yaşam döngüsü kullanır. Bunun nedenlerinden biri, DbContext nesnesinin bir iş birimi boyunca entity'leri takip etmesi ve değişiklikleri yönetmesidir. Örneğin bir sipariş oluşturma işleminde:

  1. Sipariş veritabanından okunur veya yeni bir sipariş oluşturulur.
  2. İlgili entity'lerde değişiklik yapılır.
  3. Değişiklikler SaveChangesAsync ile kaydedilir.
  4. İş birimi tamamlanır.

Aynı iş birimi içerisinde aynı context örneğinin kullanılması, takip edilen entity'lerin ve değişikliklerin tutarlı şekilde yönetilmesini sağlar. Peki DbContext'i Singleton yapsak ne olur? İlk problem, context'in uzun süre boyunca farklı işlemler arasında paylaşılmasıdır. Bir önceki işlemin takip ettiği entity'ler, context'in durumu ve sonraki işlemler arasındaki etkileşimler beklenmedik sonuçlara yol açabilir. İkinci ve daha kritik problem ise DbContext'in thread-safe olmamasıdır. Aynı context üzerinde eş zamanlı işlemler yürütmek desteklenen bir kullanım değildir. Singleton olarak kaydetmek bu problemi daha görünür hâle getirir. Bu nedenle standart web uygulamalarında DbContext'i Singleton yapmak genellikle doğru bir seçim değildir. Elbette bir uygulamanın veritabanı erişim tasarımı farklı olabilir. Örneğin IDbContextFactory<TContext> ile gerektiğinde ayrı context örnekleri oluşturmak mümkündür. Buradaki önemli nokta, context'in yaşam döngüsünü işin gereksinimine uygun şekilde yönetmektir.

6. BackgroundService içerisinde Scoped servis kullanmak

Şimdi web isteklerinden bağımsız çalışan bir senaryoya geçelim. Diyelim ki her beş dakikada bir bekleyen kayıtları işleyen bir BackgroundService yazıyoruz. Bu servis içerisinde DbContext kullanan bir uygulama servisimiz de var. İlk akla gelen yapı şu olabilir:

builder.Services.AddHostedService<ReportWorker>();
builder.Services.AddScoped<IReportService, ReportService>();

Buradaki önemli ayrıntı, AddHostedService ile kaydedilen worker'ın uygulama tarafından uzun süre yaşayan bir servis olarak yönetilmesidir. Dolayısıyla worker'ın constructor'ına doğrudan Scoped IReportService enjekte etmek doğru bir yaklaşım değildir. Scope doğrulaması etkin olduğunda bu bağımlılık zinciri hata verir. Doğrulama kapalı olsa bile Scoped bağımlılık yanlışlıkla worker'ın yaşam döngüsü boyunca tutulabilir. Bunun yerine IServiceScopeFactory ile her iş için ayrı bir scope oluşturabiliriz:

public sealed class ReportWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public ReportWorker(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();

            var reportService = scope.ServiceProvider
                .GetRequiredService<IReportService>();

            await reportService.GenerateAsync(stoppingToken);

            await Task.Delay(
                TimeSpan.FromMinutes(5),
                stoppingToken);
        }
    }
}

Bu örnekte worker'ın kendisi uzun süre yaşar. Ancak her iş döngüsünde yeni bir scope oluşturulur. IReportService ve onun Scoped bağımlılıkları bu scope içerisinde çözülür; işlem tamamlandığında scope kapatılır. Burada üretim ortamı için iki noktaya dikkat etmek gerekir:

  • İptal sinyali geldiğinde Task.Delay gibi bekleyen işlemler OperationCanceledException üretebilir. Uygulamanın durdurulma davranışı buna göre ele alınmalıdır.
  • Bir iş hata verdiğinde worker'ın tamamen durmasını mı, hatayı kaydedip sonraki döngüde devam etmesini mi istediğimize karar vermeliyiz.

Ayrıca GenerateAsync içerisinde asenkron dispose gerektiren kaynaklar varsa CreateAsyncScope ve await using kullanmak uygun olabilir. Bu örnek bize şunu gösteriyor: Singleton ve Scoped servislerin aynı uygulamada bulunması başlı başına sorun değildir. Önemli olan, bağımlılıkların doğru scope içerisinde çözümlenmesidir.

7. Transient bir servis neden beklediğimizden uzun yaşayabilir?

Bu konuya özellikle değinmek istiyorum. Çünkü yalnızca servis kaydına bakarak nesnenin gerçek yaşam süresini anlamaya çalışmak yanıltıcı olabilir. Diyelim ki aşağıdaki gibi bir yapı var:

builder.Services.AddTransient<IFormatter, Formatter>();
builder.Services.AddSingleton<ReportService>();

ReportService, constructor'ında IFormatter bağımlılığı alıyor olsun:

public sealed class ReportService
{
    private readonly IFormatter _formatter;

    public ReportService(IFormatter formatter)
    {
        _formatter = formatter;
    }
}

Singleton ReportService ilk kez oluşturulduğunda Formatter bağımlılığı da çözülür. Bu Formatter örneği daha sonra Singleton'ın alanında tutulduğu için aynı örnek ReportService yaşadığı sürece kullanılmaya devam eder. Yani Formatter'ın kaydı Transient olsa bile her rapor oluşturma işleminde otomatik olarak yeni bir Formatter örneği oluşmaz. Bu durum tek başına bir hata değildir. Formatter durumsuz ve güvenli bir bileşense gayet uygun olabilir. Ancak Formatter kendi içerisinde değişken durum tutuyorsa veya dispose edilmesi gereken bir kaynağı yönetiyorsa bu tasarımı yeniden değerlendirmek gerekir. Bir servisin yaşam döngüsü hakkında karar verirken yalnızca kendisine değil, bağımlılık zincirine de bakmamızın nedeni bu.

8. IDisposable ve nesnelerin temizlenmesi

Servis yaşam döngüsünün bir diğer boyutu da kaynak yönetimidir. Örneğin bir sınıf IDisposable uyguluyor olabilir:

public sealed class FileProcessor : IDisposable
{
    public void Process()
    {
        // Dosya işleme işlemleri
    }

    public void Dispose()
    {
        // Yönetilen kaynakların temizlenmesi
    }
}

DI container tarafından oluşturulan ve container'ın yönettiği servislerin dispose edilmesi, yaşam döngüsüyle yakından ilişkilidir.

  • Scoped: Scope kapatıldığında container tarafından yönetilen ve dispose gerektiren servisler temizlenir.
  • Singleton: Container'ın yaşam döngüsü sona erdiğinde dispose edilir.
  • Transient: Container tarafından oluşturulan dispose edilebilir örnekler de container'ın yaşam döngüsü yönetimine tabidir. Bir scope üzerinden çözümlenen örnekler genellikle ilgili scope kapanırken temizlenir.

Son maddeyi özellikle vurgulamak istiyorum. Transient olarak kaydedilen bir nesnenin her çözümlemede yeniden oluşturulması, onun her kullanım sonunda otomatik olarak dispose edileceği anlamına gelmez. Örneğin bir Transient servis Singleton bir servis tarafından tutuluyorsa, bu nesne Singleton ile birlikte yaşamaya devam edebilir. Kaynak yönetimi açısından bu davranışın farkında olmak gerekir. DI container'ın oluşturmadığı veya yönetmediği nesnelerde ise temizleme sorumluluğu farklı olabilir. Dolayısıyla dispose davranışını değerlendirirken nesnenin kim tarafından oluşturulduğunu da hesaba katmalıyız.

9. En sık yapılan hataları bir araya getirelim

Şimdiye kadar anlattıklarımızdan yola çıkarak gerçek projelerde dikkat edilmesi gereken noktaları toparlayalım.

Her servisi Singleton yapmak

Tek bir örnek kullanmanın performansı artıracağını düşünerek bütün servisleri Singleton yapmak doğru değil. Paylaşılan durum, eş zamanlı erişim ve bağımlılıkların yaşam döngüsü dikkate alınmalı.

Transient servisin her metot çağrısında yenileneceğini düşünmek

Bir nesne oluşturulduktan sonra başka bir sınıf tarafından tutuluyorsa, metot çağrıları yeni nesne oluşturmaz. Yeni örnek için yeni bir çözümleme gerekir.

Scoped servisi yalnızca HTTP isteğiyle ilişkilendirmek

Scoped, DI scope'una bağlıdır. HTTP isteği varsayılan senaryolardan biridir; arka plan işlerinde de ayrı scope'lar oluşturulabilir.

Singleton içine Scoped bağımlılığı doğrudan enjekte etmek

Bu durum Scoped nesnenin beklenenden uzun yaşamasına neden olabilir ve scope doğrulaması etkin olduğunda hata verir. Doğru çözüm, bağımlılığı uygun scope içerisinde kullanmaktır.

Thread safety konusunu göz ardı etmek

Singleton servisler paylaşılır. Scoped servisler de aynı scope içerisinde farklı bileşenler tarafından paylaşılabilir. Yaşam döngüsü, nesnenin eş zamanlı erişime karşı güvenli olduğunu garanti etmez.

Dispose edilebilir nesnelerin ömrünü düşünmemek

Bir nesnenin ne zaman oluşturulduğu kadar ne zaman temizlendiği de önemlidir. Özellikle uzun yaşayan servislerin tuttuğu bağımlılıkları incelerken bu konuyu gözden kaçırmamak gerekir.

10. Doğru yaşam döngüsünü nasıl seçebiliriz?

Benim yaklaşımım, bir servisi kaydetmeden önce aşağıdaki soruları değerlendirmek: 1. Bu nesne farklı işlemler arasında paylaşılmalı mı? Evetse Singleton veya Scoped gibi seçenekleri değerlendirebiliriz. Ancak paylaşımın hangi sınırlar içerisinde yapılması gerektiğini belirlemek şart. 2. Nesne yalnızca bir iş birimi boyunca mı yaşamalı? Örneğin aynı HTTP isteği sırasında kullanılan bir uygulama servisi için Scoped mantıklı olabilir. 3. Her çözümlemede yeni örnek oluşturulması gerekiyor mu? Bağımsız nesne örneklerine ihtiyaç duyuyorsak Transient uygun olabilir. 4. Nesne değişken durum tutuyor mu? Kullanıcıya veya isteğe özel veriler tutan bir nesneyi uygulama genelinde paylaşmak, ciddi tasarım sorunlarına neden olabilir. 5. Bağımlılıklarının yaşam döngüleri uyumlu mu? Singleton bir servisin Scoped bağımlılığı doğrudan tutması gibi durumlar özel olarak incelenmeli. 6. Nesne eş zamanlı erişime açık mı? Paylaşılan durum varsa thread safety gereksinimleri değerlendirilmeli. Bu soruların cevaplarına göre bir başlangıç kararı verebiliriz:

İhtiyaç Başlangıç tercihi
Her çözümlemede bağımsız örnek Transient
Bir scope boyunca ortak örnek Scoped
Container boyunca paylaşılan örnek Singleton
EF Core ile standart request tabanlı erişim Genellikle Scoped
Uzun yaşayan worker içerisinde Scoped bağımlılık Scope oluşturup bağımlılığı o scope'ta çözümlemek

Tablodaki tercihler mutlak kurallar değil. Örneğin EF Core'da IDbContextFactory<TContext> kullanmak veya bir worker için her işte ayrı scope oluşturmak gibi alternatifler ihtiyaca göre daha doğru olabilir. Benim için önemli olan, servis kaydının bir alışkanlıkla yapılmaması. Önce nesnenin sorumluluğunu ve paylaşım sınırlarını belirlemek, sonra yaşam döngüsünü seçmek daha sağlıklı.

Sonuç

Singleton, Scoped ve Transient arasındaki farkı anlamak için üç tanımı ezberlemek yeterli değil. Nesnenin nasıl oluşturulduğunu, hangi bileşenler tarafından tutulduğunu ve hangi noktada temizlendiğini birlikte düşünmek gerekiyor. Transient her çözümlemede yeni örnek oluşturur. Scoped aynı scope içerisinde aynı örneği paylaşır. Singleton ise aynı DI container üzerinden yapılan çözümlemelerde aynı örneği kullanır. Ancak gerçek projelerde karar verirken yalnızca bu davranışlara bakmamalıyız. DbContext gibi durum tutan nesneler, uzun yaşayan BackgroundService bileşenleri, dispose edilebilir kaynaklar ve thread safety gereksinimleri de tasarımın bir parçası. Açıkçası ben bu konuyu, bir servisin hangi metotla kaydedileceğini ezberlemekten çok, nesnenin uygulamadaki sorumluluğunu anlamak olarak görüyorum. Çünkü doğru yaşam döngüsü seçimi, yalnızca kodun çalışmasını değil, farklı istekler ve iş akışları arasında neyin paylaşıldığını da belirliyor. Bir sonraki servis kaydınızda kendinize şu soruyu sorun: Bu nesnenin ne kadar süre yaşaması gerektiğini gerçekten biliyor muyum? Cevabınız netse doğru yaşam döngüsünü seçmek de çok daha kolay olacaktır.

Bu yazıyı AI ile tartış

Yazıdaki yaklaşımı kendi bağlamınla sınamak, örnek istemek veya karşı görüş üretmek için bir sohbet başlat.

Bu konudaki uygulama desteği için yazılım mimarisi ve teknik danışmanlık hizmetini inceleyin ya da diğer notlara devam edin.