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

Composite Pattern: Ağaç Yapıları Tek Interface ile Gezmek

Mert Özen 7 Kas 2026 12 dk 5 görüntülenme
Composite Pattern: Ağaç Yapıları Tek Interface ile Gezmek

Composite, leaf'i ve onları içeren composite'i aynı interface arkasına koyarak client'ın tek bir item'la iç içe bir ağacı ayırt etmeden kullanmasını sağlar; recursion ağacı bilen tek yerde, composite'in içinde kalır. Bookstore'da iç içe kitap paketlerinin fiyatı, transparency ile safety tercihi, IConfiguration ve CompositeChangeToken örnekleri ve döngüsel ağacın stack overflow'u.

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

Önce Tahmin Et

Bookstore paket satışına başladı. Yüzüklerin Efendisi üçlemesi yüzde 10 indirimli bir paket; Hobbit ile bu üçleme de yüzde 5 indirimli daha büyük bir koleksiyon. Yani bir paketin içinde hem kitap hem başka bir paket var. Fiyatı hesaplayan ilk kod şöyle:

Book hobbit = new("978-0547928227", "The Hobbit", 14.00m);
Bundle trilogy = new("The Lord of the Rings Trilogy", 0.10m,
[
    new Book("978-0547928210", "The Fellowship of the Ring", 18.00m),
    new Book("978-0547928203", "The Two Towers", 18.00m),
    new Book("978-0547928197", "The Return of the King", 18.00m)
]);
Bundle collection = new("Middle-earth Collection", 0.05m, [hobbit, trilogy]);

Console.WriteLine($"Trilogy:    {PriceOf(trilogy)}");
Console.WriteLine($"Collection: {PriceOf(collection)}");

// Fiyat hesaplayan kod, ağacın şeklini kendisi biliyor
static decimal PriceOf(object item) => item switch
{
    Book book => book.Price,
    Bundle bundle => bundle.Items.OfType<Book>().Sum(b => b.Price) * (1 - bundle.Discount),
    _ => throw new ArgumentException($"Unknown item: {item}")
};

public sealed record Book(string Isbn, string Title, decimal Price);

public sealed record Bundle(string Name, decimal Discount, List<object> Items);

Üçlemenin fiyatı 54 × 0,90 = 48,60 olmalı. Koleksiyonun fiyatı ise (14 + 48,60) × 0,95 = 59,47. Kod ikisi için ne yazar?

Part-Whole Hiyerarşisi ve Type Check'lerin Bedeli

Çıktı:

Trilogy:    48.6000
Collection: 13.3000

Üçleme doğru, koleksiyon 59,47 yerine 13,30. OfType<Book>() paketin içindeki üçlemeyi sessizce filtreledi; geriye yalnız Hobbit'in indirimli fiyatı kaldı. Exception yok, uyarı yok, yalnız yanlış bir sayı. Bu kodu yazan kişi muhtemelen tek seviyeli paketleri düşünüyordu ve o gün için haklıydı.

Daha büyük sorun, bu hatanın tekrarlanacak olması. Fiyat, sepetteki kitap sayısı, kargo ağırlığı, stok kontrolü, fatura satırları: ağaç üzerinde çalışan her işlem aynı switch'i, aynı recursion'ı ve aynı "paketin içinde paket olabilir" bilgisini kendi içinde yeniden yazmak zorunda. Ağaç yapısı, ağacı kullanan her client'a sızmış durumda. Ayrıca List<object> tip güvenliğini de kaybettirmiş: pakete bir string eklemek derleniyor ve ancak fiyat hesaplanırken patlıyor.

Composite Pattern: Component, Leaf ve Composite

GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson ve John Vlissides'in 1994 tarihli Design Patterns kitabı) Composite'in amacını, object'leri part-whole hiyerarşilerini temsil eden ağaç yapılarında birleştirmek ve client'ın tek object'lere de bileşimlere de aynı şekilde davranabilmesini sağlamak diye tanımlar. Kitaptaki örnek bir çizim editörü: çizgi, dikdörtgen ve metin gibi basit şekiller ve bunları gruplayan, kendisi de bir şekil gibi davranan Picture.

Üç rol var. Component, ağaçtaki her şeyin ortak interface'i. Leaf, child'ı olmayan ve işi kendisi yapan uç: bizde Book. Composite, child'larını tutan ve bir işlem istendiğinde onu child'larına devredip sonuçları birleştiren düğüm: bizde Bundle. Pattern'in kilit noktası, composite'in de component interface'ini implement etmesi; böylece bir paket, başka bir paketin içine bir kitap gibi konabiliyor.

