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
- What problem does the Composite pattern solve in C#?
- How does the Composite hierarchy look in .NET 10?
- How do you implement common operations on the tree?
- When should you use a flat list instead?
- How does Composite compare to Decorator?
- What does a real .NET 10 example look like?
- 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:
- Shopping carts with bundles. Line items and bundles share
Total()andRender(); bundles can contain other bundles. - File systems. A file and a folder both have
Size,Path,Permissions. A folder is a group of files and sub-folders. - 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:
- Aggregations (sum, count, max). Composite calls the operation
on each child and combines. The example above does this for
Total()andItemCount(). - Rendering / serialisation. Composite renders itself and delegates rendering of children. Indentation is the most common parameter you pass down the recursion.
- Validation / iteration. Either return a single result (
bool IsValid()) or yield results lazily (IEnumerable<string> Errors()).
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:
- No nesting in real data. If product never authorises bundles
inside bundles, the Bundle/LineItem split is overkill. Inline the
bundle's items into the cart and use a
decimal Discountfield on the line. - Callers always flatten. If the first thing every consumer does
is
tree.Flatten().ToList(), you have a list dressed up as a tree. Store a list. - Operations are not actually shared. If
Bundle.Total()does something fundamentally different fromLineItem.Total()and callers must distinguish, the interface is a lie. Two different types with two different methods is honest.
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.
Where should you read next in this series?
- Previous: Bridge — when two axes vary independently and you do not want N×M classes.
- Next: Decorator — same interface, different intent (one-to-one wrapping that adds behaviour).
- Cross-reference: Visitor — when you need to add new operations across the tree without modifying every node type.
- Cross-reference: Iterator —
Flatten()above is exactly that pattern, exposed viaIEnumerable<T>. - Decision tree: How to choose the right design pattern.
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?
Can records be used as Composite leaves?
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?
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?
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.