Mert Özen Her Satırda Daha İleri
Backend C# ile Nesne Yönelimli Programlama — #15

C#'ta Birkaç Design Pattern: Factory, Strategy ve Observer

Mert Özen 7 Kas 2026 9 dk 4 görüntülenme
C#'ta Birkaç Design Pattern: Factory, Strategy ve Observer

Design pattern'lerin ne işe yaradığı, static factory method'lar ve switch ile factory, Func ve interface ile strategy, event ile observer, bozuk subscriber'ı izole etmek ve subscription leak'leri.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Stok değiştiğinde iki kişi haber almak istiyor: biri e-posta gönderiyor, diğeri arama index'ini güncelliyor. E-posta sunucusu o an çalışmıyor ve ilk subscriber exception fırlatıyor. Arama index'i yine de güncellenir mi? Stok 0 olarak kaydedilmiş mi?

var inventory = new Inventory();
inventory.StockChanged += (_, e) => throw new InvalidOperationException("Mail server is down.");
inventory.StockChanged += (_, e) => Console.WriteLine($"Search index updated: {e.Isbn}");

try
{
    inventory.SetStock("978-0441013593", 0);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Console.WriteLine(inventory.GetStock("978-0441013593"));

public sealed class StockChangedEventArgs : EventArgs
{
    public StockChangedEventArgs(string isbn, int newStock)
    {
        Isbn = isbn;
        NewStock = newStock;
    }

    public string Isbn { get; }
    public int NewStock { get; }
}

public sealed class Inventory
{
    private readonly Dictionary<string, int> _stock = new();

    public event EventHandler<StockChangedEventArgs>? StockChanged;

    public int GetStock(string isbn) => _stock.TryGetValue(isbn, out int count) ? count : 0;

    public void SetStock(string isbn, int count)
    {
        _stock[isbn] = count;
        StockChanged?.Invoke(this, new StockChangedEventArgs(isbn, count));
    }
}

Pattern'ler Ne İşe Yarar

Çıktı:

Mail server is down.
0

Stok kaydedildi ama arama index'i hiç güncellenmedi. Bir event'in subscriber'ları tek bir delegate listesinde sırayla çağrılır; biri exception fırlatırsa liste orada kesilir ve exception SetStock'u çağıran koda kadar çıkar. E-posta sunucusundaki bir sorun, ilgisi olmayan arama özelliğini de bozdu. Bunu yazının observer bölümünde düzelteceğiz.

Bu örnek aslında bir design pattern (tasarım deseni): observer. Design pattern'ler, tekrar tekrar karşılaşılan tasarım problemlerine verilmiş, adı konmuş çözümlerdir. 1994'te yayımlanan ve yazarlarına "Gang of Four" denen "Design Patterns" kitabı bunlardan 23 tanesini kataloglar. Pattern'lerin en büyük faydası ortak bir dil: ekipte biri "buraya bir strategy koyalım" dediğinde herkes aynı yapıyı hayal eder.

Kitap C++ ve Smalltalk dönemine ait; bazı pattern'ler o dillerde eksik olan özellikleri telafi ediyordu. C#'ta delegate, lambda, event ve interface birinci sınıf vatandaş olduğu için birçok pattern birkaç satıra iner. Bu yazıda en sık kullanacağın üçüne modern C# ile bakıyoruz.

Factory: Oluşturmayı Tek Yere Toplamak

Factory pattern'inin özü, bir object'in nasıl oluşturulacağı bilgisini tek bir yerde toplamak. İki yaygın biçimi var: tipin kendi üzerindeki static factory method'lar ve hangi concrete tipin üretileceğine karar veren bir factory:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var dune = Book.CreatePrinted("978-0441013593", "Dune", 12.99m, weightKg: 0.45m);
var emma = Book.CreateDigital("978-0141439518", "Emma", 3.99m);

foreach (var country in new[] { "TR", "DE", "US" })
{
    try
    {
        IShippingCalculator shipping = ShippingCalculatorFactory.For(country);
        Console.WriteLine($"{country}: {dune.Title} {shipping.Cost(dune):0.00}, {emma.Title} {shipping.Cost(emma):0.00}");
    }
    catch (NotSupportedException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

try
{
    Book.CreatePrinted("978-0547928227", "The Hobbit", -1m, weightKg: 0.3m);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed class Book
{
    private Book(string isbn, string title, decimal price, decimal weightKg, bool isDigital)
    {
        Isbn = isbn;
        Title = title;
        Price = price;
        WeightKg = weightKg;
        IsDigital = isDigital;
    }

    public string Isbn { get; }
    public string Title { get; }
    public decimal Price { get; }
    public decimal WeightKg { get; }
    public bool IsDigital { get; }

    public static Book CreatePrinted(string isbn, string title, decimal price, decimal weightKg)
    {
        if (price < 0)
            throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");

        return new Book(isbn, title, price, weightKg, isDigital: false);
    }

    public static Book CreateDigital(string isbn, string title, decimal price) =>
        new Book(isbn, title, price, weightKg: 0m, isDigital: true);
}

public interface IShippingCalculator
{
    decimal Cost(Book book);
}

public sealed class DomesticShipping : IShippingCalculator
{
    public decimal Cost(Book book) => book.IsDigital ? 0m : 2.50m;
}

public sealed class EuShipping : IShippingCalculator
{
    public decimal Cost(Book book) => book.IsDigital ? 0m : 5.00m + book.WeightKg * 4m;
}

public static class ShippingCalculatorFactory
{
    public static IShippingCalculator For(string countryCode) => countryCode switch
    {
        "TR" => new DomesticShipping(),
        "DE" or "FR" or "NL" => new EuShipping(),
        _ => throw new NotSupportedException($"No shipping to {countryCode}.")
    };
}
TR: Dune 2.50, Emma 0.00
DE: Dune 6.80, Emma 0.00
No shipping to US.
Price cannot be negative. (Parameter 'price')

Book'un constructor'ı private ve beş parametresi var; bunlardan biri bool. new Book(..., true) gibi bir çağrıda true'nun ne anlama geldiğini okuyan kimse bilemez. CreatePrinted ve CreateDigital ise niyeti adıyla söylüyor ve her biri kendi kuralını uyguluyor: basılı kitap negatif fiyatla oluşturulamıyor, dijital kitabın ağırlığı her zaman sıfır. .NET'in kendisi de bu pattern'i sever: TimeSpan.FromMinutes, Task.FromResult, Guid.NewGuid.

ShippingCalculatorFactory ise "hangi tip?" sorusuna cevap veriyor. Temeller serisinde gördüğümüz switch expression ve or pattern'i bu kararı okunaklı bir tabloya çeviriyor. Çağıran kod sadece IShippingCalculator'ı tanıyor; yeni bir ülke eklendiğinde değişen tek yer factory. Büyük uygulamalarda bu işi çoğunlukla dependency injection container üstlenir; container, hangi interface için hangi class'ın üretileceğini bilen büyük bir factory'dir.

Strategy: Davranışı Değiştirilebilir Kılmak

Strategy, bir algoritmayı onu kullanan koddan ayırır ve runtime'da değiştirilebilir hâle getirir. Bir önceki yazıdaki IDiscountPolicy zaten bir strategy'ydi. Davranış tek bir işlemden ibaretse interface yazmana bile gerek yok; bir Func yeter:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var campaigns = new Dictionary<string, Func<decimal, decimal>>(StringComparer.OrdinalIgnoreCase)
{
    ["none"] = total => total,
    ["student"] = total => total * 0.85m,
    ["over50"] = total => total >= 50m ? total - 10m : total
};

decimal[] prices = { 12.99m, 7.50m, 34.90m };

foreach (var code in new[] { "none", "STUDENT", "over50" })
{
    var checkout = new Checkout(campaigns[code]);
    Console.WriteLine($"{code}: {checkout.Total(prices):0.00}");
}

public sealed class Checkout
{
    private readonly Func<decimal, decimal> _discount;

    public Checkout(Func<decimal, decimal> discount) => _discount = discount;

    public decimal Total(IEnumerable<decimal> prices) =>
        Math.Round(_discount(prices.Sum()), 2, MidpointRounding.AwayFromZero);
}
none: 55.39
STUDENT: 47.08
over50: 45.39

Checkout hangi kampanyanın uygulandığını bilmiyor; ona verilen fonksiyonu çağırıyor. Kampanyalar bir dictionary'de duruyor ve kullanıcının girdiği koda göre seçiliyor; StringComparer.OrdinalIgnoreCase sayesinde "STUDENT" de buldu. Yeni kampanya, dictionary'ye bir satır.

Interface mi Func mı? Strategy tek bir method'dan ibaretse ve durum (state) tutmuyorsa Func daha az törenle aynı işi görür. Strategy'nin bir adı, birden fazla method'u, kendi bağımlılıkları varsa ya da dependency injection container'a kaydedilecekse interface daha iyi: IDiscountPolicy'yi test'te sahtesiyle değiştirmek, bir lambda'nın ne yaptığını tahmin etmekten kolaydır. Delegate'leri ve Func'ı İleri Seviye serisinin ilk yazısında derinlemesine göreceğiz.

Observer: Değişikliği Haber Vermek

Observer'da bir object (publisher) durumundaki değişikliği, onu dinleyen diğer object'lere (subscriber'lar) haber verir. Publisher, subscriber'ların kim olduğunu bilmez; sadece "bir şey değişti" der. C#'ta bu pattern'in dile gömülü hâli event. Açılıştaki sorunu, her subscriber'ı ayrı ayrı çağırarak çözelim:

var inventory = new Inventory();
var mailer = new RestockMailer();

inventory.StockChanged += mailer.OnStockChanged;
inventory.StockChanged += (_, e) => throw new InvalidOperationException("Search index is down.");
inventory.StockChanged += (_, e) => Console.WriteLine($"Audit: {e.Isbn} -> {e.NewStock}");

inventory.SetStock("978-0441013593", 0);

inventory.StockChanged -= mailer.OnStockChanged;
inventory.SetStock("978-0141439518", 0);

public sealed class RestockMailer
{
    public void OnStockChanged(object? sender, StockChangedEventArgs e)
    {
        if (e.NewStock == 0)
            Console.WriteLine($"Mail: {e.Isbn} is out of stock");
    }
}

public sealed class StockChangedEventArgs : EventArgs
{
    public StockChangedEventArgs(string isbn, int newStock)
    {
        Isbn = isbn;
        NewStock = newStock;
    }

    public string Isbn { get; }
    public int NewStock { get; }
}

public sealed class Inventory
{
    private readonly Dictionary<string, int> _stock = new();

    public event EventHandler<StockChangedEventArgs>? StockChanged;

    public int GetStock(string isbn) => _stock.TryGetValue(isbn, out int count) ? count : 0;

    public void SetStock(string isbn, int count)
    {
        _stock[isbn] = count;
        OnStockChanged(new StockChangedEventArgs(isbn, count));
    }

    private void OnStockChanged(StockChangedEventArgs e)
    {
        if (StockChanged is null)
            return;

        foreach (EventHandler<StockChangedEventArgs> handler in StockChanged.GetInvocationList())
        {
            try
            {
                handler(this, e);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Subscriber failed: {ex.Message}");
            }
        }
    }
}
Mail: 978-0441013593 is out of stock
Subscriber failed: Search index is down.
Audit: 978-0441013593 -> 0
Subscriber failed: Search index is down.
Audit: 978-0141439518 -> 0

GetInvocationList() event'in arkasındaki subscriber listesini verir; her birini kendi try/catch'i içinde çağırınca bozuk bir subscriber diğerlerini engelleyemiyor. İkinci SetStock'tan önce -= ile e-posta subscriber'ını çıkardık ve ikinci turda e-posta satırı gelmedi. Gerçek bir uygulamada Console.WriteLine yerine hatayı log'lardın; yutmak ile log'lamak arasındaki farkı unutma.

Kalıbın parçaları .NET'in event konvansiyonundan geliyor: EventHandler<T> delegate'i (gönderen ve argümanlar), EventArgs'tan derive eden bir argüman class'ı ve event'i tetikleyen OnXxx adlı bir method. event keyword'ü de dışarıdakilerin sadece += ve -= yapabilmesini, event'i kendilerinin tetikleyememesini garanti eder. BCL'de ayrıca IObservable<T> ve IObserver<T> interface'leri var; bunlar özellikle bir değer stream'ini dinlemek için tasarlandı ve System.Reactive (Rx) kütüphanesiyle birlikte kullanıldığında güç kazanır. Event'lerin ayrıntılarını İleri Seviye serisinde göreceğiz.

Unsubscribe Etmek ve Leak'ler

-= sadece aynı delegate'i verdiğinde çalışır. Lambda ile subscribe olup lambda ile unsubscribe olmaya çalışmak sık görülen bir hata:

var inventory = new Inventory();

inventory.StockChanged += (_, e) => Console.WriteLine($"Log: {e}");
inventory.StockChanged -= (_, e) => Console.WriteLine($"Log: {e}");

inventory.SetStock("978-0441013593", 4);

public sealed class Inventory
{
    public event EventHandler<string>? StockChanged;

    public void SetStock(string isbn, int count) => StockChanged?.Invoke(this, $"{isbn}={count}");
}
Log: 978-0441013593=4

İki lambda aynı görünse de iki ayrı delegate; -= listede eşleşen bir şey bulamadı ve sessizce hiçbir şey yapmadı. Unsubscribe olman gerekecekse subscriber'ı bir değişkende ya da bir method olarak tut (mailer.OnStockChanged gibi).

Unsubscribe olmamanın bir bedeli daha var. Publisher, subscriber'larına delegate üzerinden referans tutar. Uzun yaşayan bir publisher (uygulama boyunca yaşayan bir Inventory) kısa ömürlü bir subscriber'ı (bir ekran, bir istek bazlı object) listesinde tuttukça o subscriber garbage collector için ulaşılabilirdir ve hiç toplanmaz. Object'lerin ömrünü konuştuğumuz yazıda "bellek büyüyorsa sebep genelde hâlâ ulaşılabilir olan object'lerdir" demiştik; unutulmuş event subscription'ları bunun en klasik örneği. Subscriber'ın ömrü publisher'dan kısaysa, işi bitince -= ile çıkması gerekir; IDisposable bunun için iyi bir yer.

Sık Düşülen Tuzaklar

Pattern için pattern kullanmak. Tek bir concrete tipi döndüren bir IBookFactory, tek kampanyası olan bir sistemde strategy altyapısı, kimsenin dinlemediği bir event: hepsi okuyana "burada bir esneklik var" mesajı verir ama o esneklik hiç kullanılmaz. Pattern'i, karşılık geldiği problem gerçekten ortaya çıktığında getir.

Factory'de sessizce varsayılana düşmek. _ => new DomesticShipping() yazmak "US" için de yurt içi kargo fiyatı hesaplar ve kimse fark etmez. Bilinmeyen durumu exception ile görünür kılmak, yanlış bir fatura kesmekten iyidir.

Event handler'ında uzun iş yapmak. Event'ler varsayılan olarak senkron çalışır; SetStock, bütün subscriber'ların işi bitene kadar dönmez. Yavaş bir e-posta gönderimi stok güncellemesini de yavaşlatır. Uzun işleri bir queue'ya bırakmayı Asenkron Programlama serisinde Channels ile göreceğiz.

Subscriber'ları belli bir sırada çağrılacak sanmak. Bugün eklendikleri sırayla çalışıyorlar, ama birinin diğerinin sonucuna güvenmesi kırılgan bir tasarımdır. Sıra önemliyse bu bir event değil, açıkça sıralanmış adımlardır.

Küçük Bir Deneme

★☆☆ ShippingCalculatorFactory'ye "US" için InternationalShipping ekle: basılı kitap için 15 + kilogram başına 8. Çağıran koda dokunmadan çıktının değiştiğini gör.

★★☆ campaigns dictionary'sinde olmayan bir kod girildiğinde ne oluyor? TryGetValue kullanarak bilinmeyen kodlarda "none" kampanyasına düşen ve bunu ekrana yazan bir seçim method'u yaz. Bu, factory tuzağındaki "sessiz varsayılan"dan neden farklı?

★★★ RestockMailer'ı IDisposable yap: constructor'da Inventory'yi alıp subscribe olsun, Dispose'ta unsubscribe olsun. using bloğu bittikten sonra yapılan SetStock'ta e-posta satırının gelmediğini kanıtla.

Seriyi bitirmeden önce bütün parçaları bir araya getirme zamanı. Bir sonraki ve son yazıda record'lar, eşitlik, operatörler, composition, IDisposable ve bu üç pattern'le küçük ama bütünlüklü bir OOP Bookstore kuruyoruz.