Book hobbit = new("978-0547928227", "The Hobbit", 14.00m);
Bundle trilogy = new Bundle("The Lord of the Rings Trilogy", 0.10m)
    .Add(new Book("978-0547928210", "The Fellowship of the Ring", 18.00m))
    .Add(new Book("978-0547928203", "The Two Towers", 18.00m))
    .Add(new Book("978-0547928197", "The Return of the King", 18.00m));
Bundle collection = new Bundle("Middle-earth Collection", 0.05m).Add(hobbit).Add(trilogy);

// Client tek kitapla paketi ayırt etmiyor
foreach (ICatalogItem item in new ICatalogItem[] { hobbit, trilogy, collection })
    Console.WriteLine($"{item.Title,-30} {item.BookCount} book(s) {item.Price,7}");

Console.WriteLine();
collection.Print(Console.Out);

// Component: leaf ve composite'in ortak sözleşmesi
public interface ICatalogItem
{
    string Title { get; }
    decimal Price { get; }
    int BookCount { get; }
    void Print(TextWriter writer, int depth = 0);
}

// Leaf: child'ı yok, işi kendisi yapar
public sealed record Book(string Isbn, string Title, decimal Price) : ICatalogItem
{
    public int BookCount => 1;

    public void Print(TextWriter writer, int depth = 0) =>
        writer.WriteLine($"{new string(' ', depth * 2)}- {Title} ({Price})");
}

// Composite: işi child'larına devreder ve sonucu birleştirir
public sealed class Bundle(string title, decimal discount) : ICatalogItem
{
    private readonly List<ICatalogItem> _items = [];

    public string Title => title;
    public IReadOnlyList<ICatalogItem> Items => _items;
    public decimal Price => decimal.Round(_items.Sum(i => i.Price) * (1 - discount), 2);
    public int BookCount => _items.Sum(i => i.BookCount);

    public Bundle Add(ICatalogItem item)
    {
        _items.Add(item);
        return this;
    }

    public void Print(TextWriter writer, int depth = 0)
    {
        writer.WriteLine($"{new string(' ', depth * 2)}+ {Title} (-{discount:P0}) = {Price}");
        foreach (ICatalogItem item in _items)
            item.Print(writer, depth + 1);
    }
}
The Hobbit                     1 book(s)   14.00
The Lord of the Rings Trilogy  3 book(s)   48.60
Middle-earth Collection        4 book(s)   59.47

+ Middle-earth Collection (-5%) = 59.47
  - The Hobbit (14.00)
  + The Lord of the Rings Trilogy (-10%) = 48.60
    - The Fellowship of the Ring (18.00)
    - The Two Towers (18.00)
    - The Return of the King (18.00)

Koleksiyon artık 59,47. Bundle.Price içinde type check yok: her child'ın Price'ını soruyor, kitap kendi fiyatını, içteki paket kendi indirimli toplamını dönüyor. Recursion, ağacı bilen tek yerde, composite'in içinde. Client döngüsü kitabı, üçlemeyi ve koleksiyonu aynı ICatalogItem olarak ele alıyor; ağacın ne kadar derin olduğunu bilmesi gerekmiyor. Yeni bir işlem, örneğin kargo ağırlığı, interface'e bir property, leaf'e bir değer ve composite'e bir Sum demek.

İndirimin her seviyede uygulanıp yuvarlanması da bilinçli bir karar. Paketin fiyatı, içindekilerin fiyatından hesaplanan ama kendi kuralı olan bir değer; üçleme tek başına satıldığında da koleksiyonun içindeyken de 48,60. Kuruş yuvarlamasının hangi seviyede yapılacağı gibi sorular Çözüm Desenleri serisinin "Para ve Yuvarlama" yazısında ayrıntılı.

Transparency mi, Safety mi?

Composite'in en çok tartışılan sorusu, child yönetimi method'larının (Add, Remove, Children) nerede duracağı. GoF iki seçeneği de anlatır ve bunun bir trade-off olduğunu söyler. Transparency'yi seçersen bu method'ları component'e koyarsın: client her düğüme aynı şekilde davranır, ama leaf'te anlamsız işlemler olur. Safety'yi seçersen method'lar yalnız composite'te durur: leaf'e child eklemeye çalışmak derlenmez, ama client ağaç kurarken kiminle konuştuğunu bilmek zorundadır. GoF'un kendi örnek kodu transparency'yi tercih eder. Transparent bir tasarım ve sonucu:

