One interface for leaves and groups
Think of a box in a warehouse. A box can hold products, or smaller boxes, which hold products or even smaller boxes. To know what the big box weighs, you don’t need to open everything yourself: each box knows its weight is the sum of what’s inside it, and each product knows its own weight.
Composite works the same way:
- Component: the shared interface. Here, a
CartItemwithprice,weightandpickList. - Leaf: a single thing with no children. A
Product. - Composite: a group that contains components, any mix of leaves and other groups. A
Bundle. It answers every question by asking its children and combining their answers.
Composite composes objects into tree structures to represent part-whole hierarchies and lets clients treat individual objects and compositions uniformly. The composite implements the component interface by delegating to its children and aggregating (sum, max, concatenation, any/all). Clients hold a component, so adding a new leaf type (a gift card, a digital download) means implementing the interface once, instead of updating every type switch. The pattern is everywhere in UI (view hierarchies, SwiftUI and Compose trees), document models, file systems, organization charts, permission groups, and expression trees (an AST where Add(Number(2), Multiply(...)) evaluates recursively).
protocol CartItem {
var priceCents: Int { get }
var weightGrams: Int { get }
func pickList() -> [String] // SKUs for the warehouse
}
struct Product: CartItem {
let sku: String
let priceCents: Int
let weightGrams: Int
func pickList() -> [String] { [sku] }
}
struct Bundle: CartItem {
let name: String
let children: [CartItem]
let discountPercent: Int
var priceCents: Int {
let sum = children.reduce(0) { $0 + $1.priceCents }
return sum * (100 - discountPercent) / 100
}
var weightGrams: Int { children.reduce(0) { $0 + $1.weightGrams } }
func pickList() -> [String] { children.flatMap { $0.pickList() } }
}
let cleaning = Bundle(name: "Cleaning set", children: [cloth, brush], discountPercent: 0)
let starter = Bundle(name: "Starter kit", children: [camera, memoryCard, cleaning], discountPercent: 10)
let total = cart.reduce(0) { $0 + $1.priceCents } // works for products and bundles alikesealed interface CartItem {
val priceCents: Int
val weightGrams: Int
fun pickList(): List<String> // SKUs for the warehouse
}
data class Product(
val sku: String,
override val priceCents: Int,
override val weightGrams: Int,
) : CartItem {
override fun pickList() = listOf(sku)
}
data class Bundle(
val name: String,
val children: List<CartItem>,
val discountPercent: Int = 0,
) : CartItem {
override val priceCents: Int
get() = children.sumOf { it.priceCents } * (100 - discountPercent) / 100
override val weightGrams: Int
get() = children.sumOf { it.weightGrams }
override fun pickList() = children.flatMap { it.pickList() }
}
val cleaning = Bundle("Cleaning set", listOf(cloth, brush))
val starter = Bundle("Starter kit", listOf(camera, memoryCard, cleaning), discountPercent = 10)
val total = cart.sumOf { it.priceCents } // works for products and bundles alikepublic sealed interface CartItem permits Product, Bundle {
int priceCents();
int weightGrams();
List<String> pickList(); // SKUs for the warehouse
}
public record Product(String sku, int priceCents, int weightGrams) implements CartItem {
public List<String> pickList() { return List.of(sku); }
}
public record Bundle(String name, List<CartItem> children, int discountPercent) implements CartItem {
public Bundle { children = List.copyOf(children); }
public int priceCents() {
int sum = children.stream().mapToInt(CartItem::priceCents).sum();
return sum * (100 - discountPercent) / 100;
}
public int weightGrams() { return children.stream().mapToInt(CartItem::weightGrams).sum(); }
public List<String> pickList() {
return children.stream().flatMap(c -> c.pickList().stream()).toList();
}
}
var cleaning = new Bundle("Cleaning set", List.of(cloth, brush), 0);
var starter = new Bundle("Starter kit", List.of(camera, memoryCard, cleaning), 10);
int total = cart.stream().mapToInt(CartItem::priceCents).sum(); // products and bundles alikeinterface CartItem {
readonly priceCents: number;
readonly weightGrams: number;
pickList(): string[]; // SKUs for the warehouse
}
class Product implements CartItem {
constructor(readonly sku: string, readonly priceCents: number, readonly weightGrams: number) {}
pickList() { return [this.sku]; }
}
class Bundle implements CartItem {
constructor(readonly name: string, readonly children: readonly CartItem[], readonly discountPercent = 0) {}
get priceCents() {
const sum = this.children.reduce((acc, c) => acc + c.priceCents, 0);
return Math.floor((sum * (100 - this.discountPercent)) / 100);
}
get weightGrams() { return this.children.reduce((acc, c) => acc + c.weightGrams, 0); }
pickList() { return this.children.flatMap((c) => c.pickList()); }
}
const cleaning = new Bundle("Cleaning set", [cloth, brush]);
const starter = new Bundle("Starter kit", [camera, memoryCard, cleaning], 10);
const total = cart.reduce((acc, item) => acc + item.priceCents, 0); // products and bundles alikeBundle.childrenhas the type of the interface, so a bundle can hold products, other bundles, or anything else that becomes aCartItemlater.- Each operation in
Bundleasks the children and combines their answers: sum for price and weight, joining lists for the pick list. A nested bundle answers the same way, so the recursion goes as deep as the tree does. - The cart total doesn’t care what’s in the cart. One line works for everything.
- When gift cards arrive,
GiftCardimplementsCartItem(price, zero weight, empty pick list) and every calculation includes it automatically.
Tap the line with the bug.
func shippingWeight(_ items: [CartItem]) -> Int {
var grams = 0
for item in items {
if let product = item as? Product {
grams += product.weightGrams
} else if let bundle = item as? Bundle {
grams += shippingWeight(bundle.children)
}
}
return grams
}fun shippingWeight(items: List<CartItem>): Int {
var grams = 0
for (item in items) {
if (item is Product) {
grams += item.weightGrams
} else if (item is Bundle) {
grams += shippingWeight(item.children)
}
}
return grams
}static int shippingWeight(List<CartItem> items) {
int grams = 0;
for (CartItem item : items) {
if (item instanceof Product p) {
grams += p.weightGrams();
} else if (item instanceof Bundle b) {
grams += shippingWeight(b.children());
}
}
return grams;
}function shippingWeight(items: readonly CartItem[]): number {
let grams = 0;
for (const item of items) {
if (item instanceof Product) {
grams += item.weightGrams;
} else if (item instanceof Bundle) {
grams += shippingWeight(item.children);
}
}
return grams;
}Hint: A new kind of item was added: a physical gift card in a box. Which branch handles it?
The function walks the tree itself, with a type check for each kind of item it knew about when it was written. A GiftCard (or any later item type) matches neither branch and adds nothing, silently. Every service with its own copy of this loop has the same gap.
Composite exists so callers don’t do this. Every item already knows its weight, including bundles, which compute it recursively:
func shippingWeight(_ items: [CartItem]) -> Int {
items.reduce(0) { $0 + $1.weightGrams }
}fun shippingWeight(items: List<CartItem>): Int = items.sumOf { it.weightGrams }static int shippingWeight(List<CartItem> items) {
return items.stream().mapToInt(CartItem::weightGrams).sum();
}const shippingWeight = (items: readonly CartItem[]) =>
items.reduce((acc, item) => acc + item.weightGrams, 0);When you do need to switch on the type (in Kotlin and Java, the interface is sealed), use an exhaustive when or switch without an else branch, so the compiler points at every place to update when a new type appears.
Predict the output
The Starter kit (10% off) contains a camera at 20000, a memory card at 3000 and the Cleaning set bundle (no discount) with a cloth at 500 and a brush at 500. What is starter.priceCents?
The Cleaning set asks its children: 500 + 500 = 1000. The Starter kit sums its children: 20000 + 3000 + 1000 = 24000, then applies its 10% discount: 21600. Each bundle applies its own rule to whatever its children report, which is exactly how nested discounts should compose.
- Component interface shared by leaves and composites; composites hold children typed as the component and aggregate their results recursively.
- Clients treat one item and a whole tree uniformly, so new leaf types don’t touch callers.
- Everywhere: view hierarchies, SwiftUI and Compose, documents, file systems, ASTs, permission groups.
- Type switches that walk the tree from outside defeat the pattern; if you must switch, use sealed types and exhaustive
when/switchwithoutelse.
Check this part
What type are a bundle's children?
How does a bundle compute its weight?
What's the risk of walking the tree with if/else type checks in each service?