Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Design Patterns — #02

Singleton Pattern: Thread-Safe Lazy<T>, Static Field ve Neden DI Singleton Lifetime Daha İyi

Mert Özen 7 Kas 2026 14 dk 4 görüntülenme
Singleton Pattern: Thread-Safe Lazy<T>, Static Field ve Neden DI Singleton Lifetime Daha İyi

Tek instance gerekiyorsa en temiz yol klasik Singleton değil, DI container'da AddSingleton ile kaydetmektir: tek instance garantisi aynı kalır, global erişim ve gizli bağımlılık ortadan kalkar. Naif Singleton'daki race, double-checked locking'in tarihi, static readonly ve beforefieldinit, Lazy<T>'nin exception caching'i, captive dependency ve singleton içindeki paylaşılan state gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un fiyat listesi bir CSV (Comma-Separated Values) dosyasından yükleniyor ve yüklemesi pahalı. Birisi "bunu bir kez yükleyelim, herkes aynısını kullansın" demiş ve klasik bir Singleton yazmış: private constructor, static bir field, ilk erişimde oluşturma. Uygulama açılırken sekiz istek aynı anda PriceCatalog.Instance'a erişiyor. Constructor kaç kez çalışır, ortada kaç farklı fiyat listesi olur?

using Bookstore.Pricing;

Task<PriceCatalog>[] requests = Enumerable.Range(0, 8)
    .Select(_ => Task.Run(() => PriceCatalog.Instance))
    .ToArray();

PriceCatalog[] catalogs = await Task.WhenAll(requests);

Console.WriteLine($"Constructor calls: {PriceCatalog.ConstructorCalls}");
Console.WriteLine($"Distinct instances: {catalogs.Distinct().Count()}");

namespace Bookstore.Pricing
{
    public sealed class PriceCatalog
    {
        private static PriceCatalog? _instance;
        public static int ConstructorCalls;

        private PriceCatalog()
        {
            Interlocked.Increment(ref ConstructorCalls);
            Thread.Sleep(50); // fiyat listesini dosyadan yüklüyormuş gibi
        }

        public static PriceCatalog Instance
        {
            get
            {
                if (_instance is null)
                    _instance = new PriceCatalog();
                return _instance;
            }
        }
    }
}

Sekiz Thread, Sekiz Fiyat Listesi

Çıktı:

Constructor calls: 8
Distinct instances: 8

Sekiz istek, sekiz ayrı instance. Her thread _instance is null kontrolünü diğerleri constructor'dan çıkmadan yaptı ve hepsi null gördü. Constructor'daki 50 milisaniyelik bekleme durumu abartıyor ama ortadan kaldırmıyor; gerçek bir dosya okuması da bu pencereyi açar. Daha kötüsü, bu kod tek thread'li bir unit test'te kusursuz çalışır ve sorun yalnızca production'da, yük altında, ara sıra ortaya çıkar. Her biri farklı anda yüklenmiş sekiz fiyat listesi, aynı kitabın iki istekte iki farklı fiyatla satılması demek.

GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Singleton'ın amacını iki parçalı bir cümleyle tanımlar: bir class'ın yalnızca bir instance'ı olduğunu garanti et ve ona global bir erişim noktası sağla. Bu cümlede iki ayrı söz var ve yazının geri kalanı aslında bu ikisinin ayrıştırılması. Birinci söz, tek instance, çoğu zaman meşru bir ihtiyaç: pahalı bir cache, bir connection pool, bir fiyat listesi. İkinci söz, global erişim, sorunların çoğunun kaynağı. Önce birinci sözü doğru tutmanın yollarına, sonra ikincisinin bedeline bakalım.

Double-Checked Locking: Yıllarca Yanlış Yazılan Pattern

İlk akla gelen çözüm getter'ı bir lock ile sarmak. Doğru çalışır ama her erişimde lock alır; oysa instance oluştuktan sonra lock'a hiç gerek yok. Bu maliyeti azaltmak için 1990'larda yaygınlaşan çözüm double-checked locking: önce lock'suz kontrol et, instance yoksa lock al ve içeride bir daha kontrol et.