List<ICatalogItem> cart = [new Book("978-0547928227", "The Hobbit", 14.00m), new Bundle("Gift Box", 0.10m)];

// Admin ekranı: seçili her item'a kitap ayracı ekle
foreach (ICatalogItem item in cart)
{
    try
    {
        item.Add(new Book("000-0000000001", "Bookmark", 1.50m));
        Console.WriteLine($"{item.Title}: {item.Children.Count} item(s)");
    }
    catch (NotSupportedException ex)
    {
        Console.WriteLine($"{item.Title}: {ex.Message}");
    }
}

// Transparent tasarım: child yönetimi component'te
public interface ICatalogItem
{
    string Title { get; }
    decimal Price { get; }
    IReadOnlyList<ICatalogItem> Children { get; }
    void Add(ICatalogItem item);
}

public sealed record Book(string Isbn, string Title, decimal Price) : ICatalogItem
{
    public IReadOnlyList<ICatalogItem> Children => [];

    // Leaf için anlamsız işlem: sözleşme var, davranış yok
    public void Add(ICatalogItem item) => throw new NotSupportedException("A book cannot contain other items.");
}

public sealed class Bundle(string title, decimal discount) : ICatalogItem
{
    private readonly List<ICatalogItem> _items = [];

    public string Title => title;
    public decimal Price => decimal.Round(_items.Sum(i => i.Price) * (1 - discount), 2);
    public IReadOnlyList<ICatalogItem> Children => _items;
    public void Add(ICatalogItem item) => _items.Add(item);
}
The Hobbit: A book cannot contain other items.
Gift Box: 1 item(s)

Book, interface'in verdiği sözü tutamıyor: Add imzası var ama çağırınca exception fırlıyor. Bu, SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin "Liskov Substitution Principle: Behavioral Subtyping ve .NET'teki NotSupportedException'lar" yazısında incelediğimiz gerilimin aynısı. Yukarıdaki safe tasarımda aynı hata compile time'da yakalanıyor:

Book hobbit = new("978-0547928227", "The Hobbit", 14.00m);
hobbit.Add(new Book("000-0000000001", "Bookmark", 1.50m));
Program.cs(2,8): error CS1061: 'Book' does not contain a definition for 'Add' and no accessible extension method 'Add' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
TransparencySafety
Child yönetimiComponent interface'indeYalnız composite'te
Leaf'e AddRuntime'da NotSupportedExceptionDerlenmez (CS1061)
Ağacı kuran kodDüğümün türünü bilmezComposite'i concrete tipiyle tutar
Ağacı kullanan kodHer düğüme aynı davranırHer düğüme aynı davranır

Tablonun son satırı tercihi kolaylaştırıyor: asıl uniformity'ye ihtiyaç duyan, ağacı kullanan kod, ve iki tasarımda da onu alıyor. Ağacı kuran kod ise zaten çoğunlukla ne kurduğunu biliyor; new Bundle(...).Add(...) zinciri concrete tiple çalışıyor. C#'ta varsayılan olarak safety'yi seç; yürüyen ama değiştirmeyen bir client'ın child'lara erişmesi gerekiyorsa da read-only bir Items ya da is Bundle bundle pattern matching yeterli.

.NET'teki Composite'ler

ASP.NET Core'un configuration sistemi Composite'in ders kitabı örneği. IConfigurationRoot da IConfigurationSection da IConfiguration interface'ini implement eder; her ikisi de GetChildren() ile alt section'larını döner ve indexer ile değer okur. Bir section, kendisi de bir configuration'dır:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;

IConfigurationRoot config = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["Bookstore:Shipping:FreeAbove"] = "50",
        ["Bookstore:Shipping:Carriers:0"] = "Standard",
        ["Bookstore:Shipping:Carriers:1"] = "Express",
        ["Bookstore:Payments:DefaultProvider"] = "card"
    })
    .Build();

// Root da section da IConfiguration: aynı method ikisini de gezer
PrintTree(config);
PrintTree(config.GetSection("Bookstore:Shipping"));

// Birden fazla change token'ı tek bir token gibi göstermek
using CancellationTokenSource pricesChanged = new();
using CancellationTokenSource stockChanged = new();
IChangeToken catalogChanged = new CompositeChangeToken(
[
    new CancellationChangeToken(pricesChanged.Token),
    new CancellationChangeToken(stockChanged.Token)
]);

