Skip to content
Commitwise
Menu
Design Patterns

Composite

Treat single items and groups of items the same way, such as products and bundles in a cart or files and folders, build tree structures with one shared interface and recursion, and handle the practical problems of cycles, deep trees and expensive totals, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A shop sells products, and also bundles: “Starter kit” contains a camera, a memory card and a “Cleaning set”, which is itself a bundle of a cloth and a brush. The cart has to show prices, the warehouse needs a list of physical items to pick, and the shipping service wants the total weight.

The first implementation checks types everywhere: if item is Product … else if item is Bundle … for child in bundle.items …. Three services, three copies of the same recursion. Then gift cards arrive, one of the three copies isn’t updated, and gift cards inside bundles ship for free with zero weight and zero price.

The Composite pattern gives single items and groups one interface, so a bundle is just another item, and code that asks for a price, a weight or a pick list never checks what it’s holding.

New to some of these? Open what you need, or skip ahead.

Basics:What is recursion?

A function is recursive when it calls itself on a smaller part of the problem. The total size of a folder is the size of its files plus the total size of each subfolder, computed by the same function. The Recursion glossary entry has more.

Basics:What is a tree?

A tree is a structure where each element (a node) can contain other nodes, and nothing contains itself. Folders and files form a tree; so do the views on a screen. Nodes with no children are leaves.

Part Not started

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 CartItem with price, weight and pickList.
  • 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 alike
sealed 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 alike
public 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 alike
interface 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 alike
  • Bundle.children has the type of the interface, so a bundle can hold products, other bundles, or anything else that becomes a CartItem later.
  • Each operation in Bundle asks 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, GiftCard implements CartItem (price, zero weight, empty pick list) and every calculation includes it automatically.
Spot the bugGift cards in bundles ship for free

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;
}

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?

  • 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/switch without else.

Check this part

  1. What type are a bundle's children?

  2. How does a bundle compute its weight?

  3. What's the risk of walking the tree with if/else type checks in each service?

A composite and a leaf implement the same interface. The composite holds children of that interface type and implements each operation by combining its children's results, recursively. Callers treat one item and a whole tree the same way.

Part Not started

Composite in real code

Your UI code is already a composite. In SwiftUI, a VStack is a view that contains views; in Jetpack Compose, a Column is a composable that contains composables. You can put a Text (a leaf) or another Column (a group) inside, and the framework measures and draws them the same way: ask each child, combine the results.

Two practical questions come up when you build your own trees:

  • Where do add and remove go? Usually only on the group (a bundle can get a new child; a product can’t).
  • What if the tree is broken? If a bundle accidentally contains itself, computing its price never ends. Trees that come from users or a server need a check.

The GoF book discusses transparency vs safety: putting add/remove/children on the component makes all nodes uniform but forces leaves to throw or ignore; putting them only on the composite is type-safe but requires a cast when building. Modern code mostly chooses safety, and with immutable trees the question shrinks to construction. The production issues are elsewhere: cycles (a bundle referencing an ancestor via ids from a database) turn every recursive operation into a stack overflow; depth from untrusted input (deeply nested JSON comments, folder trees) can blow the stack even without cycles; and repeated aggregation gets expensive when large trees recompute totals on every frame. The answers are cycle detection at construction, iterative traversal with an explicit stack, and cached aggregates invalidated by immutable updates (structural sharing) or dirty flags.

Production puzzles

Predict the output

Bundles are loaded from the database by id. An admin edits 'Gift box' to include 'Holiday set', which already includes 'Gift box'. What happens the next time someone views either product?