using Bookstore.Pricing;

PriceCatalog[] catalogs = await Task.WhenAll(
    Enumerable.Range(0, 8).Select(_ => Task.Run(() => PriceCatalog.Instance)));

Console.WriteLine($"Constructor calls: {PriceCatalog.ConstructorCalls}");
Console.WriteLine($"Distinct instances: {catalogs.Distinct().Count()}");

namespace Bookstore.Pricing
{
    public sealed class PriceCatalog
    {
        private static volatile PriceCatalog? _instance;
        private static readonly Lock Sync = new();
        public static int ConstructorCalls;

        private PriceCatalog()
        {
            Interlocked.Increment(ref ConstructorCalls);
            Thread.Sleep(50);
        }

        public static PriceCatalog Instance
        {
            get
            {
                if (_instance is null)              // 1. kontrol: lock'suz hızlı yol
                {
                    lock (Sync)
                    {
                        if (_instance is null)      // 2. kontrol: lock'u bekleyen diğer thread'ler için
                            _instance = new PriceCatalog();
                    }
                }
                return _instance;
            }
        }
    }
}
Constructor calls: 1
Distinct instances: 1

Bu kodun tarihi, pattern'lerin dil ve runtime ayrıntılarına ne kadar bağlı olduğunun güzel bir örneği. Java dünyasında double-checked locking yıllarca yanlış çalıştı: Java'nın eski memory model'i, constructor'ın içindeki yazmaların, referansın field'a atanmasından önce diğer thread'lere görünür olacağını garanti etmiyordu. Bir thread null olmayan ama yarı kurulmuş bir object görebiliyordu. Aralarında Joshua Bloch (Effective Java kitabının yazarı) ve Doug Lea'nın (Java'nın concurrency kütüphanesinin mimarı) da bulunduğu bir grup araştırmacı 2000'lerin başında "The Double-Checked Locking is Broken" başlıklı bir bildiri yayımladı. Sorun 2004'te Java 5 ile gelen yeni memory model'de, field volatile tanımlanırsa çözüldü.

