Composite
Composite gives single items and groups the same interface, so code can ask a whole city, a zone or a single station the same question. This page builds Pedal's city as a tree of zones and stations, and shows the bikes that nested loops quietly miss.
1. The pain
Pedal's operations dashboard shows free bikes for the whole city, each zone and each station. London has zones; some zones contain stations directly, and East has a sub-zone, Docklands, with stations of its own. The first version of the dashboard used two nested loops: zones, then the stations in each zone.
The loops quietly skipped Docklands, because a zone inside a zone isn't a station. The fix people reach for is if isinstance(child, Zone): … at every level, in every report that walks the city. Each new level of nesting, or each new report, repeats those checks.
2. The idea in one picture
Composite gives single items (leaves) and groups (composites) the same interface, and lets groups contain either. A Station answers free_bikes() with its own count; a Zone answers by asking each child and adding up. Because a zone's children are just Areas, zones can nest to any depth, and the dashboard calls one method on the city.
The design choice is how much of the interface makes sense for both. free_bikes() does; add_child() doesn't for a station. This page keeps child management on Zone only, so a station can't be asked to hold children.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Component | The interface for items and groups alike. | Area: free_bikes(), stations(), report() |
| Leaf | A single item with no children. | Station |
| Composite | A group whose children are components; answers by asking them. | Zone |
| Client | Calls the component interface on whatever it holds. | The ops dashboard |
4. Before and after
Counting free bikes both ways (Python sketch; the full program is in section 5).
Without the pattern
def city_free_bikes(city):
total = 0
for zone in city.zones:
for child in zone.children:
if isinstance(child, Station):
total += child.free
elif isinstance(child, Zone):
for s in child.children: # one more level...
total += s.free
return totalWith it
class Zone(Area):
def free_bikes(self):
return sum(c.free_bikes()
for c in self.children)
class Station(Area):
def free_bikes(self):
return self.free
city.free_bikes() # 23
zone_east.free_bikes() # 8| The next change | Without the pattern | With it |
|---|---|---|
| Docklands gets a sub-zone of its own | Another loop and another type check in every report | Nothing: zones nest to any depth |
| A new report: stations below 4 free | Write the nested walk again | city.stations(), then filter |
| The dashboard shows one station | A separate code path for stations | The same calls on a Station |
The recursion lives in the tree, once, instead of in every piece of code that walks it.
5. The code
Station and Zone share the Area interface; zones hold areas, including other zones. One call reports the whole city, another finds low stations at any depth.
from __future__ import annotations
from abc import ABC, abstractmethod
class Area(ABC):
"""One interface for a single station and for any group of them."""
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def free_bikes(self) -> int: ...
@abstractmethod
def stations(self) -> list[Station]: ...
@abstractmethod
def report(self, indent: str = "") -> list[str]: ...
class Station(Area): # a leaf
def __init__(self, name: str, free: int) -> None:
super().__init__(name)
self.free = free
def free_bikes(self) -> int:
return self.free
def stations(self) -> list[Station]:
return [self]
def report(self, indent: str = "") -> list[str]:
return [f"{indent}{self.name}: {self.free} free"]
class Zone(Area): # a composite: holds areas, which may be zones themselves
def __init__(self, name: str, children: list[Area]) -> None:
super().__init__(name)
self.children = children
def free_bikes(self) -> int:
return sum(c.free_bikes() for c in self.children)
def stations(self) -> list[Station]:
return [s for c in self.children for s in c.stations()]
def report(self, indent: str = "") -> list[str]:
n = len(self.stations())
head = f"{indent}{self.name}: {self.free_bikes()} free bikes at {n} station{'s' if n != 1 else ''}"
return [head] + [line for c in self.children for line in c.report(indent + " ")]
if __name__ == "__main__":
city = Zone("London", [
Zone("Central", [Station("S-12 Holborn", 7), Station("S-14 Bank", 8)]),
Zone("East", [Station("S-31 Bow", 3), Zone("Docklands", [Station("S-40 Canary Wharf", 5)])]),
])
print("\n".join(city.report()))
low = [s.name for s in city.stations() if s.free_bikes() < 4]
print(f"stations below 4 free: {', '.join(low)}")
one: Area = Station("S-12 Holborn", 7)
print(f"the same call on one station: {one.name} has {one.free_bikes()} free bikes")
// One interface for a single station and for any group of them.
abstract class Area {
constructor(readonly name: string) {}
abstract freeBikes(): number;
abstract stations(): Station[];
abstract report(indent?: string): string[];
}
class Station extends Area { // a leaf
constructor(name: string, private readonly free: number) { super(name); }
freeBikes(): number { return this.free; }
stations(): Station[] { return [this]; }
report(indent = ""): string[] { return [`${indent}${this.name}: ${this.free} free`]; }
}
class Zone extends Area { // a composite: holds areas, which may be zones themselves
constructor(name: string, private readonly children: Area[]) { super(name); }
freeBikes(): number { return this.children.reduce((sum, c) => sum + c.freeBikes(), 0); }
stations(): Station[] { return this.children.flatMap((c) => c.stations()); }
report(indent = ""): string[] {
const n = this.stations().length;
const head = `${indent}${this.name}: ${this.freeBikes()} free bikes at ${n} station${n !== 1 ? "s" : ""}`;
return [head, ...this.children.flatMap((c) => c.report(indent + " "))];
}
}
const city = new Zone("London", [
new Zone("Central", [new Station("S-12 Holborn", 7), new Station("S-14 Bank", 8)]),
new Zone("East", [new Station("S-31 Bow", 3), new Zone("Docklands", [new Station("S-40 Canary Wharf", 5)])]),
]);
console.log(city.report().join("\n"));
const low = city.stations().filter((s) => s.freeBikes() < 4).map((s) => s.name);
console.log(`stations below 4 free: ${low.join(", ")}`);
const one: Area = new Station("S-12 Holborn", 7);
console.log(`the same call on one station: ${one.name} has ${one.freeBikes()} free bikes`);import java.util.ArrayList;
import java.util.List;
public class Main {
// One interface for a single station and for any group of them.
abstract static class Area {
final String name;
Area(String name) { this.name = name; }
abstract int freeBikes();
abstract List<Station> stations();
abstract List<String> report(String indent);
}
static class Station extends Area { // a leaf
private final int free;
Station(String name, int free) { super(name); this.free = free; }
int freeBikes() { return free; }
List<Station> stations() { return List.of(this); }
List<String> report(String indent) { return List.of(indent + name + ": " + free + " free"); }
}
static class Zone extends Area { // a composite: holds areas, which may be zones themselves
private final List<Area> children;
Zone(String name, List<Area> children) { super(name); this.children = children; }
int freeBikes() { return children.stream().mapToInt(Area::freeBikes).sum(); }
List<Station> stations() {
List<Station> out = new ArrayList<>();
for (Area c : children) out.addAll(c.stations());
return out;
}
List<String> report(String indent) {
int n = stations().size();
List<String> out = new ArrayList<>();
out.add(indent + name + ": " + freeBikes() + " free bikes at " + n + " station" + (n != 1 ? "s" : ""));
for (Area c : children) out.addAll(c.report(indent + " "));
return out;
}
}
public static void main(String[] args) {
Zone city = new Zone("London", List.of(
new Zone("Central", List.of(new Station("S-12 Holborn", 7), new Station("S-14 Bank", 8))),
new Zone("East", List.of(new Station("S-31 Bow", 3), new Zone("Docklands", List.of(new Station("S-40 Canary Wharf", 5)))))));
System.out.println(String.join("\n", city.report("")));
List<String> low = new ArrayList<>();
for (Station s : city.stations()) if (s.freeBikes() < 4) low.add(s.name);
System.out.println("stations below 4 free: " + String.join(", ", low));
Area one = new Station("S-12 Holborn", 7);
System.out.println("the same call on one station: " + one.name + " has " + one.freeBikes() + " free bikes");
}
}Output (the same in all three languages)
London: 23 free bikes at 4 stations
Central: 15 free bikes at 2 stations
S-12 Holborn: 7 free
S-14 Bank: 8 free
East: 8 free bikes at 2 stations
S-31 Bow: 3 free
Docklands: 5 free bikes at 1 station
S-40 Canary Wharf: 5 free
stations below 4 free: S-31 Bow
the same call on one station: S-12 Holborn has 7 free bikes
6. Try it
Ask any part of the city
Click a zone or station to call free_bikes() on it and watch the call spread down the tree. Compare with the two-loop version.
7. When not to use it
What you get
- One call for any size: a station, a zone or a whole city.
- Any depth: zones inside zones need no new code.
- No type checks in clients: the tree does its own walking.
- New groups are easy: a region of cities is just another composite.
What you pay
- A shared interface can be too general: some operations only make sense for groups.
- Hard to restrict: "zones may only hold stations from one city" needs runtime checks.
- Hidden cost: one innocent call can walk thousands of nodes.
- Cycles break it: a zone that contains itself recurses forever.
| If… | Use instead |
|---|---|
| The structure is always exactly two levels | Two plain loops are clearer |
| You add behaviour around one object, not hold many | 07 Decorator |
| You need several different operations over the tree | Composite plus 21 Visitor or 20 Iterator |
| The tree is huge and the counts are read constantly | Keep running totals on each zone, updated as bikes move |
8. How it goes wrong
Station an add_child() that throws is a trap at runtime. Keep child management on the composite.if isinstance(area, Zone), an operation is missing from the interface.9. Remember it and say it
How this hook works: what “Ask down the org chart” means
What the slogan means, phrase by phrase
- “Ask”
- One method on the interface.
- “down the org chart”
- Composites forward to their children.
- “count yourself”
- Leaves answer directly.
Why this picture fits: Nobody in a company needs to know its depth to give a headcount, and neither does the dashboard.
The story → the concept
| The company | The code |
|---|---|
| CEO | The London zone |
| Division and team leads | Central, East, Docklands |
| A person | A Station |
| "How many people?" | free_bikes() |
Watch it on an example
- The dashboard asks London.
- London asks Central (15) and East; East asks Bow (3) and Docklands, which asks Canary Wharf (5).
- The answers add up on the way back: 23.
The trap it saves you from
Nested loops that silently miss anything deeper than they expected.
It still works when…
Yes: file systems (folders and files), UI trees (panels and buttons), and organisation charts.
In an interview or code review
- Say it's a tree of one type: "Stations and zones share an
Areainterface; a zone holds areas, so nesting is unlimited." - State the interface decision: child management lives on
Zoneonly (safety) rather than onArea(uniformity). - Mention cost: totals are recomputed per call; at large scale, keep running counts on each zone.
- Pair it: composites are often walked with an iterator or visited by a visitor (pages 20 and 21).
10. Check yourself
What do leaves and composites share?
How does a Zone answer free_bikes()?
Docklands gets its own sub-zone. What code changes?
Where should add_child() live?
A station is added to two zones. What goes wrong?
11. Go deeper
| Resource | Why |
|---|---|
| 20 Iterator | Walking a composite without knowing its shape. |
| 21 Visitor | Adding new operations over a composite's nodes. |
Python pathlib | Folders and files: the composite most people use daily. |
| Wikipedia: Composite pattern | Uniformity versus type safety. |