using IDisposable registration = catalogChanged.RegisterChangeCallback(_ => Console.WriteLine("catalog cache invalidated"), null);
Console.WriteLine($"HasChanged: {catalogChanged.HasChanged}");
stockChanged.Cancel();
Console.WriteLine($"HasChanged: {catalogChanged.HasChanged}");

static void PrintTree(IConfiguration node, int depth = 0)
{
    foreach (IConfigurationSection child in node.GetChildren())
    {
        Console.WriteLine($"{new string(' ', depth * 2)}{child.Key}{(child.Value is null ? "" : $" = {child.Value}")}");
        PrintTree(child, depth + 1);
    }
}
Bookstore
  Payments
    DefaultProvider = card
  Shipping
    Carriers
      0 = Standard
      1 = Express
    FreeAbove = 50
Carriers
  0 = Standard
  1 = Express
FreeAbove = 50
HasChanged: False
catalog cache invalidated
HasChanged: True

PrintTree bir kez root ile, bir kez de Bookstore:Shipping section'ı ile çağrıldı ve ikisinde de aynı kod çalıştı. Kodun ikinci yarısı başka bir Composite'i gösteriyor: CompositeChangeToken, birden fazla IChangeToken'ı tek bir token gibi sunar; child'lardan biri değişince kendisi de değişmiş sayılır ve callback'ini tetikler. Fiyat ya da stok değiştiğinde katalog cache'ini temizlemek isteyen kod, iki ayrı token'ı dinlemek yerine tek bir token'a bakıyor. Aynı ailede CompositeFileProvider da var: birden fazla IFileProvider'ı tek bir provider olarak sunar, bir dosya istendiğinde provider'ları sırayla dener ve bulduğu ilk dosyayı döner. ASP.NET Core Web API (Application Programming Interface) serisindeki "ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation" yazısı configuration tarafını ayrıntılı anlatıyor.

Dikkatli bakınca .NET'te başka ağaçlar da görürsün: System.Linq.Expressions'daki expression tree'ler, System.Xml.Linq'teki XElement, Roslyn'in syntax tree'si. Bunlarda Composite genellikle Iterator ve Visitor ile birlikte gelir: ağacı gezmek ve ağaca yeni işlemler eklemek. Bu ikisi serinin behavioral pattern yazılarının konusu.

Derin Ağaçlar ve Döngüler

Composite'in recursion'ı, ağacın gerçekten ağaç olduğunu varsayar. Admin ekranından paket düzenlenebiliyorsa bu varsayım bir gün bozulur:

Bundle staffPicks = new("Staff Picks", 0.05m);
Bundle summerReads = new("Summer Reads", 0.10m);

summerReads.Add(new Book("978-0141439518", "Emma", 9.50m)).Add(staffPicks);
// Başka bir admin, "Staff Picks" paketine "Summer Reads"i ekliyor
staffPicks.Add(summerReads);

Console.WriteLine(staffPicks.Price);