.NET'te hikâye biraz farklı. ECMA (European Computer Manufacturers Association, bugünkü adıyla Ecma International) tarafından yayımlanan CLI (Common Language Infrastructure) standardı ECMA-335'in tarif ettiği memory model zayıftır ve kâğıt üzerinde aynı sorunu taşır. Microsoft'un runtime'ı ise daha güçlü garantiler verir: dotnet/runtime deposundaki memory model belgesi, yeni oluşturulmuş bir object'in referansını paylaşılan bir field'a yazmanın bir release olduğunu, yani object'in field'larının referanstan önce görünür olacağını açıkça söyler. Yani bugünkü .NET'te volatile'sız yazılmış double-checked locking pratikte doğru çalışır. Jon Skeet (C# in Depth kitabının yazarı) yine de Singleton makalesinde bu sürümü "kullanma" diye işaretler: yanlış yazmak kolay, okuyanın memory model bilmesini gerektiriyor ve aşağıdaki iki seçenekten ne daha hızlı ne daha okunaklı. Ben de yukarıdaki kodu yalnızca tarih için gösterdim; volatile keyword'ü niyeti belgeliyor, o kadar.

static readonly ve beforefieldinit Ayrıntısı

CLR (Common Language Runtime) bir tipin static field'larını initialize eden kodu, yani type initializer'ı, thread-safe biçimde ve yalnızca bir kez çalıştırmayı zaten garanti ediyor. Bu yüzden en kısa thread-safe Singleton tek satırdır: public static readonly PriceCatalog Instance = new();. Lock yok, kontrol yok. Tek soru, bu satırın ne zaman çalıştığı:

using System.Reflection;
using Bookstore.Pricing;

Console.WriteLine($"EagerCatalog  beforefieldinit: {typeof(EagerCatalog).Attributes.HasFlag(TypeAttributes.BeforeFieldInit)}");
Console.WriteLine($"StrictCatalog beforefieldinit: {typeof(StrictCatalog).Attributes.HasFlag(TypeAttributes.BeforeFieldInit)}");

Console.WriteLine(EagerCatalog.Describe());
Console.WriteLine(StrictCatalog.Describe());

Console.WriteLine("Touching Instance...");
_ = EagerCatalog.Instance;
_ = StrictCatalog.Instance;

namespace Bookstore.Pricing
{
    public sealed class EagerCatalog
    {
        public static readonly EagerCatalog Instance = new();

        private EagerCatalog() => Console.WriteLine("  EagerCatalog created");

        public static string Describe() => "EagerCatalog.Describe called";
    }

    public sealed class StrictCatalog
    {
        public static readonly StrictCatalog Instance = new();

        // Boş static constructor: derleyici beforefieldinit bayrağını koymaz
        static StrictCatalog() { }

        private StrictCatalog() => Console.WriteLine("  StrictCatalog created");

        public static string Describe() => "StrictCatalog.Describe called";
    }
}
EagerCatalog  beforefieldinit: True
StrictCatalog beforefieldinit: False
EagerCatalog.Describe called
  StrictCatalog created
StrictCatalog.Describe called
Touching Instance...
  EagerCatalog created

İki class'ın tek farkı boş bir static constructor. Static constructor'ı olmayan tipe C# derleyicisi IL (Intermediate Language) seviyesinde beforefieldinit bayrağını koyar. Bu bayrak runtime'a "static field'ları ilk erişimden önce herhangi bir anda initialize edebilirsin" der; zaman seçimini JIT (Just-In-Time) derleyicisine bırakır. Bayrak yoksa kural katıdır: tipin herhangi bir static üyesine ilk dokunuşta, tam o anda. Çıktıda bu fark net görünüyor: StrictCatalog.Describe() fiyat listesiyle ilgisi olmayan bir method olduğu hâlde constructor'ı tetikledi; EagerCatalog ise ancak Instance'a dokunulunca oluştu.

Bu davranış sürümden sürüme değişebilen bir ayrıntı. .NET Framework 4.0'dan önce beforefieldinit'li tipler genelde daha erken, method'a ilk girilirken initialize ediliyordu; bugünkü JIT ise bu koşuda gördüğün gibi oldukça tembel. Güvenmen gereken tek şey sözleşme: bayrak varsa initialize anı garanti değil, yoksa garanti. Singleton'ın oluşma anı senin için önemliyse (constructor'da log yazıyorsan, bir dosya okuyorsan, exception fırlatabiliyorsan) boş bir static constructor eklemek o anı sabitler. Önemli değilse beforefieldinit daha iyidir; JIT her erişimde "initialize edildi mi?" kontrolü yapmak zorunda kalmaz.

Static initializer'ın bir de can sıkıcı yanı var: constructor exception fırlatırsa tip kalıcı olarak bozulur. O tipe sonraki her erişim, asıl hatayı içinde taşıyan bir TypeInitializationException fırlatır ve process yeniden başlatılmadan düzelmez.

Lazy<T>: En Kısa Doğru Cevap ve Exception Caching

.NET Framework 4.0 ile gelen Lazy<T>, tembel ve thread-safe oluşturmayı tek satıra indirir: private static readonly Lazy<PriceCatalog> Lazy = new(() => new PriceCatalog()); ve Instance => Lazy.Value. Varsayılan mod LazyThreadSafetyMode.ExecutionAndPublication; factory'yi yalnızca bir thread çalıştırır, diğerleri bekler. Ama static initializer'daki bozulma sorunu burada da, daha sinsi bir biçimde var:

string path = Path.Combine(Path.GetTempPath(), "bookstore-prices.csv");
File.Delete(path);

Lazy<string[]> cached = new(() => File.ReadAllLines(path));
Lazy<string[]> retrying = new(() => File.ReadAllLines(path), LazyThreadSafetyMode.PublicationOnly);

Console.WriteLine("1) File missing:");
Try("ExecutionAndPublication", cached);
Try("PublicationOnly", retrying);

File.WriteAllLines(path, ["978-0441013593;12.99", "978-0141439518;7.50"]);

Console.WriteLine("2) File created, same Lazy instances:");
Try("ExecutionAndPublication", cached);
Try("PublicationOnly", retrying);

File.Delete(path);

static void Try(string mode, Lazy<string[]> lazy)
{
    try
    {
        Console.WriteLine($"  {mode}: {lazy.Value.Length} prices");
    }
    catch (FileNotFoundException)
    {
        Console.WriteLine($"  {mode}: FileNotFoundException (IsValueCreated={lazy.IsValueCreated})");
    }
}
1) File missing:
  ExecutionAndPublication: FileNotFoundException (IsValueCreated=False)
  PublicationOnly: FileNotFoundException (IsValueCreated=False)
2) File created, same Lazy instances:
  ExecutionAndPublication: FileNotFoundException (IsValueCreated=False)
  PublicationOnly: 2 prices

