A single product has a price. A bundle of products also has a price. So why should the code asking “how much?” care which one it’s holding? Composite lets single items and groups of items share one interface, so the caller never has to check.
| Category | Structural |
| Smell fixed | S08: Bundles follow a separate catalogue and pricing path |
| Code | patterns/structural/composite |
The Scene
CornerStore sells bundles, like a running kit with shoes and socks at 10% off. When Ava adds one to her cart, it expands into its parts:
[014] cart.add customer=cu-1 sku=BUN-RUN-KIT
- SHOE-RUN-42 Runner Pro x2 @ A$89.95 = A$179.90
- SHOE-RUN-42 Runner Pro x1 @ A$80.96 = A$80.96 (from BUN-RUN-KIT)
- SOCK-WOOL Wool Socks x1 @ A$10.80 = A$10.80 (from BUN-RUN-KIT)
Earlier in the day, someone tried to put a bundle inside another bundle:
[008] bundle.add bundle=BUN-GIFT sku=BUN-RUN-KIT
rejected: BUN-RUN-KIT is a bundle and bundles cannot nest
Keep that rejection in mind. It comes back at the end.
The Smell
In the baseline, every piece of code that touches a catalogue item first asks: is this a bundle? Here’s pricing:
// SMELL S08: bundles take a separate pricing path picked with an is-check
if (item is Bundle bundle)
{
var total = 0m;
foreach (var componentSku in bundle.ComponentSkus)
{
total += products[componentSku].Price;
}
return Math.Round(total * (100 - bundle.DiscountPercent) / 100m, 2, MidpointRounding.AwayFromZero);
}
The cart has its own is Bundle check to expand bundles into lines, and adding to a bundle has another. Three places, all asking the same question, all with their own special path.
Notice too that a bundle stores ComponentSkus, a list of strings, and looks each one up in products. A bundle can only ever hold products. The data structure itself makes nesting impossible.
The Idea
Composite gives single items (leaves) and groups (composites) the same interface. A group implements each operation by asking its children to do the same thing and combining the answers. The caller just calls the method and never asks “which kind are you?”
Think of a folder on your computer. Ask a file for its size and you get a number. Ask a folder for its size and it asks everything inside it, including other folders, and adds them up. You use the same “properties” window for both.
The Refactor
Products and bundles now share one interface:
internal interface ICatalogItem
{
string Sku { get; }
string Name { get; }
decimal Price { get; }
IReadOnlyList<ICatalogItem> Components { get; }
string? AddToCart(Cart cart, int quantity, string fromBundle, decimal? unitPrice);
}
A Product is a leaf: its price is just its price. A Bundle is a composite: it holds ICatalogItem components (not strings) and works out its price by asking them:
public decimal Price
{
get
{
var total = 0m;
foreach (var component in components)
{
total += component.Price;
}
return Math.Round(total * (100 - DiscountPercent) / 100m, 2, MidpointRounding.AwayFromZero);
}
}
And the catalogue’s pricing method collapses to a single line of real work:
var item = Lookup(sku);
return item?.Price ?? throw new KeyNotFoundException("unknown sku " + sku);
No type check. The item knows how to price itself, whatever it is.
classDiagram
class ICatalogItem {
<<interface>>
+Price
+Components
+AddToCart(cart, quantity, fromBundle, unitPrice)
}
ICatalogItem <|.. Product
ICatalogItem <|.. Bundle
Bundle o--> "0..*" ICatalogItem : components
Catalog --> ICatalogItem : looks up and prices
CartService --> ICatalogItem : delegates cart addition
CartLine --> Product : stores product lines
About that nesting rule
Here’s the honest part. Look at the diagram: a Bundle holds ICatalogItems, and a Bundle is an ICatalogItem. Structurally, bundles can now contain bundles, and the pricing would just work, because each bundle asks its children for their price.
But step 8 of the trading day says nested bundles are rejected, and the golden master test holds the transcript to exactly that. So one is Bundle check remains, when adding to a bundle:
if (item is Bundle)
{
return sku + " is a bundle and bundles cannot nest";
That’s an important distinction. Before, the type check was how the code worked: it had no other way to price or expand a bundle. Now it’s a business rule: the code could support nesting, but the shop has decided not to. If the business changes its mind, it’s one if to remove, not three code paths to rewrite.
What It Cost
6 files changed: about 151 lines added and 71 removed. A fair chunk of the added code is in Bundle.AddToCart, which spreads the bundle’s discounted price across its component lines so the cart totals stay identical to the cent.
When Not to Use It
- There’s no real hierarchy. If groups can’t contain groups and you only have two fixed kinds of item, a couple of explicit branches may be easier to follow than a recursive structure.
- Leaves and groups need very different operations. Composite works when “do this to one” and “do this to many” are genuinely the same question. If half the interface makes no sense for a leaf (like
AddComponenton a single product), the shared contract becomes awkward.
Easily Confused With
Decorator. Both use recursive composition, and the diagrams look alike. The difference is intent. A composite groups children and combines their results (a bundle sums its parts). A decorator wraps one object to add a responsibility (gift wrap adds a fee around a price). See Composite vs Decorator.
Try It Yourself
dotnet build patterns/structural/composite/Composite.slnx
dotnet test patterns/structural/composite/Composite.slnx
dotnet run --project patterns/structural/composite/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: remove the nesting rule in Catalog.cs and run the scenario. The golden master fails, because step 8 now succeeds. Then look at the gift set’s new price in the transcript. It now includes the whole running kit, even though nobody wrote any nested-bundle pricing code. That’s Composite. (Whether the inner bundle’s discount should stack with the outer one is a business question the pattern can’t answer for you.)
The Composite README walks through how a bundle adds itself to a cart. The full change is in DIFF.md.