Çıktının başı ve sonu (toplam 50.567 satır, process macOS'ta exit code 134 ile sonlandı):

Stack overflow.
   at Bundle.get_Price()
   at Bundle+<>c.<get_Price>b__9_0(ICatalogItem)
   at Bundle.get_Price()
   at Bundle+<>c.<get_Price>b__9_0(ICatalogItem)
   ...
   at Bundle.get_Price()
   at Program.<Main>$(System.String[])

İki paket birbirini içeriyor; Price sonsuza kadar birbirini çağırdı ve stack doldu. .NET'te stack overflow yakalanamaz: try/catch koysan da process sonlanır. Web uygulamasında bu, o isteği değil bütün uygulamayı düşürmek demek. Çözüm, döngünün ağaca girmesini Add'de engellemek:

    public Bundle Add(ICatalogItem item)
    {
        // Eklenen paket bizi zaten içeriyorsa ağaç döngüye girer
        if (item is Bundle bundle && (bundle == this || bundle.Contains(this)))
            throw new InvalidOperationException($"'{bundle.Title}' already contains '{Title}'.");

        _items.Add(item);
        return this;
    }

    private bool Contains(ICatalogItem target) =>
        _items.Any(i => i == target || (i is Bundle child && child.Contains(target)));
'Summer Reads' already contains 'Staff Picks'.
8.55

Kontrolün kendisi de recursive ve ağacın boyutuyla orantılı maliyeti var; katalog paketleri gibi küçük ağaçlarda bu önemsiz. Çok derin ağaçlarda (on binlerce seviye, örneğin otomatik üretilmiş bir zincir) recursion'ın kendisi sorun olabilir; o durumda gezinmeyi Stack<T> ile iterative yazmak gerekir. Gerçek iş verisinde, örneğin bir organizasyon şemasında ya da kategori ağacında ise döngü kontrolü çoğu zaman veritabanı tarafında da yapılmalı.

Ağacı Veritabanında Saklamak

Bellekteki Composite, ağacın ilişkisel veritabanında, yani SQL (Structured Query Language) tablolarında nasıl saklandığından bağımsızdır ama ondan etkilenir. En yaygın model her satırın ParentId'sini tuttuğu adjacency list; Entity Framework Core (EF Core) bunu self-referencing bir navigation ile map eder. Sorun, ağacın tamamını yüklemek: her seviye için bir Include yazmak ya da lazy loading ile N+1 sorguya düşmek kolay. Recursive CTE (Common Table Expression), closure table ve SQL Server'ın hierarchyid tipi bu problemin farklı çözümleri; Çözüm Desenleri serisindeki "SQL'de Hiyerarşik Veri: Adjacency List, Recursive CTE, Closure Table ve hierarchyid" yazısı bunları karşılaştırıyor. Pratik bir yol: ağacı tek sorguyla düz bir liste olarak çek, bellekte Dictionary<int, Bundle> ile composite'i kur ve hesapları bellekteki ağaçta yap.

Sık Düşülen Tuzaklar

Leaf'e anlamsız method'lar koymak. Transparency adına Book.Add'in exception fırlatması bir LSP (Liskov Substitution Principle) ihlali ve bir runtime tuzağı. Uniformity'ye ihtiyaç duyan işlemleri component'e, ağaç kurmayı composite'e koy.

Composite'te hesaplanmış değeri cache'lemek ve geçersiz kılmayı unutmak. Bundle.Price'ı bir field'da tutmak hızlıdır, ama içteki bir kitabın fiyatı değiştiğinde dış paketin bunu öğrenmesi gerekir. Bu, ya parent referansı ve yukarı doğru bildirim (Observer) ya da her seferinde yeniden hesaplamak demek. Ağaç küçükse yeniden hesapla.

Parent referansını tutarsız bırakmak. Child'dan parent'a referans gerekiyorsa (item.Parent), bunu yalnız Add ve Remove içinde atayan tek bir yer olmalı; aksi hâlde bir item iki pakette birden "parent"lı görünür.

Aynı item'ı iki yerde paylaşmak ve bunu bilmemek. Yukarıdaki örnekte bir Book instance'ı iki paketin içinde olabilir ve bu çoğu zaman sorun değil. Ama item mutable ise, bir paketteki değişiklik diğerine de yansır. Ağaç mı, yoksa paylaşımlı düğümleri olan bir graph mı kurduğunu bil.

Her ağacı Composite'e zorlamak. İki seviyeli ve hiçbir zaman derinleşmeyecek bir yapı (sipariş ve sipariş satırları) için ortak interface kurmak gereksiz soyutlama. Composite'in değeri, derinliğin bilinmediği ve client'ın tek item'la bileşimi ayırt etmemesi gereken yerde ortaya çıkar.

Küçük Bir Deneme

★☆☆ ICatalogItem'a WeightGrams ekle: kitabın kendi ağırlığı olsun, paket içindekilerin toplamına 150 gram ambalaj eklesin. Koleksiyonun ağırlığını yazdır ve kaç satır kod değiştiğini say.

★★☆ Döngü kontrolünü bir adım öteye taşı: aynı kitabın bir pakette iki kez bulunmasını da engelle (aynı ISBN (International Standard Book Number) farklı alt paketlerde olsa bile). Kontrolü Add'e mi koyarsın, yoksa ayrı bir doğrulama method'u mu yazarsın? Neden?

★★★ Bir paketin tüm kitaplarını recursion kullanmadan, Stack<ICatalogItem> ile gezen bir IEnumerable<Book> Flatten() yaz. Sonra 100.000 seviye derinliğinde, her seviyede tek paket olan bir zincir kurup hem Price'ı hem Flatten().Count()'u çağır. Hangisi çalıştı?

Composite, ağacın her düğümüne aynı interface'i verdi. Bir sonraki yazıda yine aynı interface'i implement eden ama bu kez ağaç değil, üst üste sarılmış bir zincir kuran pattern'e geçiyoruz: Decorator ile caching, logging ve retry davranışlarını mevcut koda dokunmadan eklemek.