Dosya oluşturulduktan sonra bile varsayılan moddaki Lazy aynı FileNotFoundException'ı fırlatmaya devam etti. Bu bir bug değil, belgelenmiş davranış: factory alan constructor'larda ilk exception saklanır ve Value'ya yapılan her sonraki erişimde aynısı fırlatılır. Microsoft Learn'deki gerekçe, farklı thread'lerin farklı sonuç görmesini engellemek. Pratikteki anlamı şu: uygulama açılırken fiyat dosyası bir saniyeliğine erişilemezse, process yeniden başlayana kadar fiyat listesi yok.

PublicationOnly modunda exception saklanmaz ve ikinci denemede dosya okundu. Bedeli, birden fazla thread aynı anda gelirse factory'nin birden fazla kez çalışabilmesi; yalnızca biri kazanır, diğerlerinin ürettiği atılır. Factory pahalı ama yan etkisizse bu kabul edilebilir. Belgenin önerisi ise genelde daha iyi: geçici hataya karşı retry'ı factory'nin içine koy, Lazy'nin modunu değiştirme.

Neden Anti-Pattern'e Yakın: Gizli Bağımlılık ve Sızan Test'ler

Şimdiye kadar birinci sözü, yani tek instance'ı doğru tutmaya çalıştık. İkinci söz olan global erişimin bedeli daha az görünür ama daha pahalı. Bookstore'da kampanyaları tutan bir registry düşün; sipariş toplamı en iyi kampanya oranıyla hesaplanıyor:

using System.Globalization;
using Bookstore.Orders;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// İki "test", aynı process'te sırayla çalışıyor (test runner'ların yaptığı gibi)
RunTest("Student campaign gives 15% off", () =>
{
    CampaignRegistry.Instance.Activate("STUDENT", 0.15m);
    return new OrderService().Total([12.99m, 7.50m]) == 17.42m;
});

RunTest("No campaign means full price", () =>
    new OrderService().Total([12.99m, 7.50m]) == 20.49m);

static void RunTest(string name, Func<bool> test) =>
    Console.WriteLine($"{(test() ? "PASS" : "FAIL")}  {name}");

namespace Bookstore.Orders
{
    public sealed class CampaignRegistry
    {
        public static CampaignRegistry Instance { get; } = new();

        private readonly Dictionary<string, decimal> _active = [];

        private CampaignRegistry() { }

        public void Activate(string code, decimal rate) => _active[code] = rate;
        public decimal BestRate() => _active.Count == 0 ? 0m : _active.Values.Max();
    }

    public sealed class OrderService
    {
        // Bağımlılık constructor'da görünmüyor; method'un içinde gizli
        public decimal Total(IEnumerable<decimal> prices) =>
            Math.Round(prices.Sum() * (1 - CampaignRegistry.Instance.BestRate()), 2);
    }
}
PASS  Student campaign gives 15% off
FAIL  No campaign means full price

İkinci test tek başına çalışsa geçerdi. Birinci test'in etkinleştirdiği öğrenci kampanyası process boyunca yaşayan singleton'da kaldı ve ikinci test'in sonucunu değiştirdi. Test'lerin çalışma sırası değişirse sonuç da değişir; xUnit gibi test runner'lar farklı class'lardaki test'leri paralel çalıştırdığında ise hata bazen çıkar, bazen çıkmaz. "Bende geçiyor, CI'da (Continuous Integration) düşüyor" şikâyetlerinin klasik kaynaklarından biri budur.

