Structural Intermediate 8 min read

Composite Pattern in C#: Trees with Uniform Operations

Composite pattern in C# / .NET 10: a shared interface for leaves and groups so totals, rendering, and validation work the same at every level of a tree.

Table of contents
  1. What problem does the Composite pattern solve in C#?
  2. How does the Composite hierarchy look in .NET 10?
  3. How do you implement common operations on the tree?
  4. When should you use a flat list instead?
  5. How does Composite compare to Decorator?
  6. What does a real .NET 10 example look like?
  7. Where should you read next in this series?

The shop sells gift bundles — "Holiday Box" containing three items sold together at a 10% discount. The cart contains regular line items and bundles, and every cart operation has to handle both: compute the total, render the receipt, validate inventory, apply taxes. The first version branches everywhere:

foreach (var entry in cart.Entries)
{
    if (entry is LineItem line)        total += line.Price * line.Quantity;
    else if (entry is Bundle bundle)
    {
        foreach (var inner in bundle.Items)
            total += inner.Price * inner.Quantity;
        total *= 0.9m;     // bundle discount
    }
}

When marketing adds nested bundles ("Holiday Mega Box" containing three Holiday Boxes), the if ladder grows another layer. Adding sales tax means visiting every if branch and updating both. The shape is wrong; uniform tree traversal is what you wanted.

The Composite pattern is the answer. Define an interface that both leaves and groups implement. Operations like Total() and Render() recurse naturally. Adding a new node type (a "free gift" that costs zero) is one new class with no edits to existing code.

What problem does the Composite pattern solve in C#?

The pattern earns its place when the same operation must apply to both a single thing and a group of things, possibly nested. Three concrete shapes:

  1. Shopping carts with bundles. Line items and bundles share Total() and Render(); bundles can contain other bundles.
  2. File systems. A file and a folder both have Size, Path, Permissions. A folder is a group of files and sub-folders.
  3. UI menus. A menu item and a sub-menu both have Label, IsEnabled, Click(). A sub-menu contains menu items, possibly recursively.

What is not a Composite problem: "I have a single object and want to add behaviour" — that is Decorator. "I have a list of items but no nesting" — that is just a List<T>. Composite is specifically about uniform recursion through trees.

How does the Composite hierarchy look in .NET 10?

A common interface plus two implementations — leaf and composite. In modern C#, the leaf is usually a record and the composite is a class because it holds a mutable list of children:

public interface ICartNode
{
    decimal Total();
    string  Render(int indent = 0);
    int     ItemCount();
}

public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : ICartNode
{
    public decimal Total()             => UnitPrice * Quantity;
    public string  Render(int indent)  => $"{new string(' ', indent)}{Sku} x{Quantity} = {Total():C}";
    public int     ItemCount()         => Quantity;
}

public sealed class Bundle : ICartNode
{
    private readonly List<ICartNode> _children = new();
    public string Name { get; }
    public decimal DiscountFactor { get; }

    public Bundle(string name, decimal discountFactor = 1.0m)
        => (Name, DiscountFactor) = (name, discountFactor);

    public Bundle Add(ICartNode child) { _children.Add(child); return this; }

    public decimal Total()       => _children.Sum(c => c.Total()) * DiscountFactor;
    public int     ItemCount()   => _children.Sum(c => c.ItemCount());
    public string  Render(int indent)
    {
        var header = $"{new string(' ', indent)}{Name} (-{(1m - DiscountFactor) * 100:0}%):";
        var lines  = _children.Select(c => c.Render(indent + 2));
        return string.Join("\n", new[] { header }.Concat(lines));
    }
}

Notice that the only if/switch you wrote against node types is zero. Total() calls Total(). Each node knows how to handle itself.

The structural picture:

classDiagram
    class ICartNode {
        <<interface>>
        +Total() decimal
        +Render(indent) string
        +ItemCount() int
    }
    class LineItem {
        +Sku, UnitPrice, Quantity
    }
    class Bundle {
        -List children
        +Name, DiscountFactor
        +Add(child) Bundle
    }
    ICartNode <|.. LineItem
    ICartNode <|.. Bundle
    Bundle o-- ICartNode : children

A nested cart now reads naturally:

var cart = new Bundle("Cart");
cart.Add(new LineItem("BOOK-1", 9.99m, 2));
cart.Add(new Bundle("Holiday Box", 0.9m)
    .Add(new LineItem("MUG", 12m, 1))
    .Add(new LineItem("PEN", 3m, 1))
    .Add(new LineItem("PAD", 5m, 1)));

Console.WriteLine(cart.Render());
Console.WriteLine($"Total: {cart.Total():C}");

Output:

Cart (-0%):
  BOOK-1 x2 = $19.98
  Holiday Box (-10%):
    MUG x1 = $12.00
    PEN x1 = $3.00
    PAD x1 = $5.00
Total: $37.98

The 10% bundle discount is applied automatically by Bundle.Total(); the cart-level total just sums children. Adding a nested bundle does not change a single line of Bundle code.

How do you implement common operations on the tree?

Three patterns of operation cover most real cases:

For lazy iteration, IEnumerable<T> plus yield return and SelectMany keep the code crisp:

public IEnumerable<LineItem> Flatten() => this switch
{
    LineItem leaf => new[] { leaf },
    Bundle  comp  => comp.Children.SelectMany(c => c.Flatten()),
    _ => throw new InvalidOperationException("Unknown ICartNode")
};

(Yes, this snippet uses switch — but it lives in one extension method that knows the closed set of node types, not at every call site.)

When should you use a flat list instead?

