An online store's shopping cart has single products, bundles, and bundles that contain other bundles. A directory on a server holds files and subdirectories. A menu has items and submenus. All three are trees, and all three raise the same question: how do you compute a total price, a total size, or print the tree without writing if (x instanceof ...) everywhere? The Composite pattern answers with one simple idea: give leaf nodes and container nodes the same interface, so the caller never needs to know whether it holds one item or a whole group. This article implements the Composite pattern in Java 17 (sealed interface and record), runs it for real, and then covers the parts people tend to skip: where the child-management methods should live, and when an object tree is not worth turning into a Composite.
The previous article in the series covered the Facade pattern: a single entry point to a complex system, where several layers hide behind one front. The Composite pattern works in the opposite direction: instead of hiding, it makes many objects look like one. If you have not read the fundamentals yet, see SOLID principles in Java with practical examples, because both the benefits and the costs of the Composite pattern tie back to Open/Closed and Interface Segregation.
The problem the Composite pattern solves: object trees and type-check statements
Suppose a store sells single products (Product) and bundles (Bundle). A bundle gives a percentage discount and can hold single products or other bundles. The naive approach is to give Bundle two separate lists: one for products and one for child bundles. To compute a price, you write two loops. To add weight, you write two more loops. When a third kind appears, such as a "free gift", every place that walks the tree has to change.
The root of the problem is that callers are forced to know the concrete type of every node. Each operation on the tree (pricing, printing, searching, checking stock) becomes a function with instanceof inside. The Composite pattern moves that responsibility into the nodes themselves: each node knows how to answer, and a container answers by asking its children and combining the results.
Structure of the Composite pattern
According to the description on refactoring.guru (which also follows the Gang of Four book Design Patterns), the Composite pattern has four roles:
| Role | Responsibility | In the shopping cart example |
|---|---|---|
| Component | The common interface for leaf and container nodes | Item |
| Leaf | A node with no children, usually doing the real work | Product |
| Composite | Holds a list of children, delegates, then combines the results | Bundle |
| Client | Works only through the Component | The describe function, checkout code |
The key point of the Composite pattern is the sentence "the Composite talks to its children only through the Component". Because of that, the tree can be any number of levels deep, and adding a new kind of node does not break the code that walks the tree, which is exactly the spirit of Open/Closed.
Implementing the Composite pattern with a sealed interface and records
Since Java 17, a sealed interface lets you declare a closed list of subclasses, and a record gives you an immutable leaf in a single declaration line. Both features suit the Composite pattern well, because the set of node types is usually finite and known in advance. The whole example lives in one file, compiled with --release 17 to make sure it only uses Java 17 APIs:
sealed interface Item permits Product, Bundle {
String name();
long price(); // VND
double weightKg();
Stream<Item> flatten(); // walk the whole tree
void print(StringBuilder sb, int depth);
}
record Product(String name, long price, double weightKg) implements Item {
public Stream<Item> flatten() { return Stream.of(this); }
public void print(StringBuilder sb, int depth) {
sb.append(" ".repeat(depth)).append("- ").append(name)
.append(" : ").append(price).append(" VND\n");
}
}
/** Composite: holds the children and talks to them only through the Item interface. */
static final class Bundle implements Item {
private final String name;
private final int discountPercent;
private final List<Item> children = new ArrayList<>();
Bundle(String name, int discountPercent) {
this.name = name;
this.discountPercent = discountPercent;
}
Bundle add(Item item) {
if (item == this) throw new IllegalArgumentException("A bundle cannot contain itself");
children.add(item);
return this;
}
public String name() { return name; }
public long price() {
long sum = children.stream().mapToLong(Item::price).sum();
return sum * (100 - discountPercent) / 100;
}
public double weightKg() { return children.stream().mapToDouble(Item::weightKg).sum(); }
public Stream<Item> flatten() {
return Stream.concat(Stream.of(this), children.stream().flatMap(Item::flatten));
}
public void print(StringBuilder sb, int depth) {
sb.append(" ".repeat(depth)).append("+ ").append(name)
.append(" (").append(discountPercent).append("% off) : ").append(price()).append(" VND\n");
children.forEach(c -> c.print(sb, depth + 1));
}
}Three design decisions deserve a closer look. First, Bundle.price() never checks the type of a child: it calls Item::price and lets polymorphism do the rest. Second, tree traversal is split out into flatten(), which returns a Stream<Item>, so counting nodes, filtering products, and searching by name are each a single stream line. Third, add exists only on Bundle, and we come back to that choice right below.
The main part of the Composite pattern example builds a two-level tree and runs the operations:
public static void main(String[] args) {
Item chuot = new Product("Wireless mouse", 250_000, 0.1);
Item banPhim = new Product("Mechanical keyboard", 900_000, 0.9);
Item taiNghe = new Product("Headphones", 600_000, 0.3);
Item lot = new Product("Mouse pad", 100_000, 0.2);
Bundle phuKien = new Bundle("Accessories combo", 10).add(chuot).add(lot);
Bundle gaming = new Bundle("Gaming combo", 5).add(banPhim).add(taiNghe).add(phuKien);
StringBuilder sb = new StringBuilder();
gaming.print(sb, 0);
System.out.print(sb);
System.out.println("Tree total price: " + gaming.price() + " VND");
System.out.println("Total weight: " + gaming.weightKg() + " kg");
System.out.println("Node count (including bundles): " + gaming.flatten().count());
System.out.println("Single product count: " + gaming.flatten().filter(i -> i instanceof Product).count());
// The client treats everything uniformly: one function accepts a Product or a Bundle
for (Item i : new Item[]{chuot, phuKien, gaming}) {
System.out.println(describe(i));
}
// Cycle: A contains B, B contains A
Bundle a = new Bundle("A", 0);
Bundle b = new Bundle("B", 0);
a.add(b);
b.add(a);
try {
a.price();
} catch (StackOverflowError e) {
System.out.println("A cycle in the tree causes StackOverflowError");
}
}
static String describe(Item i) {
return String.format("%-18s price=%,d VND weight=%.1f kg", i.name(), i.price(), i.weightKg());
}Actual run output (java -cp out Composite1, compiled with --release 17, 16 GB RAM machine):
+ Gaming combo (5% off) : 1724250 VND
- Mechanical keyboard : 900000 VND
- Headphones : 600000 VND
+ Accessories combo (10% off) : 315000 VND
- Wireless mouse : 250000 VND
- Mouse pad : 100000 VND
Tree total price: 1724250 VND
Total weight: 1.5 kg
Node count (including bundles): 6
Single product count: 4
Wireless mouse price=250,000 VND weight=0.1 kg
Accessories combo price=315,000 VND weight=0.3 kg
Gaming combo price=1,724,250 VND weight=1.5 kg
A cycle in the tree causes StackOverflowErrorCheck the arithmetic by hand to be sure polymorphism is doing its job. Accessories combo: (250,000 + 100,000) × 90% = 315,000 VND. Gaming combo: (900,000 + 600,000 + 315,000) × 95% = 1,724,250 VND. Weight: 0.9 + 0.3 + 0.1 + 0.2 = 1.5 kg. The describe function accepts a single product, a small bundle, and a large bundle with the same signature: that is exactly the "uniform treatment" benefit the Composite pattern promises.
Transparent or safe Composite pattern: where add/remove should live
This is the point that material on the pattern argues about most. The GoF book calls the two options transparency and safety.
- Transparency: put
add/removedirectly on the Component. The client handles every node the same way, but leaf nodes must implement meaningless methods, usually by throwingUnsupportedOperationException. Refactoring.guru notes that this violates the Interface Segregation Principle. - Safety: put
add/removeonly on the Composite. There are no fake methods, but to modify the tree the client must know which node is a container, which means going back to type checks.
The Composite pattern example above chooses safety: Item has only read operations, and Bundle.add is a method of its own. The reason is that in practice, building the tree (entering bundles in an admin page) and using the tree (computing the price at checkout) usually live in two different places, and the code that builds the tree already knows it is creating a bundle. To see the cost of the transparent option, here is a directory tree built that way:
The snippet below is a shortened version of code/s6-10/src/Transparent.java: the enclosing class declaration, the import lines, and comments are trimmed, and the full code is in that file. This is the body of the Transparent class, with import java.util.ArrayList; and import java.util.List; at the top of the file:
interface Node {
long size();
default void add(Node child) { throw new UnsupportedOperationException(getClass().getSimpleName() + " is a leaf node"); }
}
record File(String name, long bytes) implements Node {
public long size() { return bytes; }
}
static class Dir implements Node {
private final List<Node> kids = new ArrayList<>();
@Override public void add(Node child) { kids.add(child); }
public long size() { return kids.stream().mapToLong(Node::size).sum(); }
}
public static void main(String[] args) {
Dir root = new Dir();
root.add(new File("a.log", 1_200));
Dir sub = new Dir();
sub.add(new File("b.log", 800));
root.add(sub);
System.out.println("Directory size: " + root.size() + " bytes");
Node leaf = new File("c.log", 10);
try {
leaf.add(new File("d.log", 5));
} catch (UnsupportedOperationException e) {
System.out.println("Runtime error: " + e.getMessage());
}
}
}Running java -cp out Transparent:
Directory size: 2000 bytes
Runtime error: File is a leaf nodeThe error appears at run time, not at compile time. Since Java 16, pattern matching for instanceof makes the safe option tidier: if you need to modify the tree, you write if (node instanceof Bundle b) b.add(child) with no manual cast. sealed interface brings another benefit: since Java 21, a switch over a sealed type is checked for exhaustiveness by the compiler, so adding a new kind of node produces an error at every switch that does not handle it yet. A pragmatic rule when implementing the Composite pattern: prefer the safe option for trees that are mostly read and computed on, and use the transparent option only when clients really need to modify the tree through the shared interface.
The Composite pattern in the JDK: java.awt.Container
The JDK's AWT/Swing widget set is the easiest example of the Composite pattern to verify. java.awt.Component is the Component, java.awt.Container extends it and holds a list of children, and JButton is conceptually a leaf. There is one interesting detail: because JComponent extends Container, practically every Swing component is a container as far as its type goes, even a button. The JDK chose the transparent option in a very broad way. The following code walks the tree and confirms it:
public static void main(String[] args) {
System.out.println("headless = " + GraphicsEnvironment.isHeadless());
JPanel root = new JPanel();
JPanel form = new JPanel();
form.add(new JButton("Save"));
form.add(new JButton("Cancel"));
root.add(form);
root.add(new JButton("Close"));
System.out.println("root has " + root.getComponentCount() + " direct children");
StringBuilder chain = new StringBuilder("JButton");
for (Class<?> c = JButton.class.getSuperclass(); c != Object.class; c = c.getSuperclass()) {
chain.append(" -> ").append(c.getSimpleName());
}
System.out.println("Inheritance chain: " + chain);
System.out.println("Component is a Container? JButton=" + isContainer(new JButton()) + ", JPanel=" + isContainer(form));
System.out.println("Button count in tree: " + countButtons(root));
}
static boolean isContainer(Component c) { return c instanceof Container; }
static int countButtons(Component c) {
int n = c instanceof JButton ? 1 : 0;
if (c instanceof Container ct) {
for (Component child : ct.getComponents()) n += countButtons(child);
}
return n;
}Output (java -Djava.awt.headless=true -cp out JdkComposite, run without a display):
headless = true
root has 2 direct children
Inheritance chain: JButton -> AbstractButton -> JComponent -> Container -> Component
Component is a Container? JButton=true, JPanel=true
Button count in tree: 3The inheritance chain shows that JButton passes through Container, so the question "is this node a container?" can no longer be answered with instanceof Container. The lesson: if you want leaf and container to stay distinct concepts, do not let the leaf inherit from a class that can hold children. A similar model appears in java.nio.file when walking a directory tree, in the XML DOM tree, and in the tree-shaped expressions inside compilers.
Variations of the Composite pattern: result caching, parent pointers, and child order
The basic Composite pattern is usually extended in three directions.
- Parent pointer: each node keeps a reference to its parent so you can walk upward, for example to show a breadcrumb path or to propagate change notifications. The cost is keeping both directions in sync on add/remove, and a memory leak is easy if you forget to detach.
- Caching aggregate results: for a large tree, running
price()every time walks the whole thing again. You can store the result in the container node and invalidate it when a child changes. Do this only after measuring the cost, because a cache adds a new kind of bug: stale data. - Child order: when order matters (menus, stacked graphic layers), use a
List; when it does not, aSetprevents duplicates.
The Composite pattern also combines well with other patterns. Visitor separates operations from the tree so you can add operations without touching the nodes. Iterator walks the tree without exposing its structure. If millions of leaves are identical, the leaves can be shared with Flyweight, the topic of the next article. And Decorator, covered in Decorator pattern: lessons from java.io applied to services, has a nearly identical recursive structure but a different purpose: Decorator wraps exactly one object to add behavior, while the Composite pattern groups many objects to represent a part-whole relationship.
When not to use the Composite pattern
The Composite pattern has its own cost, and the refactoring.guru documentation states one drawback clearly: when classes have very different functionality, the common interface is hard to define and easy to over-generalize, which makes it hard to understand. A few signs you are forcing the Composite pattern where it does not fit:
- The data is not really a tree, so the Composite pattern does not fit, for example a graph where one node has several parents. You then have to deal with cycles and duplicates, and the plain Composite pattern is not enough.
- Leaf and container nodes have completely different operations. The shared interface ends up full of methods that throw exceptions.
- The tree is shallow (Composite is overkill), for example just two fixed levels (order and order line). Two separate classes are much simpler.
- You need strict control over child types, for example "a bundle may contain only single products". The shared interface of the Composite pattern loses this constraint at compile time, and you have to check at run time.
Common mistakes when using the Composite pattern
- Cycles: if A contains B and B contains A, every recursive traversal never ends. The example above deliberately builds this loop and gets a
StackOverflowError; note thataddin the example only blocks a node from containing itself, not an indirect cycle through an ancestor. How to prevent it: check inadd(the node being added must not be an ancestor of the receiving node), or use an immutable tree built bottom-up, where a cycle cannot occur. - Recursion that is too deep: a tree tens of thousands of levels deep (for example a disguised linked list) overflows the stack. Use iterative traversal with an
ArrayDequeif the depth cannot be controlled. - Modifying the tree while traversing it: adding or removing children during a
forEachover the child list causesConcurrentModificationException. Iterate over a copy, or collect the changes and apply them afterward. - Sharing a node between two parents: a
Productinside two bundles is fine if the node is immutable (like the record above), but if a node has a parent pointer or changing state, sharing causes bugs that are hard to see. - Threads:
ArrayListis not thread-safe. A tree shared between threads should be immutable once built.
Checklist for applying the Composite pattern
- Is the data really a tree, with more than two levels, and do clients often operate on both groups and single elements?
- Do the operations (pricing, printing, searching) make sense for both leaf and container nodes, and can a container compute its result by combining its children's results?
- Did you choose the transparent or safe option on purpose, and record the reason in code review?
- Do you block cycles in
add, or build an immutable tree? - If you need to add operations often, consider Visitor for the Composite pattern so you do not have to change every node class.
Further reading: the java.awt.Container documentation in Java SE 17 and the Composite description on refactoring.guru. All the Composite pattern source code in this article was compiled and run again before the output was pasted.
Next article
The next article in the series is Bridge and Flyweight pattern: separating abstraction and sharing state. After the Composite pattern, we will see how much memory Flyweight saves when a tree has millions of identical leaves, measured with real numbers.