Asıl sorun test değil, test'in görünür kıldığı şey: OrderService'in constructor'ına bakan biri onun kampanyalara bağlı olduğunu göremiyor. Bağımlılık method'un ortasında, CampaignRegistry.Instance çağrısının içinde saklı. Mark Seemann (Dependency Injection in .NET kitabının yazarı) Service Locator'ı anti-pattern olarak nitelediği yazısında aynı sorunu anlatır: class'ın dışarıdan görünen API'si (Application Programming Interface) neye ihtiyaç duyduğunu söylemez, öğrenmek için kodunu okuman gerekir. Global erişim, tek instance ihtiyacının yanına bedava gelen ama hiç istenmemiş bir özelliktir.

Aynı Garantiyi DI ile Almak: AddSingleton

İki sözü ayırmanın modern yolu dependency injection (DI). "Tek instance" kararı class'ın içinden çıkıp container registration'ına taşınıyor; "erişim" ise global bir property yerine constructor parametresi oluyor:

using System.Globalization;
using Bookstore.Orders;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();
services.AddSingleton<CampaignRegistry>();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,
    ValidateOnBuild = true
});

OrderService first, second;
using (IServiceScope scope = provider.CreateScope())
    first = scope.ServiceProvider.GetRequiredService<OrderService>();
using (IServiceScope scope = provider.CreateScope())
    second = scope.ServiceProvider.GetRequiredService<OrderService>();

Console.WriteLine($"Same OrderService:     {ReferenceEquals(first, second)}");
Console.WriteLine($"Same CampaignRegistry: {ReferenceEquals(first.Campaigns, second.Campaigns)}");

// Test'te container yok: her test kendi registry'sini new'ler
CampaignRegistry studentOnly = new();
studentOnly.Activate("STUDENT", 0.15m);
Console.WriteLine(new OrderService(studentOnly).Total([12.99m, 7.50m]));
Console.WriteLine(new OrderService(new CampaignRegistry()).Total([12.99m, 7.50m]));

namespace Bookstore.Orders
{
    // Tek instance garantisi artık class'ın değil, container'ın işi
    public sealed class CampaignRegistry
    {
        private readonly Lock _sync = new();
        private readonly Dictionary<string, decimal> _active = [];

        public void Activate(string code, decimal rate)
        {
            lock (_sync)
                _active[code] = rate;
        }

        public decimal BestRate()
        {
            lock (_sync)
                return _active.Count == 0 ? 0m : _active.Values.Max();
        }
    }

    public sealed class OrderService(CampaignRegistry campaigns)
    {
        public CampaignRegistry Campaigns => campaigns;

        public decimal Total(IEnumerable<decimal> prices) =>
            Math.Round(prices.Sum() * (1 - campaigns.BestRate()), 2);
    }
}
Same OrderService:     False
Same CampaignRegistry: True
17.42
20.49

İki farklı scope'tan çözülen iki OrderService farklı ama ikisinin de elindeki CampaignRegistry aynı instance. Klasik Singleton'ın verdiği garanti burada da var; üstelik CampaignRegistry'nin private constructor'ı, static field'ı ya da lock'lu getter'ı yok. Test'te container kullanmıyoruz: her test kendi registry'sini new ile oluşturuyor ve birbirine sızamıyor. Container'ın oluşturma sırasında thread-safety'yi de üstlendiğini Microsoft Learn'deki DI yönergeleri açıkça söylüyor: singleton factory'si tek bir thread tarafından, bir kez çağrılır. Lifetime'ların ayrıntısı için C# İleri serisindeki Dependency Injection Temelleri yazısına bakabilirsin.

.NET'in kendi kütüphanesine bakınca da aynı ayrımı görürsün. EqualityComparer<T>.Default, StringComparer.Ordinal, ArrayPool<T>.Shared, Encoding.UTF8 ya da TimeProvider.System static, iyi bilinen instance'lar; ama hiçbirinde değiştirilebilir iş verisi yok. Ya tamamen stateless'lar ya da thread-safe tasarlanmışlar ve hepsi bir abstraction'ın varsayılan implementation'ı. TimeProvider burada özellikle öğretici: .NET 8 öncesinde zaman, DateTime.UtcNow adlı global bir erişim noktasıydı ve test'te dondurulamıyordu. .NET 8 TimeProvider abstract class'ını getirdi: production'da TimeProvider.System singleton'ını enjekte ediyorsun, test'te sahte bir provider. Singleton hâlâ orada, ama artık gizli değil.