Composite has a fixed cost — every operation traverses, every leaf must implement the interface — and that cost only pays back when the tree is genuinely a tree. Three signs you should not use it:

The litmus test: write the Composite, then write the same code as a flat list with one extra field for grouping. The shorter one wins.

How does Composite compare to Decorator?

Both wrap a node behind an interface; the difference is one-to-one versus one-to-many:

Aspect Composite Decorator
Holds A list of ICartNode children One ICartNode it wraps
Adds A new branching point New behaviour around the existing one
Interface relationship Composite is also an ICartNode Decorator is also an ICartNode
Typical mental model A tree An onion

You can — and frequently do — use both: a Composite tree of LineItems and Bundles, with a logging Decorator wrapping the root to trace every total computation. The patterns compose cleanly because they share the same interface contract.

What does a real .NET 10 example look like?

A complete, runnable shape — the cart from the running checkout domain, with a small extension method for traversal:

public interface ICartNode
{
    decimal Total();
    int     ItemCount();
    string  Render(int indent = 0);
}

public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : ICartNode
{
    public decimal Total()            => UnitPrice * Quantity;
    public int     ItemCount()        => Quantity;
    public string  Render(int indent) => $"{new string(' ', indent)}{Sku} x{Quantity} = {Total():C}";
}

public sealed class Bundle : ICartNode
{
    private readonly List<ICartNode> _children = new();
    public string  Name { get; }
    public decimal DiscountFactor { get; }
    public IReadOnlyList<ICartNode> Children => _children;

    public Bundle(string name, decimal discountFactor = 1.0m)
    {
        if (discountFactor <= 0 || discountFactor > 1)
            throw new ArgumentOutOfRangeException(nameof(discountFactor));
        (Name, DiscountFactor) = (name, discountFactor);
    }

    public Bundle Add(ICartNode child) { _children.Add(child); return this; }

    public decimal Total()       => _children.Sum(c => c.Total()) * DiscountFactor;
    public int     ItemCount()   => _children.Sum(c => c.ItemCount());

    public string Render(int indent)
    {
        var pct = (1m - DiscountFactor) * 100;
        var header = $"{new string(' ', indent)}{Name} ({(pct == 0 ? "no discount" : $"-{pct:0}%")}):";
        var lines  = _children.Select(c => c.Render(indent + 2));
        return string.Join("\n", new[] { header }.Concat(lines));
    }
}

public static class CartExtensions
{
    public static IEnumerable<LineItem> Flatten(this ICartNode node) => node switch
    {
        LineItem leaf => new[] { leaf },
        Bundle  comp  => comp.Children.SelectMany(Flatten),
        _ => Array.Empty<LineItem>(),
    };
}

// Usage
var cart = new Bundle("Cart")
    .Add(new LineItem("BOOK-1", 9.99m, 2))
    .Add(new Bundle("Holiday Box", 0.9m)
        .Add(new LineItem("MUG", 12m, 1))
        .Add(new LineItem("PEN", 3m, 1))
        .Add(new LineItem("PAD", 5m, 1)));

Console.WriteLine(cart.Render());
Console.WriteLine($"Total:    {cart.Total():C}");
Console.WriteLine($"Items:    {cart.ItemCount()}");
Console.WriteLine($"Distinct: {cart.Flatten().Select(l => l.Sku).Distinct().Count()}");

[Fact]
public void Bundle_applies_discount_to_children_only()
{
    var bundle = new Bundle("Box", 0.9m)
        .Add(new LineItem("A", 10m, 1))
        .Add(new LineItem("B", 10m, 1));
    Assert.Equal(18m, bundle.Total());                 // 20 * 0.9 = 18
    Assert.Equal(2, bundle.ItemCount());
}

Adding a third node type — say, FreeGift (always costs zero) — is one new record with no edits anywhere else:

public sealed record FreeGift(string Sku) : ICartNode
{
    public decimal Total() => 0m;
    public int ItemCount() => 1;
    public string Render(int indent) => $"{new string(' ', indent)}🎁 {Sku} (free)";
}

The cart's Total() continues to work without change, because every node honours the same contract.

A practical observation: nearly every domain that involves hierarchical data has a Composite hiding in it. JSON trees, AST nodes, org charts, BOM lists, file systems. Recognising the shape makes the difference between writing the same if (isLeaf) ... else ... ladder ten times and writing one interface that scales as the tree grows.

Frequently asked questions

What is the difference between Composite and Decorator?
Decorator wraps one component to add behaviour around it; the wrapper exposes the same interface as the wrapped object. Composite groups many components into one node that exposes the same interface as the leaves. Decorator is one-to-one (more behaviour); Composite is one-to-many (a tree). They look similar because both involve a class that holds a reference of the same interface — the count of held references is the tell.
Can records be used as Composite leaves?
Yes, and they are the natural choice for immutable leaves like a LineItem. The composite node usually has to be a class because it holds a mutable list of children, but you can keep the children as IReadOnlyList<ICartNode> and rebuild a new composite via copy-and-add helpers if you want the whole tree to stay immutable.
How do you visit a Composite tree without recursion stack overflow?
Two options. For shallow trees (under a few hundred levels) recursion is fine. For deep or unbounded trees, use an explicit Stack<T> or Queue<T> and iterate. The Visitor pattern generalises tree traversal further when you need to add new operations without modifying every node type.
When is a flat list better than a Composite tree?
When the data has only one level (no nesting) or when callers always need to flatten the tree before processing it. If every consumer immediately calls tree.SelectMany(t => t.Flatten()), the Composite is paying for structure no one is using. Store a flat IReadOnlyList<LineItem> instead and add a GroupBy when grouping is occasionally needed.