func buildItem(_ id: ItemId, rows: [ItemId: ItemRow], path: Set<ItemId> = []) throws -> CartItem {
    guard !path.contains(id) else { throw CatalogError.cycle(id) }
    guard let row = rows[id] else { throw CatalogError.missing(id) }
    switch row.kind {
    case .product(let sku, let price, let weight):
        return Product(sku: sku, priceCents: price, weightGrams: weight)
    case .bundle(let name, let childIds, let discount):
        let children = try childIds.map { try buildItem($0, rows: rows, path: path.union([id])) }
        return Bundle(name: name, children: children, discountPercent: discount)
    }
}
fun buildItem(id: ItemId, rows: Map<ItemId, ItemRow>, path: Set<ItemId> = emptySet()): CartItem {
    require(id !in path) { "Cycle at $id" }
    return when (val row = rows[id] ?: error("Missing $id")) {
        is ItemRow.ProductRow -> Product(row.sku, row.priceCents, row.weightGrams)
        is ItemRow.BundleRow -> Bundle(
            row.name,
            row.childIds.map { buildItem(it, rows, path + id) },
            row.discountPercent,
        )
    }
}
static CartItem buildItem(ItemId id, Map<ItemId, ItemRow> rows, Set<ItemId> path) {
    if (path.contains(id)) throw new IllegalStateException("Cycle at " + id);
    ItemRow row = Objects.requireNonNull(rows.get(id), () -> "Missing " + id);
    return switch (row) {
        case ProductRow p -> new Product(p.sku(), p.priceCents(), p.weightGrams());
        case BundleRow b -> {
            var nextPath = new HashSet<>(path); nextPath.add(id);
            yield new Bundle(b.name(),
                b.childIds().stream().map(c -> buildItem(c, rows, nextPath)).toList(),
                b.discountPercent());
        }
    };
}
function buildItem(id: ItemId, rows: Map<ItemId, ItemRow>, path = new Set<ItemId>()): CartItem {
  if (path.has(id)) throw new Error(`Cycle at ${id}`);
  const row = rows.get(id);
  if (!row) throw new Error(`Missing ${id}`);
  if (row.kind === "product") return new Product(row.sku, row.priceCents, row.weightGrams);
  const nextPath = new Set(path).add(id);
  return new Bundle(row.name, row.childIds.map((c) => buildItem(c, rows, nextPath)), row.discountPercent);
}

The path set tracks only the current branch, so the same product appearing in two sibling bundles (a shared node, which is fine) isn’t mistaken for a cycle.

Predict the output

A comment thread is a composite (comments with replies). A spammer posts a chain of 100,000 nested replies through the API. The server renders the reply count with a recursive function. What's the risk, and the fix?

Transparency vs safety

Whether add(child) belongs on the component or only on the composite is the pattern’s classic design choice. With immutable trees, as in the examples here, children are passed at construction and the question mostly disappears. With mutable trees, put child management on the composite type, and accept a cast (or a builder) where trees are assembled; a leaf whose add throws UnsupportedOperationException is a runtime error waiting for the wrong caller.

Exerciseeasy

A folder tree

A files app shows folders and files. Every node has a name; files have a size in bytes; folders contain files and folders. The app needs the total size of any folder and the number of files inside it, at any depth. Sketch the interface, the leaf and the composite, and the two operations on each.

›

The leaf answers from its own data. The composite asks each child and combines the answers.

SolutionTry it yourself first
  • Interface FileNode with name, totalBytes() and fileCount().
  • Leaf FileItem: totalBytes() returns its size; fileCount() returns 1.
  • Composite Folder with children: List<FileNode>: totalBytes() is the sum of the children’s totalBytes(); fileCount() is the sum of their fileCount().

An empty folder returns 0 for both, with no special case. If folders can contain shortcuts to other folders, a shortcut must not be followed as a child, or a shortcut to a parent folder creates a cycle.

  • Transparency (child management on the component) vs safety (only on the composite); immutable trees mostly sidestep it.
  • Cycles from id-based storage overflow every recursive operation: validate on write, track the current path when building.
  • Untrusted depth overflows the stack without cycles: cap depth on input, traverse iteratively.
  • Large trees: cache aggregates with structural sharing or dirty flags rather than recomputing per frame.

Check this part

  1. What happens if a bundle contains itself?

  2. Why track the current path rather than all visited ids when detecting cycles?

  3. A deep but acyclic tree from user input can still…

Decide where child management lives: only on the composite (type-safe) or on the shared interface (uniform but leaves must refuse it). Real trees bring cycles, very deep nesting and expensive repeated totals; guard against cycles, iterate instead of recursing on untrusted depth, and cache with care.

Key takeaways

  • Composite lets single items and groups share one interface.
  • A group answers by asking its children and combining the answers.
  • Callers never need to check whether they hold one item or a group.
  • Watch out for trees that contain themselves.
  • Uniform treatment of leaves and composites; aggregation via recursion.
  • Type switches from outside defeat the pattern.
  • Guard against cycles and unbounded depth in data you don’t control.
  • Prefer immutable trees; cache aggregates deliberately.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.