Captive Dependency ve Paylaşılan Mutable State

DI singleton'ı klasik Singleton'ın iki sorununu otomatik çözmüyor; yalnızca görünür kılıyor. Birincisi captive dependency: uygulama boyunca yaşayan bir singleton'ın, istek başına yaşaması gereken bir bağımlılığı (bir DbContext gibi) constructor'da alıp sonsuza kadar tutması. İkincisi singleton'ın içindeki paylaşılan state:

using System.Globalization;
using Bookstore.Pricing;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();
services.AddScoped<StoreSession>();      // istek başına: DbContext gibi
services.AddSingleton<PriceCache>();     // uygulama boyunca tek

try
{
    using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
    {
        ValidateScopes = true,
        ValidateOnBuild = true
    });
}
catch (AggregateException ex)
{
    foreach (Exception inner in ex.InnerExceptions)
        Console.WriteLine(inner.Message);
}

// Singleton'daki paylaşılan sayaç: 8 thread, 1.000.000 artış
HitCounter counter = new();
Parallel.For(0, 1_000_000, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ => counter.UnsafeHit());
Parallel.For(0, 1_000_000, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ => counter.SafeHit());
Console.WriteLine($"count++:           {counter.Unsafe:N0}");
Console.WriteLine($"Interlocked:       {counter.Safe:N0}");

namespace Bookstore.Pricing
{
    public sealed class StoreSession : IDisposable
    {
        public void Dispose() { }
    }

    public sealed class PriceCache(StoreSession session)
    {
        public StoreSession Session => session;
    }

    public sealed class HitCounter
    {
        private int _unsafe;
        private int _safe;

        public int Unsafe => _unsafe;
        public int Safe => Volatile.Read(ref _safe);

        public void UnsafeHit() => _unsafe++;
        public void SafeHit() => Interlocked.Increment(ref _safe);
    }
}
Error while validating the service descriptor 'ServiceType: Bookstore.Pricing.PriceCache Lifetime: Singleton ImplementationType: Bookstore.Pricing.PriceCache': Cannot consume scoped service 'Bookstore.Pricing.StoreSession' from singleton 'Bookstore.Pricing.PriceCache'.
count++:           153,805
Interlocked:       1,000,000

ValidateScopes ve ValidateOnBuild açıkken container daha uygulama başlarken hatayı yakalıyor ve mesaj neyin yanlış olduğunu tam söylüyor. ASP.NET Core Development ortamında bu doğrulamaları varsayılan olarak açar; Web API serisindeki ASP.NET Core'da Dependency Injection yazısında captive dependency'nin production'da neye yol açtığını ayrıntılı görmüştük.

İkinci satır ise lifetime'ın değil, singleton'ın doğasının sorunu. Bir milyon artıştan bu koşuda yalnızca 153.805'i kaldı; sayı her koşuda değişiyor ama bir milyona yaklaşmıyor bile. _unsafe++ tek bir işlem değil: oku, bir ekle, yaz. Sekiz thread aynı değeri okuyup aynı sonucu yazınca artışlar birbirini eziyor. Singleton tanım gereği bütün isteklerin ortak object'i; içinde değişen bir şey varsa Interlocked, lock ya da concurrent collection ile korumak zorundasın. Async serisindeki Senkronizasyon yazısı bu araçları tek tek ele alıyor. En iyi seçenek ise çoğu zaman singleton'ı immutable tutmak: fiyat listesi değişecekse yeni bir liste oluşturup referansı tek seferde değiştirmek.

Sık Düşülen Tuzaklar

Private constructor'ın mutlak bir kilit olduğunu sanmak. Activator.CreateInstance(typeof(PriceCatalog), nonPublic: true) private constructor'ı reflection ile çağırır ve ikinci bir instance üretir. Bu bir güvenlik açığı değil ama "dilde imkânsız" sandığın şeyin yalnızca bir anlaşma olduğunu hatırlatır. Serializer'lar ve bazı mapping kütüphaneleri de aynı yolu kullanabilir.

Tek instance'ın process başına olduğunu unutmak. Uygulaman üç pod'da ya da iki sunucuda çalışıyorsa ortada üç ya da iki "tek" fiyat listesi vardır. Birinin cache'i güncellendiğinde diğerleri bilmez. Gerçekten sistem genelinde tek bir şey gerekiyorsa bu bir dağıtık sistem problemidir; Singleton pattern'i onu çözmez.

Generic tipte static field'ın tek olduğunu sanmak. Registry<T> içindeki static readonly field her closed tip için ayrıdır: Registry<Book> ve Registry<Order> iki farklı instance tutar. Bazen istenen budur, bazen de sürpriz.

Singleton'ı ayar deposu yapmak. "Her yerden erişilsin" diye AppSettings.Instance açıp configuration'ı oraya koymak, Options pattern'inin çözdüğü problemi yeniden ve daha kötü çözmektir. ASP.NET Core'da IOptions<T> ve IOptionsMonitor<T> aynı işi test edilebilir ve yeniden yüklenebilir biçimde yapar.

Klasik Singleton ile DI singleton'ını aynı class'ta birleştirmek. Hem services.AddSingleton<PriceCatalog>() hem de PriceCatalog.Instance varsa iki ayrı instance olur: biri container'ın, biri static field'ın. Hangi kodun hangisini kullandığını bulmak da ayrı bir eğlence.

Ne Zaman Kullanma

Klasik Singleton'ı, yani private constructor ve static Instance property'sini, uygulama kodunda neredeyse hiç yazmana gerek yok. DI kullanan her .NET uygulamasında "tek instance" ihtiyacı AddSingleton ile karşılanır ve bağımlılık açık kalır. Klasik biçimin meşru kaldığı yer, DI'ın olmadığı ya da anlamsız olduğu küçük köşeler: stateless ve ucuz bir comparer'ın varsayılan instance'ı, bir library'nin kendi içinde kullandığı ve dışarıya hiç açmadığı bir cache, bir source generator'ın ürettiği kod. Bunların ortak noktası, içlerinde değişen iş verisi olmaması.

DI singleton'ını da gelişigüzel seçme. Microsoft'un yönergesi, state'i olmayan service'ler için singleton yerine transient'ı öneriyor: stateless bir service'i her seferinde oluşturmak ucuzdur, singleton ise bağımlılık ağacını uygulama boyunca bellekte tutar ve ilgisiz istekleri birbirine bağlar. Singleton'ı, oluşturması gerçekten pahalı ya da gerçekten paylaşılması gereken state için sakla.

Küçük Bir Deneme

★☆☆ İlk örnekteki PriceCatalog'u Lazy<T> ile yeniden yaz ve sekiz isteğin tek bir constructor çağrısıyla sonuçlandığını çıktıyla göster.

★★☆ StrictCatalog'un constructor'ına throw new InvalidOperationException("Price file missing") ekle. Describe()'ı iki kez çağır ve her seferinde hangi exception tipinin geldiğini, InnerException'ın ne olduğunu yazdır. Aynı şeyi Lazy<T> ile yap; iki davranışı karşılaştır.

★★★ CampaignRegistry'yi immutable yap: Activate yeni bir registry döndürsün ve DI'da tutulan referansı tek bir atomik işlemle (Interlocked.Exchange ya da volatile bir field) değiştiren küçük bir CampaignStore yaz. Sekiz thread aynı anda kampanya eklerken hiçbir kampanyanın kaybolmadığını kanıtla.

Bir sonraki yazıda oluşturmanın kendisine bakıyoruz: GoF'un Factory Method'u ile her yerde gördüğün static factory method arasındaki fark, base class constructor'ından virtual method çağırmanın neden tehlikeli olduğu ve IReportExporter'ı DI'da factory delegate'iyle üretirken sahiplik sorusunun nasıl ortaya çıktığı.