Low Level DesignDesign patterns
Composite pattern
The Composite design pattern through three real examples, from folder sizes to store bundles with cycle checks to a document layout tree with dirty flags, with diagrams, live demos and code in five languages.
- Read first
- Open/closedLiskov substitution
Composite builds a tree out of objects that share one interface. So a whole tree and a single object answer the same questions, in the same way. A folder and a file both have a size. A bundle and a product both have a price. A layout container and a line of text both have a height. The caller asks, and never checks which kind it holds.
You use it every day. In the DOM, an element and a text node are both a Node. In Java’s AWT,
Container extends Component. On Android, a ViewGroup is a View. And every compiler holds its
program as a syntax tree. This page builds in three steps. First, folder sizes. Then store bundles,
with real pricing rules and a tree that people edit. Then a large layout tree that has to stay fast
while it changes.
The shape
Section titled “The shape”Every composite has the same parts:
- The component is the interface callers use, for example
Nodewithsize(). - Leaves implement it directly, for example
File. They have no children. - The composite implements it too, and holds a list of components. Its version of each operation calls the same operation on its children and combines the results.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
The arrow from Composite back to Component, with a *, is the whole pattern. A composite’s
children are components, so a child can be a leaf or another composite, to any depth. The
client has one arrow, to the interface, so it can’t tell a leaf from a tree of a thousand nodes.
This diagram puts add and remove only on the composite. The Gang of Four book put them on
the component instead, so that every node looks the same. Both are in use, and the Medium level
shows what each one costs.
Worked examples
Section titled “Worked examples”Folder sizes
- The basic shape
- One interface for leaf and tree
- Recursion in one place
A cloud drive shows the size of every folder in its sidebar and a “storage used” bar for the whole account. Folders hold files and other folders, to any depth. Next quarter the drive adds shortcuts: a link to a file somewhere else, which takes 1 KB and must not count its target’s size.
Without the pattern
Section titled “Without the pattern”File and Folder are unrelated classes, and each operation walks the tree by asking every
item what it is:
import java.util.ArrayList;import java.util.List;
// Two unrelated classes. Nothing in the types says a folder holds files and folders.class File { final String name; final int kb; File(String name, int kb) { this.name = name; this.kb = kb; }}
class Folder { final String name; // Object, because there is no common type. Anything can go in, even a String. final List<Object> items = new ArrayList<>(); Folder(String name) { this.name = name; }}
// Added later: a shortcut stores a path, not a copy, so it takes 1 KB on disk.class Link { final String name; Link(String name) { this.name = name; }}
class Explorer { // Every operation re-implements the walk and asks each item "what are you?". static int size(Object item) { if (item instanceof File f) return f.kb; if (item instanceof Folder d) { int total = 0; for (Object child : d.items) total += size(child); return total; } // Link was added after this was written, and nobody updated it. throw new IllegalArgumentException("unknown item type " + item.getClass().getSimpleName()); }
// A second operation, a second copy of the same branching and the same recursion. static int countFiles(Object item) { if (item instanceof File || item instanceof Link) return 1; if (item instanceof Folder d) { int total = 0; for (Object child : d.items) total += countFiles(child); return total; } throw new IllegalArgumentException("unknown item type " + item.getClass().getSimpleName()); }}
class Main { public static void main(String[] args) { Folder docs = new Folder("docs"); docs.items.add(new File("resume.pdf", 120)); docs.items.add(new File("taxes-2025.pdf", 840)); Folder home = new Folder("home"); home.items.add(docs); home.items.add(new File("notes.txt", 4));
System.out.println("home: " + Explorer.size(home) + " KB, " + Explorer.countFiles(home) + " files"); // home: 964 KB, 3 files
// The new type works in the one function that was updated... home.items.add(new Link("resume.lnk")); System.out.println("files: " + Explorer.countFiles(home)); // files: 4
// ...and fails at run time in the one that was missed. try { Explorer.size(home); } catch (IllegalArgumentException e) { System.out.println("size failed: " + e.getMessage()); // size failed: unknown item type Link } }}from dataclasses import dataclass, field
# Two unrelated classes. Nothing in the types says a folder holds files and folders.@dataclassclass File: name: str kb: int
@dataclassclass Folder: name: str # A plain list with no common type. Anything can go in, even a string. items: list = field(default_factory=list)
# Added later: a shortcut stores a path, not a copy, so it takes 1 KB on disk.@dataclassclass Link: name: str
# Every operation re-implements the walk and asks each item "what are you?".def size(item) -> int: if isinstance(item, File): return item.kb if isinstance(item, Folder): return sum(size(child) for child in item.items) # Link was added after this was written, and nobody updated it. raise ValueError(f"unknown item type {type(item).__name__}")
# A second operation, a second copy of the same branching and the same recursion.def count_files(item) -> int: if isinstance(item, (File, Link)): return 1 if isinstance(item, Folder): return sum(count_files(child) for child in item.items) raise ValueError(f"unknown item type {type(item).__name__}")
def main() -> None: docs = Folder("docs", [File("resume.pdf", 120), File("taxes-2025.pdf", 840)]) home = Folder("home", [docs, File("notes.txt", 4)])
print(f"home: {size(home)} KB, {count_files(home)} files") # home: 964 KB, 3 files
# The new type works in the one function that was updated... home.items.append(Link("resume.lnk")) print(f"files: {count_files(home)}") # files: 4
# ...and fails at run time in the one that was missed. try: size(home) except ValueError as e: print(f"size failed: {e}") # size failed: unknown item type Link
main()using System;using System.Collections.Generic;using System.Linq;
// Two unrelated classes. Nothing in the types says a folder holds files and folders.public sealed class File{ public string Name { get; } public int Kb { get; } public File(string name, int kb) { Name = name; Kb = kb; }}
public sealed class Folder{ public string Name { get; } // object, because there is no common type. Anything can go in, even a string. public List<object> Items { get; } = new(); public Folder(string name) { Name = name; }}
// Added later: a shortcut stores a path, not a copy, so it takes 1 KB on disk.public sealed class Link{ public string Name { get; } public Link(string name) { Name = name; }}
public static class Explorer{ // Every operation re-implements the walk and asks each item "what are you?". public static int Size(object item) => item switch { File f => f.Kb, Folder d => d.Items.Sum(child => Size(child)), // Link was added after this was written, and nobody updated it. _ => throw new ArgumentException($"unknown item type {item.GetType().Name}"), };
// A second operation, a second copy of the same branching and the same recursion. public static int CountFiles(object item) => item switch { File or Link => 1, Folder d => d.Items.Sum(child => CountFiles(child)), _ => throw new ArgumentException($"unknown item type {item.GetType().Name}"), };}
public static class Program{ public static void Main() { var docs = new Folder("docs"); docs.Items.Add(new File("resume.pdf", 120)); docs.Items.Add(new File("taxes-2025.pdf", 840)); var home = new Folder("home"); home.Items.Add(docs); home.Items.Add(new File("notes.txt", 4));
Console.WriteLine($"home: {Explorer.Size(home)} KB, {Explorer.CountFiles(home)} files"); // home: 964 KB, 3 files
// The new type works in the one function that was updated... home.Items.Add(new Link("resume.lnk")); Console.WriteLine($"files: {Explorer.CountFiles(home)}"); // files: 4
// ...and fails at run time in the one that was missed. try { Explorer.Size(home); } catch (ArgumentException e) { Console.WriteLine($"size failed: {e.Message}"); // size failed: unknown item type Link } }}package main
import ( "fmt" "reflect")
// Two unrelated types. Nothing in the types says a folder holds files and folders.type File struct { Name string KB int}
type Folder struct { Name string // any, because there is no common type. Anything can go in, even a string. Items []any}
// Link was added later: a shortcut stores a path, not a copy, so it takes 1 KB on disk.type Link struct{ Name string }
// size re-implements the walk and asks each item "what are you?".func size(item any) (int, error) { switch v := item.(type) { case *File: return v.KB, nil case *Folder: total := 0 for _, child := range v.Items { n, err := size(child) if err != nil { return 0, err } total += n } return total, nil } // Link was added after this was written, and nobody updated it. return 0, fmt.Errorf("unknown item type %s", reflect.TypeOf(item).Elem().Name())}
// countFiles is a second operation: a second copy of the same branching and recursion.func countFiles(item any) (int, error) { switch v := item.(type) { case *File, *Link: return 1, nil case *Folder: total := 0 for _, child := range v.Items { n, err := countFiles(child) if err != nil { return 0, err } total += n } return total, nil } return 0, fmt.Errorf("unknown item type %s", reflect.TypeOf(item).Elem().Name())}
func main() { docs := &Folder{Name: "docs", Items: []any{&File{"resume.pdf", 120}, &File{"taxes-2025.pdf", 840}}} home := &Folder{Name: "home", Items: []any{docs, &File{"notes.txt", 4}}}
kb, _ := size(home) files, _ := countFiles(home) fmt.Printf("home: %d KB, %d files\n", kb, files) // home: 964 KB, 3 files
// The new type works in the one function that was updated... home.Items = append(home.Items, &Link{"resume.lnk"}) files, _ = countFiles(home) fmt.Printf("files: %d\n", files) // files: 4
// ...and fails at run time in the one that was missed. if _, err := size(home); err != nil { fmt.Println("size failed:", err) // size failed: unknown item type Link }}// Two unrelated classes. Nothing in the types says a folder holds files and folders.class File { constructor(readonly name: string, readonly kb: number) {}}
class Folder { // unknown[], because there is no common type. Anything can go in, even a string. readonly items: unknown[] = []; constructor(readonly name: string) {}}
// Added later: a shortcut stores a path, not a copy, so it takes 1 KB on disk.class Link { constructor(readonly name: string) {}}
const typeName = (item: unknown) => (item as object).constructor.name;
// Every operation re-implements the walk and asks each item "what are you?".function size(item: unknown): number { if (item instanceof File) return item.kb; if (item instanceof Folder) return item.items.reduce<number>((sum, child) => sum + size(child), 0); // Link was added after this was written, and nobody updated it. throw new Error(`unknown item type ${typeName(item)}`);}
// A second operation, a second copy of the same branching and the same recursion.function countFiles(item: unknown): number { if (item instanceof File || item instanceof Link) return 1; if (item instanceof Folder) return item.items.reduce<number>((sum, child) => sum + countFiles(child), 0); throw new Error(`unknown item type ${typeName(item)}`);}
const docs = new Folder("docs");docs.items.push(new File("resume.pdf", 120), new File("taxes-2025.pdf", 840));const home = new Folder("home");home.items.push(docs, new File("notes.txt", 4));
console.log(`home: ${size(home)} KB, ${countFiles(home)} files`); // home: 964 KB, 3 files
// The new type works in the one function that was updated...home.items.push(new Link("resume.lnk"));console.log(`files: ${countFiles(home)}`); // files: 4
// ...and fails at run time in the one that was missed.try { size(home);} catch (e) { console.log(`size failed: ${(e as Error).message}`); // size failed: unknown item type Link}Every operation repeats the same branching and the same recursion. A folder holds a list of
Object, so the compiler can’t stop a string going in. When Link arrived, countFiles was
updated and size was missed, and the bug only appears when a user with a shortcut opens the
sidebar.
With Composite
Section titled “With Composite”Give files and folders one interface, Node. A file returns its own size. A folder returns
the sum of its children’s sizes, and it doesn’t care what kind of node each child is:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Add and delete files and watch every folder above them change. The right panel lists the
size() calls that one home.size() makes, each folder in the order it returned:
Explorer
What home.size() did, folders in the order they returned
Click a name to select it. Folders open and close with the arrow.
import java.util.ArrayList;import java.util.List;
// The component: what a file and a folder have in common. Callers only ever hold a Node,// so they treat one file and a whole tree of folders the same way.interface Node { String name();
// Size in KB. A file knows its own; a folder asks its children. int size();
void print(String indent);}
// A leaf: no children. Its size is a stored number.class File implements Node { private final String name; private final int kb; File(String name, int kb) { this.name = name; this.kb = kb; } public String name() { return name; } public int size() { return kb; } public void print(String indent) { System.out.println(indent + name + " " + kb + " KB"); }}
// A new kind of leaf is one class. Every operation on the tree works with it unchanged.class Shortcut implements Node { private final String name; Shortcut(String name) { this.name = name; } public String name() { return name; } public int size() { return 1; } // stores a path, not a copy of the target public void print(String indent) { System.out.println(indent + name + " 1 KB"); }}
// The composite: it IS a Node and HAS Nodes. Its size is the sum of its children's, and it// doesn't care whether a child is a file, a shortcut or another folder.class Folder implements Node { private final String name; private final List<Node> children = new ArrayList<>(); Folder(String name) { this.name = name; }
// Child management lives on the composite only: a file can't be asked to hold anything. Folder add(Node child) { children.add(child); return this; } void remove(Node child) { children.remove(child); }
public String name() { return name; }
// The recursion is here, once, instead of in every caller. Nothing is cached: every call // walks the whole subtree, which is fine for a few hundred files (the Hard level fixes it). public int size() { int total = 0; for (Node child : children) total += child.size(); return total; }
public void print(String indent) { System.out.println(indent + name + "/ " + size() + " KB"); for (Node child : children) child.print(indent + " "); }}
class Main { public static void main(String[] args) { File taxes = new File("taxes-2025.pdf", 840); Folder docs = new Folder("docs").add(new File("resume.pdf", 120)).add(taxes); Folder year = new Folder("2026").add(new File("beach.jpg", 2400)).add(new File("dog.jpg", 1800)); Folder photos = new Folder("photos").add(year).add(new File("avatar.png", 300)); File notes = new File("notes.txt", 4); Folder home = new Folder("home").add(docs).add(photos).add(notes).add(new Shortcut("resume.lnk"));
home.print(""); // home/ 5465 KB // docs/ 960 KB // resume.pdf 120 KB // taxes-2025.pdf 840 KB // photos/ 4500 KB // 2026/ 4200 KB // beach.jpg 2400 KB // dog.jpg 1800 KB // avatar.png 300 KB // notes.txt 4 KB // resume.lnk 1 KB
// One file or a whole folder: the caller asks the same question the same way. for (Node n : List.of(notes, photos)) { System.out.println(n.name() + ": " + n.size() + " KB"); } // notes.txt: 4 KB // photos: 4500 KB
// A change deep in the tree shows up in every ancestor, because sizes are computed, not stored. year.add(new File("sunset.jpg", 2100)); System.out.println("after adding sunset.jpg: home " + home.size() + " KB"); // after adding sunset.jpg: home 7565 KB docs.remove(taxes); System.out.println("after deleting taxes-2025.pdf: home " + home.size() + " KB"); // after deleting taxes-2025.pdf: home 6725 KB }}from abc import ABC, abstractmethod
# The component: what a file and a folder have in common. Callers only ever hold a Node,# so they treat one file and a whole tree of folders the same way.class Node(ABC): def __init__(self, name: str) -> None: self.name = name
# Size in KB. A file knows its own; a folder asks its children. @abstractmethod def size(self) -> int: ...
@abstractmethod def print(self, indent: str = "") -> None: ...
# A leaf: no children. Its size is a stored number.class File(Node): def __init__(self, name: str, kb: int) -> None: super().__init__(name) self.kb = kb
def size(self) -> int: return self.kb
def print(self, indent: str = "") -> None: print(f"{indent}{self.name} {self.kb} KB")
# A new kind of leaf is one class. Every operation on the tree works with it unchanged.class Shortcut(Node): def size(self) -> int: return 1 # stores a path, not a copy of the target
def print(self, indent: str = "") -> None: print(f"{indent}{self.name} 1 KB")
# The composite: it IS a Node and HAS Nodes. Its size is the sum of its children's, and it# doesn't care whether a child is a file, a shortcut or another folder.class Folder(Node): def __init__(self, name: str) -> None: super().__init__(name) self._children: list[Node] = []
# Child management lives on the composite only: a file can't be asked to hold anything. def add(self, child: Node) -> "Folder": self._children.append(child) return self
def remove(self, child: Node) -> None: self._children.remove(child)
# The recursion is here, once, instead of in every caller. Nothing is cached: every call # walks the whole subtree, which is fine for a few hundred files (the Hard level fixes it). def size(self) -> int: return sum(child.size() for child in self._children)
def print(self, indent: str = "") -> None: print(f"{indent}{self.name}/ {self.size()} KB") for child in self._children: child.print(indent + " ")
def main() -> None: taxes = File("taxes-2025.pdf", 840) docs = Folder("docs").add(File("resume.pdf", 120)).add(taxes) year = Folder("2026").add(File("beach.jpg", 2400)).add(File("dog.jpg", 1800)) photos = Folder("photos").add(year).add(File("avatar.png", 300)) notes = File("notes.txt", 4) home = Folder("home").add(docs).add(photos).add(notes).add(Shortcut("resume.lnk"))
home.print() # home/ 5465 KB # docs/ 960 KB # resume.pdf 120 KB # taxes-2025.pdf 840 KB # photos/ 4500 KB # 2026/ 4200 KB # beach.jpg 2400 KB # dog.jpg 1800 KB # avatar.png 300 KB # notes.txt 4 KB # resume.lnk 1 KB
# One file or a whole folder: the caller asks the same question the same way. for n in (notes, photos): print(f"{n.name}: {n.size()} KB") # notes.txt: 4 KB # photos: 4500 KB
# A change deep in the tree shows up in every ancestor, because sizes are computed, not stored. year.add(File("sunset.jpg", 2100)) print(f"after adding sunset.jpg: home {home.size()} KB") # after adding sunset.jpg: home 7565 KB docs.remove(taxes) print(f"after deleting taxes-2025.pdf: home {home.size()} KB") # after deleting taxes-2025.pdf: home 6725 KB
main()using System;using System.Collections.Generic;using System.Linq;
// The component: what a file and a folder have in common. Callers only ever hold an INode,// so they treat one file and a whole tree of folders the same way.public interface INode{ string Name { get; }
// Size in KB. A file knows its own; a folder asks its children. int Size();
void Print(string indent = "");}
// A leaf: no children. Its size is a stored number.public sealed class File : INode{ private readonly int _kb; public File(string name, int kb) { Name = name; _kb = kb; } public string Name { get; } public int Size() => _kb; public void Print(string indent = "") => Console.WriteLine($"{indent}{Name} {_kb} KB");}
// A new kind of leaf is one class. Every operation on the tree works with it unchanged.public sealed class Shortcut : INode{ public Shortcut(string name) { Name = name; } public string Name { get; } public int Size() => 1; // stores a path, not a copy of the target public void Print(string indent = "") => Console.WriteLine($"{indent}{Name} 1 KB");}
// The composite: it IS an INode and HAS INodes. Its size is the sum of its children's, and it// doesn't care whether a child is a file, a shortcut or another folder.public sealed class Folder : INode{ private readonly List<INode> _children = new(); public Folder(string name) { Name = name; } public string Name { get; }
// Child management lives on the composite only: a file can't be asked to hold anything. public Folder Add(INode child) { _children.Add(child); return this; } public void Remove(INode child) => _children.Remove(child);
// The recursion is here, once, instead of in every caller. Nothing is cached: every call // walks the whole subtree, which is fine for a few hundred files (the Hard level fixes it). public int Size() => _children.Sum(child => child.Size());
public void Print(string indent = "") { Console.WriteLine($"{indent}{Name}/ {Size()} KB"); foreach (var child in _children) child.Print(indent + " "); }}
public static class Program{ public static void Main() { var taxes = new File("taxes-2025.pdf", 840); var docs = new Folder("docs").Add(new File("resume.pdf", 120)).Add(taxes); var year = new Folder("2026").Add(new File("beach.jpg", 2400)).Add(new File("dog.jpg", 1800)); var photos = new Folder("photos").Add(year).Add(new File("avatar.png", 300)); var notes = new File("notes.txt", 4); var home = new Folder("home").Add(docs).Add(photos).Add(notes).Add(new Shortcut("resume.lnk"));
home.Print(); // home/ 5465 KB // docs/ 960 KB // resume.pdf 120 KB // taxes-2025.pdf 840 KB // photos/ 4500 KB // 2026/ 4200 KB // beach.jpg 2400 KB // dog.jpg 1800 KB // avatar.png 300 KB // notes.txt 4 KB // resume.lnk 1 KB
// One file or a whole folder: the caller asks the same question the same way. foreach (INode n in new INode[] { notes, photos }) { Console.WriteLine($"{n.Name}: {n.Size()} KB"); } // notes.txt: 4 KB // photos: 4500 KB
// A change deep in the tree shows up in every ancestor, because sizes are computed, not stored. year.Add(new File("sunset.jpg", 2100)); Console.WriteLine($"after adding sunset.jpg: home {home.Size()} KB"); // after adding sunset.jpg: home 7565 KB docs.Remove(taxes); Console.WriteLine($"after deleting taxes-2025.pdf: home {home.Size()} KB"); // after deleting taxes-2025.pdf: home 6725 KB }}package main
import ( "fmt" "slices")
// Node is the component: what a file and a folder have in common. Callers only ever hold a// Node, so they treat one file and a whole tree of folders the same way.type Node interface { Name() string // Size in KB. A file knows its own; a folder asks its children. Size() int Print(indent string)}
// File is a leaf: no children. Its size is a stored number.type File struct { name string kb int}
func (f *File) Name() string { return f.name }func (f *File) Size() int { return f.kb }func (f *File) Print(indent string) { fmt.Printf("%s%s %d KB\n", indent, f.name, f.kb) }
// Shortcut shows that a new kind of leaf is one type. Every operation works with it unchanged.type Shortcut struct{ name string }
func (s *Shortcut) Name() string { return s.name }func (s *Shortcut) Size() int { return 1 } // stores a path, not a copy of the targetfunc (s *Shortcut) Print(indent string) { fmt.Printf("%s%s 1 KB\n", indent, s.name) }
// Folder is the composite: it IS a Node and HAS Nodes. Its size is the sum of its children's,// and it doesn't care whether a child is a file, a shortcut or another folder.type Folder struct { name string children []Node}
// Add and Remove live on the composite only: a file can't be asked to hold anything.func (d *Folder) Add(child Node) *Folder { d.children = append(d.children, child) return d}
func (d *Folder) Remove(child Node) { if i := slices.Index(d.children, child); i >= 0 { d.children = slices.Delete(d.children, i, i+1) }}
func (d *Folder) Name() string { return d.name }
// Size holds the recursion, once, instead of every caller. Nothing is cached: every call// walks the whole subtree, which is fine for a few hundred files (the Hard level fixes it).func (d *Folder) Size() int { total := 0 for _, child := range d.children { total += child.Size() } return total}
func (d *Folder) Print(indent string) { fmt.Printf("%s%s/ %d KB\n", indent, d.name, d.Size()) for _, child := range d.children { child.Print(indent + " ") }}
func main() { taxes := &File{"taxes-2025.pdf", 840} docs := (&Folder{name: "docs"}).Add(&File{"resume.pdf", 120}).Add(taxes) year := (&Folder{name: "2026"}).Add(&File{"beach.jpg", 2400}).Add(&File{"dog.jpg", 1800}) photos := (&Folder{name: "photos"}).Add(year).Add(&File{"avatar.png", 300}) notes := &File{"notes.txt", 4} home := (&Folder{name: "home"}).Add(docs).Add(photos).Add(notes).Add(&Shortcut{"resume.lnk"})
home.Print("") // home/ 5465 KB // docs/ 960 KB // resume.pdf 120 KB // taxes-2025.pdf 840 KB // photos/ 4500 KB // 2026/ 4200 KB // beach.jpg 2400 KB // dog.jpg 1800 KB // avatar.png 300 KB // notes.txt 4 KB // resume.lnk 1 KB
// One file or a whole folder: the caller asks the same question the same way. for _, n := range []Node{notes, photos} { fmt.Printf("%s: %d KB\n", n.Name(), n.Size()) } // notes.txt: 4 KB // photos: 4500 KB
// A change deep in the tree shows up in every ancestor, because sizes are computed, not stored. year.Add(&File{"sunset.jpg", 2100}) fmt.Printf("after adding sunset.jpg: home %d KB\n", home.Size()) // after adding sunset.jpg: home 7565 KB docs.Remove(taxes) fmt.Printf("after deleting taxes-2025.pdf: home %d KB\n", home.Size()) // after deleting taxes-2025.pdf: home 6725 KB}// The component: what a file and a folder have in common. Callers only ever hold a Node,// so they treat one file and a whole tree of folders the same way.interface Node { readonly name: string; // Size in KB. A file knows its own; a folder asks its children. size(): number; print(indent?: string): void;}
// A leaf: no children. Its size is a stored number.class File implements Node { constructor(readonly name: string, private readonly kb: number) {} size(): number { return this.kb; } print(indent = ""): void { console.log(`${indent}${this.name} ${this.kb} KB`); }}
// A new kind of leaf is one class. Every operation on the tree works with it unchanged.class Shortcut implements Node { constructor(readonly name: string) {} size(): number { return 1; // stores a path, not a copy of the target } print(indent = ""): void { console.log(`${indent}${this.name} 1 KB`); }}
// The composite: it IS a Node and HAS Nodes. Its size is the sum of its children's, and it// doesn't care whether a child is a file, a shortcut or another folder.class Folder implements Node { private readonly children: Node[] = []; constructor(readonly name: string) {}
// Child management lives on the composite only: a file can't be asked to hold anything. add(child: Node): this { this.children.push(child); return this; } remove(child: Node): void { const i = this.children.indexOf(child); if (i >= 0) this.children.splice(i, 1); }
// The recursion is here, once, instead of in every caller. Nothing is cached: every call // walks the whole subtree, which is fine for a few hundred files (the Hard level fixes it). size(): number { return this.children.reduce((sum, child) => sum + child.size(), 0); }
print(indent = ""): void { console.log(`${indent}${this.name}/ ${this.size()} KB`); for (const child of this.children) child.print(indent + " "); }}
const taxes = new File("taxes-2025.pdf", 840);const docs = new Folder("docs").add(new File("resume.pdf", 120)).add(taxes);const year = new Folder("2026").add(new File("beach.jpg", 2400)).add(new File("dog.jpg", 1800));const photos = new Folder("photos").add(year).add(new File("avatar.png", 300));const notes = new File("notes.txt", 4);const home = new Folder("home").add(docs).add(photos).add(notes).add(new Shortcut("resume.lnk"));
home.print();// home/ 5465 KB// docs/ 960 KB// resume.pdf 120 KB// taxes-2025.pdf 840 KB// photos/ 4500 KB// 2026/ 4200 KB// beach.jpg 2400 KB// dog.jpg 1800 KB// avatar.png 300 KB// notes.txt 4 KB// resume.lnk 1 KB
// One file or a whole folder: the caller asks the same question the same way.for (const n of [notes, photos] as Node[]) { console.log(`${n.name}: ${n.size()} KB`);}// notes.txt: 4 KB// photos: 4500 KB
// A change deep in the tree shows up in every ancestor, because sizes are computed, not stored.year.add(new File("sunset.jpg", 2100));console.log(`after adding sunset.jpg: home ${home.size()} KB`); // after adding sunset.jpg: home 7565 KBdocs.remove(taxes);console.log(`after deleting taxes-2025.pdf: home ${home.size()} KB`); // after deleting taxes-2025.pdf: home 6725 KBWhat to notice
Section titled “What to notice”- Callers hold a
Node.notes.size()andphotos.size()are the same call. Code that shows a size, sorts by size or checks a quota works for one file or a whole tree without a branch. - The recursion is written once.
Folder.size()is the only place that loops over children. In the version without the pattern, every operation had its own copy. - A new kind of node is one class.
ShortcutimplementsNode, andsize,printand anything written later handle it with no edits. That is the open-closed principle applied to a tree. - Only folders have
addandremove. A file can’t be asked to hold anything, because the method isn’t there. The Medium level looks at the other choice. - Sizes are computed, never stored. So they can’t go stale. A change deep in the tree shows up
in every ancestor, with no code to update them. The cost is time.
printcallssize()on every folder, so each file is summed once for every folder above it. That’s fine for a few hundred files, and too slow for a large tree. That’s the Hard level’s problem.
Design the classes for a file browser. Folders hold files and other folders, and we need to show the size of any folder.
Whether you give files and folders one interface with size(). Whether you put the loop over
children in the folder. And whether you keep callers free of type checks. The red flag is an
instanceof chain in a helper. So is a size field on each folder, updated by hand from every
place that adds a file.
Files and folders share one interface, Node, with size(). A file returns its own size. A folder holds a list of Nodes, and returns the sum of their sizes. It doesn’t care whether a child is a file or another folder. Callers ask any node the same way.
Store bundles
- Real rules over the tree
- Transparency or safety
- Parent pointers and moves
- Refusing cycles
- New operations without new methods
An electronics store sells bundles. A merchandiser builds them in an admin tool. A “Home office” bundle holds a laptop, a “Desk setup” bundle, an “Accessories” bundle and a gift card. Each bundle has its own discount. The pricing rules are real ones:
- Gift cards and extended warranties are never discounted. Warranties are priced by the insurer, and a discounted gift card is free money.
- Discounts don’t stack. An item gets the discount of the innermost bundle it’s in. The outer bundle discounts only what nobody below it has discounted.
Merchandisers move items between bundles all day, by dragging them in the admin tool.
Without the pattern
Section titled “Without the pattern”Take the Easy level’s shape and use it as is: an interface, a leaf, and a composite that sums its children and applies its discount:
import java.util.ArrayList;import java.util.List;
// The Easy shape, carried over: an interface, a leaf and a composite that sums its children.interface Item { long cents(); }
class Product implements Item { private final long cents; Product(long cents) { this.cents = cents; } public long cents() { return cents; }}
class Bundle implements Item { private final String name; private final int percentOff; final List<Item> items = new ArrayList<>(); Bundle(String name, int percentOff) { this.name = name; this.percentOff = percentOff; }
// Two real rules are already wrong here: excluded items (gift cards) get discounted too, // and an outer bundle discounts what an inner bundle already discounted. public long cents() { long sum = 0; for (Item item : items) sum += item.cents(); return sum * (100 - percentOff) / 100; }
// No checks and no parent pointer: any item can be added anywhere, any number of times. Bundle add(Item item) { items.add(item); return this; } public String toString() { return name; }}
class Main { static String money(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }
public static void main(String[] args) { Product keyboard = new Product(8000); Bundle desk = new Bundle("Desk setup", 5).add(new Product(30000)).add(keyboard).add(new Product(4900)); Bundle accessories = new Bundle("Accessories", 15).add(new Product(4000)).add(new Product(2000)); Bundle office = new Bundle("Home office", 10) .add(new Product(120000)).add(desk).add(accessories).add(new Product(5000));
// The store's rules give $1591.00. Stacked discounts and a discounted gift card lose $53.31. System.out.println("total: " + money(office.cents())); // total: $1537.69
// Moving the keyboard: with no parent pointer, the admin tool must search the catalog for // the keyboard's old bundle. This one forgot, so the keyboard is now in both bundles. accessories.add(keyboard); System.out.println("after the move: " + money(office.cents())); // after the move: $1598.89
// Nothing stops a bundle going inside its own child. The next cents() call recurses // forever and ends in a stack overflow, on the product page, not in the admin tool. desk.add(office); System.out.println("accepted: " + desk + " now holds " + office + ", which holds " + desk); // accepted: Desk setup now holds Home office, which holds Desk setup }}from __future__ import annotations
from abc import ABC, abstractmethod
def money(cents: int) -> str: return f"${cents // 100}.{cents % 100:02d}"
# The Easy shape, carried over: an interface, a leaf and a composite that sums its children.class Item(ABC): @abstractmethod def cents(self) -> int: ...
class Product(Item): def __init__(self, cents: int) -> None: self._cents = cents
def cents(self) -> int: return self._cents
class Bundle(Item): def __init__(self, name: str, percent_off: int) -> None: self.name = name self._percent_off = percent_off self.items: list[Item] = []
# Two real rules are already wrong here: excluded items (gift cards) get discounted too, # and an outer bundle discounts what an inner bundle already discounted. def cents(self) -> int: return sum(item.cents() for item in self.items) * (100 - self._percent_off) // 100
# No checks and no parent pointer: any item can be added anywhere, any number of times. def add(self, item: Item) -> Bundle: self.items.append(item) return self
def main() -> None: keyboard = Product(8000) desk = Bundle("Desk setup", 5).add(Product(30000)).add(keyboard).add(Product(4900)) accessories = Bundle("Accessories", 15).add(Product(4000)).add(Product(2000)) office = (Bundle("Home office", 10) .add(Product(120000)).add(desk).add(accessories).add(Product(5000)))
# The store's rules give $1591.00. Stacked discounts and a discounted gift card lose $53.31. print(f"total: {money(office.cents())}") # total: $1537.69
# Moving the keyboard: with no parent pointer, the admin tool must search the catalog for # the keyboard's old bundle. This one forgot, so the keyboard is now in both bundles. accessories.add(keyboard) print(f"after the move: {money(office.cents())}") # after the move: $1598.89
# Nothing stops a bundle going inside its own child. The next cents() call recurses # forever and ends in a stack overflow, on the product page, not in the admin tool. desk.add(office) print(f"accepted: {desk.name} now holds {office.name}, which holds {desk.name}") # accepted: Desk setup now holds Home office, which holds Desk setup
main()using System;using System.Collections.Generic;using System.Linq;
// The Easy shape, carried over: an interface, a leaf and a composite that sums its children.public interface IItem{ long Cents();}
public sealed class Product : IItem{ private readonly long _cents; public Product(long cents) { _cents = cents; } public long Cents() => _cents;}
public sealed class Bundle : IItem{ private readonly int _percentOff; public string Name { get; } public List<IItem> Items { get; } = new(); public Bundle(string name, int percentOff) { Name = name; _percentOff = percentOff; }
// Two real rules are already wrong here: excluded items (gift cards) get discounted too, // and an outer bundle discounts what an inner bundle already discounted. public long Cents() => Items.Sum(item => item.Cents()) * (100 - _percentOff) / 100;
// No checks and no parent pointer: any item can be added anywhere, any number of times. public Bundle Add(IItem item) { Items.Add(item); return this; }}
public static class Program{ static string Money(long cents) => $"${cents / 100}.{cents % 100:D2}";
public static void Main() { var keyboard = new Product(8000); var desk = new Bundle("Desk setup", 5).Add(new Product(30000)).Add(keyboard).Add(new Product(4900)); var accessories = new Bundle("Accessories", 15).Add(new Product(4000)).Add(new Product(2000)); var office = new Bundle("Home office", 10) .Add(new Product(120000)).Add(desk).Add(accessories).Add(new Product(5000));
// The store's rules give $1591.00. Stacked discounts and a discounted gift card lose $53.31. Console.WriteLine($"total: {Money(office.Cents())}"); // total: $1537.69
// Moving the keyboard: with no parent pointer, the admin tool must search the catalog for // the keyboard's old bundle. This one forgot, so the keyboard is now in both bundles. accessories.Add(keyboard); Console.WriteLine($"after the move: {Money(office.Cents())}"); // after the move: $1598.89
// Nothing stops a bundle going inside its own child. The next Cents() call recurses // forever and ends in a stack overflow that kills the process, on the product page. desk.Add(office); Console.WriteLine($"accepted: {desk.Name} now holds {office.Name}, which holds {desk.Name}"); // accepted: Desk setup now holds Home office, which holds Desk setup }}package main
import "fmt"
func money(cents int64) string { return fmt.Sprintf("$%d.%02d", cents/100, cents%100) }
// Item is the Easy shape, carried over: an interface, a leaf and a composite that sums its children.type Item interface{ Cents() int64 }
type Product struct{ cents int64 }
func (p *Product) Cents() int64 { return p.cents }
type Bundle struct { Name string percentOff int64 items []Item}
// Cents gets two real rules wrong already: excluded items (gift cards) get discounted too,// and an outer bundle discounts what an inner bundle already discounted.func (b *Bundle) Cents() int64 { var sum int64 for _, item := range b.items { sum += item.Cents() } return sum * (100 - b.percentOff) / 100}
// Add has no checks and there is no parent pointer: any item can be added anywhere, any// number of times.func (b *Bundle) Add(item Item) *Bundle { b.items = append(b.items, item) return b}
func main() { keyboard := &Product{8000} desk := (&Bundle{Name: "Desk setup", percentOff: 5}).Add(&Product{30000}).Add(keyboard).Add(&Product{4900}) accessories := (&Bundle{Name: "Accessories", percentOff: 15}).Add(&Product{4000}).Add(&Product{2000}) office := (&Bundle{Name: "Home office", percentOff: 10}). Add(&Product{120000}).Add(desk).Add(accessories).Add(&Product{5000})
// The store's rules give $1591.00. Stacked discounts and a discounted gift card lose $53.31. fmt.Println("total:", money(office.Cents())) // total: $1537.69
// Moving the keyboard: with no parent pointer, the admin tool must search the catalog for // the keyboard's old bundle. This one forgot, so the keyboard is now in both bundles. accessories.Add(keyboard) fmt.Println("after the move:", money(office.Cents())) // after the move: $1598.89
// Nothing stops a bundle going inside its own child. The next Cents() call recurses // forever and ends in a fatal stack overflow, on the product page, not in the admin tool. desk.Add(office) fmt.Printf("accepted: %s now holds %s, which holds %s\n", desk.Name, office.Name, desk.Name) // accepted: Desk setup now holds Home office, which holds Desk setup}const money = (cents: number) => `$${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The Easy shape, carried over: an interface, a leaf and a composite that sums its children.interface Item { cents(): number;}
class Product implements Item { constructor(private readonly price: number) {} cents(): number { return this.price; }}
class Bundle implements Item { readonly items: Item[] = []; constructor(readonly name: string, private readonly percentOff: number) {}
// Two real rules are already wrong here: excluded items (gift cards) get discounted too, // and an outer bundle discounts what an inner bundle already discounted. cents(): number { const sum = this.items.reduce((s, item) => s + item.cents(), 0); return Math.floor((sum * (100 - this.percentOff)) / 100); }
// No checks and no parent pointer: any item can be added anywhere, any number of times. add(item: Item): this { this.items.push(item); return this; }}
const keyboard = new Product(8000);const desk = new Bundle("Desk setup", 5).add(new Product(30000)).add(keyboard).add(new Product(4900));const accessories = new Bundle("Accessories", 15).add(new Product(4000)).add(new Product(2000));const office = new Bundle("Home office", 10) .add(new Product(120000)).add(desk).add(accessories).add(new Product(5000));
// The store's rules give $1591.00. Stacked discounts and a discounted gift card lose $53.31.console.log(`total: ${money(office.cents())}`); // total: $1537.69
// Moving the keyboard: with no parent pointer, the admin tool must search the catalog for// the keyboard's old bundle. This one forgot, so the keyboard is now in both bundles.accessories.add(keyboard);console.log(`after the move: ${money(office.cents())}`); // after the move: $1598.89
// Nothing stops a bundle going inside its own child. The next cents() call recurses// forever and ends in a stack overflow, on the product page, not in the admin tool.desk.add(office);console.log(`accepted: ${desk.name} now holds ${office.name}, which holds ${desk.name}`);// accepted: Desk setup now holds Home office, which holds Desk setupThe shape is right, and three things are still wrong:
- One number can’t carry the rules.
cents()returns a single total, so the outer bundle can’t tell which part it may discount. It discounts the gift card and discounts the desk setup a second time. The bundle sells for $1537.69 instead of $1591.00. - A move leaves the item in two places. Nothing records where an item lives. So the admin tool has to search for the old bundle, and this one forgot. The keyboard is now priced twice.
- A cycle is accepted. A bundle went inside its own sub-bundle, and nothing noticed. The next price lookup recurses forever and crashes the product page, not the admin tool where the mistake was made.
With Composite, and the tree’s rules
Section titled “With Composite, and the tree’s rules”Four changes, each one small:
- Return a value, not a number.
quote()returns aQuotewith a discountable part and a fixed part. A product puts its price in one or the other. A bundle discounts the discountable part of its children and returns the result as fixed, so no outer bundle can discount it again. - Give every node a parent pointer, set only by
add. A node has exactly one parent, soaddon a node that already has one is a move: it detaches the node first. - Check for cycles before changing anything. Walk up from the target bundle through the parent pointers. If the item being added is the target or one of its ancestors, refuse.
- Write new operations as plain functions over
children(), which every node has.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Try the moves. Each one runs the cycle check first, and the right panel shows the walk up from the target bundle:
Price breakdown, per level
Cycle check: walk up from the target
Pick a move. The check runs before anything changes.
- Home office total
import java.util.ArrayList;import java.util.Collections;import java.util.List;
// A price in two parts, because some lines may never be discounted (gift cards, warranties// priced by an insurer). One number would lose which part an outer bundle may discount.record Quote(long discountable, long fixed) { long total() { return discountable + fixed; } Quote plus(Quote o) { return new Quote(discountable + o.discountable, fixed + o.fixed); }}
// The component. Reading is transparent: every item has children() (a product has none), so a// walk over the tree needs no type checks. Writing is safe: only Bundle has add().abstract class Item { final String name; // Set only by Bundle.add, so it can never disagree with the bundles' child lists. Bundle parent; Item(String name) { this.name = name; } abstract Quote quote(); abstract String label(); List<Item> children() { return List.of(); }
static String money(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }}
// A leaf. Each line in a bundle is its own Product node, so every node has exactly one parent.// The same SKU in two bundles is two nodes that share catalog data, not one shared node.class Product extends Item { private final long cents; private final boolean discountable; Product(String name, long cents, boolean discountable) { super(name); this.cents = cents; this.discountable = discountable; } Quote quote() { return discountable ? new Quote(cents, 0) : new Quote(0, cents); } String label() { return name + (discountable ? "" : ", no discount") + ": " + money(cents); }}
// The composite: a bundle holds products and other bundles, and has its own discount.class Bundle extends Item { private final int percentOff; private final List<Item> items = new ArrayList<>(); Bundle(String name, int percentOff) { super(name); this.percentOff = percentOff; }
// Read-only view: callers can walk the children but can't bypass add(). @Override List<Item> children() { return Collections.unmodifiableList(items); }
Quote subtotal() { Quote sum = new Quote(0, 0); for (Item item : items) sum = sum.plus(item.quote()); return sum; }
// Discounts don't stack: once this bundle discounts something, the result is fixed, so an // outer bundle can't discount it again. The innermost bundle's discount wins. Quote quote() { Quote s = subtotal(); return new Quote(0, s.discountable() * (100 - percentOff) / 100 + s.fixed()); }
String label() { return name + " (" + percentOff + "% off " + money(subtotal().discountable()) + "): " + money(quote().total()); }
// Adds a child, moving it if it already has a parent. Every check runs before anything // changes, so a refused add leaves the tree exactly as it was. Bundle add(Item item) { // Cycle check: walk up from here. If the item is this bundle or one of its ancestors, // it would end up inside itself and quote() would never return. With parent pointers // this costs O(depth); without them it means searching the item's whole subtree. for (Bundle b = this; b != null; b = b.parent) { if (b == item) { throw new IllegalArgumentException(item == this ? name + " can't contain itself" : item.name + " already contains " + name); } } // One parent at a time: detach from the old bundle, or the item would be priced twice. if (item.parent != null) item.parent.items.remove(item); item.parent = this; items.add(item); return this; }}
class Main { // A new operation over the tree is a plain function: it only needs label() and children(). static void breakdown(Item item, String indent) { System.out.println(indent + item.label()); for (Item child : item.children()) breakdown(child, indent + " "); }
// Parent pointers answer "where is this?" without searching from the root. static String path(Item item) { String path = item.name; for (Bundle b = item.parent; b != null; b = b.parent) path = b.name + " > " + path; return path; }
public static void main(String[] args) { Product keyboard = new Product("Keyboard", 8000, true); Bundle desk = new Bundle("Desk setup", 5) .add(new Product("Monitor", 30000, true)) .add(keyboard) .add(new Product("Extended warranty", 4900, false)); Bundle accessories = new Bundle("Accessories", 15) .add(new Product("Mouse", 4000, true)) .add(new Product("USB-C cable", 2000, true)); Bundle office = new Bundle("Home office", 10) .add(new Product("Laptop", 120000, true)) .add(desk) .add(accessories) .add(new Product("Gift card", 5000, false));
breakdown(office, ""); // Home office (10% off $1200.00): $1591.00 // Laptop: $1200.00 // Desk setup (5% off $380.00): $410.00 // Monitor: $300.00 // Keyboard: $80.00 // Extended warranty, no discount: $49.00 // Accessories (15% off $60.00): $51.00 // Mouse: $40.00 // USB-C cable: $20.00 // Gift card, no discount: $50.00
// Both refusals happen before anything changes. for (Runnable attempt : List.<Runnable>of(() -> office.add(office), () -> desk.add(office))) { try { attempt.run(); } catch (IllegalArgumentException e) { System.out.println("refused: " + e.getMessage()); } } // refused: Home office can't contain itself // refused: Home office already contains Desk setup
// A move is one add: the keyboard leaves Desk setup and gets 15% off instead of 5%. accessories.add(keyboard); System.out.println(path(keyboard)); // Home office > Accessories > Keyboard System.out.println("Desk setup: " + Item.money(desk.quote().total())); // Desk setup: $334.00 System.out.println("Home office: " + Item.money(office.quote().total())); // Home office: $1583.00 }}from __future__ import annotations
from abc import ABC, abstractmethodfrom dataclasses import dataclass
# A price in two parts, because some lines may never be discounted (gift cards, warranties# priced by an insurer). One number would lose which part an outer bundle may discount.@dataclass(frozen=True)class Quote: discountable: int fixed: int
def total(self) -> int: return self.discountable + self.fixed
def __add__(self, o: Quote) -> Quote: return Quote(self.discountable + o.discountable, self.fixed + o.fixed)
def money(cents: int) -> str: return f"${cents // 100}.{cents % 100:02d}"
# The component. Reading is transparent: every item has children() (a product has none), so a# walk over the tree needs no type checks. Writing is safe: only Bundle has add().class Item(ABC): def __init__(self, name: str) -> None: self.name = name # Set only by Bundle.add, so it can never disagree with the bundles' child lists. self.parent: Bundle | None = None
@abstractmethod def quote(self) -> Quote: ...
@abstractmethod def label(self) -> str: ...
def children(self) -> tuple[Item, ...]: return ()
# A leaf. Each line in a bundle is its own Product node, so every node has exactly one parent.# The same SKU in two bundles is two nodes that share catalog data, not one shared node.class Product(Item): def __init__(self, name: str, cents: int, discountable: bool) -> None: super().__init__(name) self._cents = cents self._discountable = discountable
def quote(self) -> Quote: return Quote(self._cents, 0) if self._discountable else Quote(0, self._cents)
def label(self) -> str: return f"{self.name}{'' if self._discountable else ', no discount'}: {money(self._cents)}"
# The composite: a bundle holds products and other bundles, and has its own discount.class Bundle(Item): def __init__(self, name: str, percent_off: int) -> None: super().__init__(name) self._percent_off = percent_off self._items: list[Item] = []
# Read-only view: callers can walk the children but can't bypass add(). def children(self) -> tuple[Item, ...]: return tuple(self._items)
def subtotal(self) -> Quote: return sum((item.quote() for item in self._items), Quote(0, 0))
# Discounts don't stack: once this bundle discounts something, the result is fixed, so an # outer bundle can't discount it again. The innermost bundle's discount wins. def quote(self) -> Quote: s = self.subtotal() return Quote(0, s.discountable * (100 - self._percent_off) // 100 + s.fixed)
def label(self) -> str: return (f"{self.name} ({self._percent_off}% off {money(self.subtotal().discountable)}): " f"{money(self.quote().total())}")
# Adds a child, moving it if it already has a parent. Every check runs before anything # changes, so a refused add leaves the tree exactly as it was. def add(self, item: Item) -> Bundle: # Cycle check: walk up from here. If the item is this bundle or one of its ancestors, # it would end up inside itself and quote() would never return. With parent pointers # this costs O(depth); without them it means searching the item's whole subtree. b: Bundle | None = self while b is not None: if b is item: raise ValueError(f"{self.name} can't contain itself" if item is self else f"{item.name} already contains {self.name}") b = b.parent # One parent at a time: detach from the old bundle, or the item would be priced twice. if item.parent is not None: item.parent._items.remove(item) item.parent = self self._items.append(item) return self
# A new operation over the tree is a plain function: it only needs label() and children().def breakdown(item: Item, indent: str = "") -> None: print(indent + item.label()) for child in item.children(): breakdown(child, indent + " ")
# Parent pointers answer "where is this?" without searching from the root.def path(item: Item) -> str: result, b = item.name, item.parent while b is not None: result, b = f"{b.name} > {result}", b.parent return result
def main() -> None: keyboard = Product("Keyboard", 8000, True) desk = (Bundle("Desk setup", 5) .add(Product("Monitor", 30000, True)) .add(keyboard) .add(Product("Extended warranty", 4900, False))) accessories = (Bundle("Accessories", 15) .add(Product("Mouse", 4000, True)) .add(Product("USB-C cable", 2000, True))) office = (Bundle("Home office", 10) .add(Product("Laptop", 120000, True)) .add(desk) .add(accessories) .add(Product("Gift card", 5000, False)))
breakdown(office) # Home office (10% off $1200.00): $1591.00 # Laptop: $1200.00 # Desk setup (5% off $380.00): $410.00 # Monitor: $300.00 # Keyboard: $80.00 # Extended warranty, no discount: $49.00 # Accessories (15% off $60.00): $51.00 # Mouse: $40.00 # USB-C cable: $20.00 # Gift card, no discount: $50.00
# Both refusals happen before anything changes. for attempt in (lambda: office.add(office), lambda: desk.add(office)): try: attempt() except ValueError as e: print(f"refused: {e}") # refused: Home office can't contain itself # refused: Home office already contains Desk setup
# A move is one add: the keyboard leaves Desk setup and gets 15% off instead of 5%. accessories.add(keyboard) print(path(keyboard)) # Home office > Accessories > Keyboard print(f"Desk setup: {money(desk.quote().total())}") # Desk setup: $334.00 print(f"Home office: {money(office.quote().total())}") # Home office: $1583.00
main()using System;using System.Collections.Generic;using System.Linq;
// A price in two parts, because some lines may never be discounted (gift cards, warranties// priced by an insurer). One number would lose which part an outer bundle may discount.public readonly record struct Quote(long Discountable, long Fixed){ public long Total => Discountable + Fixed; public static Quote operator +(Quote a, Quote b) => new(a.Discountable + b.Discountable, a.Fixed + b.Fixed);}
public static class Money{ public static string Of(long cents) => $"${cents / 100}.{cents % 100:D2}";}
// The component. Reading is transparent: every item has Children (a product has none), so a// walk over the tree needs no type checks. Writing is safe: only Bundle has Add.public abstract class Item{ protected Item(string name) { Name = name; } public string Name { get; } // Set only by Bundle.Add, so it can never disagree with the bundles' child lists. public Bundle? Parent { get; internal set; } public abstract Quote Quote(); public abstract string Label(); public virtual IReadOnlyList<Item> Children => Array.Empty<Item>();}
// A leaf. Each line in a bundle is its own Product node, so every node has exactly one parent.// The same SKU in two bundles is two nodes that share catalog data, not one shared node.public sealed class Product : Item{ private readonly long _cents; private readonly bool _discountable; public Product(string name, long cents, bool discountable) : base(name) { _cents = cents; _discountable = discountable; } public override Quote Quote() => _discountable ? new Quote(_cents, 0) : new Quote(0, _cents); public override string Label() => $"{Name}{(_discountable ? "" : ", no discount")}: {Money.Of(_cents)}";}
// The composite: a bundle holds products and other bundles, and has its own discount.public sealed class Bundle : Item{ private readonly int _percentOff; private readonly List<Item> _items = new(); public Bundle(string name, int percentOff) : base(name) { _percentOff = percentOff; }
// Read-only view: callers can walk the children but can't bypass Add. public override IReadOnlyList<Item> Children => _items.AsReadOnly();
public Quote Subtotal() => _items.Aggregate(new Quote(0, 0), (sum, item) => sum + item.Quote());
// Discounts don't stack: once this bundle discounts something, the result is fixed, so an // outer bundle can't discount it again. The innermost bundle's discount wins. public override Quote Quote() { var s = Subtotal(); return new Quote(0, s.Discountable * (100 - _percentOff) / 100 + s.Fixed); }
public override string Label() => $"{Name} ({_percentOff}% off {Money.Of(Subtotal().Discountable)}): {Money.Of(Quote().Total)}";
// Adds a child, moving it if it already has a parent. Every check runs before anything // changes, so a refused add leaves the tree exactly as it was. public Bundle Add(Item item) { // Cycle check: walk up from here. If the item is this bundle or one of its ancestors, // it would end up inside itself and Quote() would never return. With parent pointers // this costs O(depth); without them it means searching the item's whole subtree. for (Bundle? b = this; b != null; b = b.Parent) { if (ReferenceEquals(b, item)) { throw new ArgumentException(ReferenceEquals(item, this) ? $"{Name} can't contain itself" : $"{item.Name} already contains {Name}"); } } // One parent at a time: detach from the old bundle, or the item would be priced twice. item.Parent?._items.Remove(item); item.Parent = this; _items.Add(item); return this; }}
public static class Program{ // A new operation over the tree is a plain function: it only needs Label() and Children. static void Breakdown(Item item, string indent = "") { Console.WriteLine(indent + item.Label()); foreach (var child in item.Children) Breakdown(child, indent + " "); }
// Parent pointers answer "where is this?" without searching from the root. static string PathOf(Item item) { var path = item.Name; for (var b = item.Parent; b != null; b = b.Parent) path = $"{b.Name} > {path}"; return path; }
public static void Main() { var keyboard = new Product("Keyboard", 8000, true); var desk = new Bundle("Desk setup", 5) .Add(new Product("Monitor", 30000, true)) .Add(keyboard) .Add(new Product("Extended warranty", 4900, false)); var accessories = new Bundle("Accessories", 15) .Add(new Product("Mouse", 4000, true)) .Add(new Product("USB-C cable", 2000, true)); var office = new Bundle("Home office", 10) .Add(new Product("Laptop", 120000, true)) .Add(desk) .Add(accessories) .Add(new Product("Gift card", 5000, false));
Breakdown(office); // Home office (10% off $1200.00): $1591.00 // Laptop: $1200.00 // Desk setup (5% off $380.00): $410.00 // Monitor: $300.00 // Keyboard: $80.00 // Extended warranty, no discount: $49.00 // Accessories (15% off $60.00): $51.00 // Mouse: $40.00 // USB-C cable: $20.00 // Gift card, no discount: $50.00
// Both refusals happen before anything changes. foreach (var attempt in new Action[] { () => office.Add(office), () => desk.Add(office) }) { try { attempt(); } catch (ArgumentException e) { Console.WriteLine($"refused: {e.Message}"); } } // refused: Home office can't contain itself // refused: Home office already contains Desk setup
// A move is one Add: the keyboard leaves Desk setup and gets 15% off instead of 5%. accessories.Add(keyboard); Console.WriteLine(PathOf(keyboard)); // Home office > Accessories > Keyboard Console.WriteLine($"Desk setup: {Money.Of(desk.Quote().Total)}"); // Desk setup: $334.00 Console.WriteLine($"Home office: {Money.Of(office.Quote().Total)}"); // Home office: $1583.00 }}package main
import ( "fmt" "slices")
// Quote is a price in two parts, because some lines may never be discounted (gift cards,// warranties priced by an insurer). One number would lose which part an outer bundle may discount.type Quote struct{ Discountable, Fixed int64 }
func (q Quote) Total() int64 { return q.Discountable + q.Fixed }func (q Quote) Plus(o Quote) Quote { return Quote{q.Discountable + o.Discountable, q.Fixed + o.Fixed} }
func money(cents int64) string { return fmt.Sprintf("$%d.%02d", cents/100, cents%100) }
// Item is the component. Reading is transparent: every item has Children() (a product has// none), so a walk over the tree needs no type switch. Writing is safe: only Bundle has Add.type Item interface { Name() string Parent() *Bundle Quote() Quote Label() string Children() []Item setParent(*Bundle) // unexported: only this package (Bundle.Add) can set it}
// node holds what every item has. The parent is set only by Bundle.Add, so it can never// disagree with the bundles' child lists.type node struct { name string parent *Bundle}
func (n *node) Name() string { return n.name }func (n *node) Parent() *Bundle { return n.parent }func (n *node) Children() []Item { return nil }func (n *node) setParent(b *Bundle) { n.parent = b }
// Product is a leaf. Each line in a bundle is its own Product, so every node has exactly one// parent. The same SKU in two bundles is two nodes that share catalog data, not one shared node.type Product struct { node cents int64 discountable bool}
func NewProduct(name string, cents int64, discountable bool) *Product { return &Product{node{name: name}, cents, discountable}}
func (p *Product) Quote() Quote { if p.discountable { return Quote{p.cents, 0} } return Quote{0, p.cents}}
func (p *Product) Label() string { note := "" if !p.discountable { note = ", no discount" } return fmt.Sprintf("%s%s: %s", p.name, note, money(p.cents))}
// Bundle is the composite: it holds products and other bundles, and has its own discount.type Bundle struct { node percentOff int64 items []Item}
// NewBundle builds a bundle from new items. It panics on error, which only a programming// mistake can cause here; run-time changes go through Add and get an error back.func NewBundle(name string, percentOff int64, items ...Item) *Bundle { b := &Bundle{node: node{name: name}, percentOff: percentOff} for _, item := range items { if err := b.Add(item); err != nil { panic(err) } } return b}
// Children returns a copy: callers can walk the children but can't bypass Add.func (b *Bundle) Children() []Item { return slices.Clone(b.items) }
func (b *Bundle) Subtotal() Quote { var sum Quote for _, item := range b.items { sum = sum.Plus(item.Quote()) } return sum}
// Quote: discounts don't stack. Once this bundle discounts something, the result is fixed, so// an outer bundle can't discount it again. The innermost bundle's discount wins.func (b *Bundle) Quote() Quote { s := b.Subtotal() return Quote{0, s.Discountable*(100-b.percentOff)/100 + s.Fixed}}
func (b *Bundle) Label() string { return fmt.Sprintf("%s (%d%% off %s): %s", b.name, b.percentOff, money(b.Subtotal().Discountable), money(b.Quote().Total()))}
// Add adds a child, moving it if it already has a parent. Every check runs before anything// changes, so a refused add leaves the tree exactly as it was.func (b *Bundle) Add(item Item) error { // Cycle check: walk up from here. If the item is this bundle or one of its ancestors, it // would end up inside itself and Quote() would never return. With parent pointers this // costs O(depth); without them it means searching the item's whole subtree. for a := b; a != nil; a = a.parent { if Item(a) == item { if a == b { return fmt.Errorf("%s can't contain itself", b.name) } return fmt.Errorf("%s already contains %s", item.Name(), b.name) } } // One parent at a time: detach from the old bundle, or the item would be priced twice. if old := item.Parent(); old != nil { i := slices.Index(old.items, item) old.items = slices.Delete(old.items, i, i+1) } item.setParent(b) b.items = append(b.items, item) return nil}
// breakdown is a new operation as a plain function: it only needs Label() and Children().func breakdown(item Item, indent string) { fmt.Println(indent + item.Label()) for _, child := range item.Children() { breakdown(child, indent+" ") }}
// path uses parent pointers to answer "where is this?" without searching from the root.func path(item Item) string { result := item.Name() for b := item.Parent(); b != nil; b = b.parent { result = b.name + " > " + result } return result}
func main() { keyboard := NewProduct("Keyboard", 8000, true) desk := NewBundle("Desk setup", 5, NewProduct("Monitor", 30000, true), keyboard, NewProduct("Extended warranty", 4900, false)) accessories := NewBundle("Accessories", 15, NewProduct("Mouse", 4000, true), NewProduct("USB-C cable", 2000, true)) office := NewBundle("Home office", 10, NewProduct("Laptop", 120000, true), desk, accessories, NewProduct("Gift card", 5000, false))
breakdown(office, "") // Home office (10% off $1200.00): $1591.00 // Laptop: $1200.00 // Desk setup (5% off $380.00): $410.00 // Monitor: $300.00 // Keyboard: $80.00 // Extended warranty, no discount: $49.00 // Accessories (15% off $60.00): $51.00 // Mouse: $40.00 // USB-C cable: $20.00 // Gift card, no discount: $50.00
// Both refusals happen before anything changes. for _, err := range []error{office.Add(office), desk.Add(office)} { if err != nil { fmt.Println("refused:", err) } } // refused: Home office can't contain itself // refused: Home office already contains Desk setup
// A move is one Add: the keyboard leaves Desk setup and gets 15% off instead of 5%. if err := accessories.Add(keyboard); err != nil { fmt.Println("move failed:", err) } fmt.Println(path(keyboard)) // Home office > Accessories > Keyboard fmt.Println("Desk setup:", money(desk.Quote().Total())) // Desk setup: $334.00 fmt.Println("Home office:", money(office.Quote().Total())) // Home office: $1583.00}// A price in two parts, because some lines may never be discounted (gift cards, warranties// priced by an insurer). One number would lose which part an outer bundle may discount.class Quote { constructor(readonly discountable: number, readonly fixed: number) {} total(): number { return this.discountable + this.fixed; } plus(o: Quote): Quote { return new Quote(this.discountable + o.discountable, this.fixed + o.fixed); }}
const money = (cents: number) => `$${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The component. Reading is transparent: every item has children() (a product has none), so a// walk over the tree needs no type checks. Writing is safe: only Bundle has add().abstract class Item { // Set only by Bundle.add, so it can never disagree with the bundles' child lists. parent: Bundle | null = null; constructor(readonly name: string) {} abstract quote(): Quote; abstract label(): string; children(): readonly Item[] { return []; }}
// A leaf. Each line in a bundle is its own Product node, so every node has exactly one parent.// The same SKU in two bundles is two nodes that share catalog data, not one shared node.class Product extends Item { constructor(name: string, private readonly cents: number, private readonly discountable: boolean) { super(name); } quote(): Quote { return this.discountable ? new Quote(this.cents, 0) : new Quote(0, this.cents); } label(): string { return `${this.name}${this.discountable ? "" : ", no discount"}: ${money(this.cents)}`; }}
// The composite: a bundle holds products and other bundles, and has its own discount.class Bundle extends Item { private readonly items: Item[] = []; constructor(name: string, private readonly percentOff: number) { super(name); }
// Read-only view: callers can walk the children but can't bypass add(). override children(): readonly Item[] { return this.items; }
subtotal(): Quote { return this.items.reduce((sum, item) => sum.plus(item.quote()), new Quote(0, 0)); }
// Discounts don't stack: once this bundle discounts something, the result is fixed, so an // outer bundle can't discount it again. The innermost bundle's discount wins. quote(): Quote { const s = this.subtotal(); return new Quote(0, Math.floor((s.discountable * (100 - this.percentOff)) / 100) + s.fixed); }
label(): string { return `${this.name} (${this.percentOff}% off ${money(this.subtotal().discountable)}): ${money(this.quote().total())}`; }
// Adds a child, moving it if it already has a parent. Every check runs before anything // changes, so a refused add leaves the tree exactly as it was. add(item: Item): this { // Cycle check: walk up from here. If the item is this bundle or one of its ancestors, // it would end up inside itself and quote() would never return. With parent pointers // this costs O(depth); without them it means searching the item's whole subtree. for (let b: Bundle | null = this; b !== null; b = b.parent) { if (b === item) { throw new Error(item === this ? `${this.name} can't contain itself` : `${item.name} already contains ${this.name}`); } } // One parent at a time: detach from the old bundle, or the item would be priced twice. if (item.parent !== null) { const siblings = item.parent.items; siblings.splice(siblings.indexOf(item), 1); } item.parent = this; this.items.push(item); return this; }}
// A new operation over the tree is a plain function: it only needs label() and children().function breakdown(item: Item, indent = ""): void { console.log(indent + item.label()); for (const child of item.children()) breakdown(child, indent + " ");}
// Parent pointers answer "where is this?" without searching from the root.function path(item: Item): string { let result = item.name; for (let b = item.parent; b !== null; b = b.parent) result = `${b.name} > ${result}`; return result;}
const keyboard = new Product("Keyboard", 8000, true);const desk = new Bundle("Desk setup", 5) .add(new Product("Monitor", 30000, true)) .add(keyboard) .add(new Product("Extended warranty", 4900, false));const accessories = new Bundle("Accessories", 15) .add(new Product("Mouse", 4000, true)) .add(new Product("USB-C cable", 2000, true));const office = new Bundle("Home office", 10) .add(new Product("Laptop", 120000, true)) .add(desk) .add(accessories) .add(new Product("Gift card", 5000, false));
breakdown(office);// Home office (10% off $1200.00): $1591.00// Laptop: $1200.00// Desk setup (5% off $380.00): $410.00// Monitor: $300.00// Keyboard: $80.00// Extended warranty, no discount: $49.00// Accessories (15% off $60.00): $51.00// Mouse: $40.00// USB-C cable: $20.00// Gift card, no discount: $50.00
// Both refusals happen before anything changes.for (const attempt of [() => office.add(office), () => desk.add(office)]) { try { attempt(); } catch (e) { console.log(`refused: ${(e as Error).message}`); }}// refused: Home office can't contain itself// refused: Home office already contains Desk setup
// A move is one add: the keyboard leaves Desk setup and gets 15% off instead of 5%.accessories.add(keyboard);console.log(path(keyboard)); // Home office > Accessories > Keyboardconsole.log(`Desk setup: ${money(desk.quote().total())}`); // Desk setup: $334.00console.log(`Home office: ${money(office.quote().total())}`); // Home office: $1583.00Transparency or safety
Section titled “Transparency or safety”Where add lives is a real design choice, and the two options fail in different ways:
add on the component (transparent) |
add only on the composite (safe) |
|
|---|---|---|
| Every node looks the same | Yes: code can call add on any item |
No: code must know it holds a bundle |
| A leaf asked to add | Throws at run time | Doesn’t compile |
| Code that builds or edits trees | No casts | Needs the bundle type, or a check |
| Liskov substitution | Broken: a product can’t do what an item promises | Kept |
This page splits the difference. Reading is transparent: every item has children(), and a
product returns an empty list, so a function can walk any tree without checking types.
Writing is safe: only Bundle has add, so nobody can add to a product. Code that edits
trees, like the admin tool, already knows it holds a bundle, because that’s what it lets
people drop items into.
What to notice
Section titled “What to notice”- The value carries the rules.
Quotekeeps “may be discounted” and “may not” apart all the way up the tree. A composite often needs a richer result than its leaves return: a range, a breakdown, a set of permissions. - The check comes before the change.
addwalks up, decides, and only then detaches and attaches. A refused move leaves the tree exactly as it was, so the admin tool can show an error and nothing needs undoing. - Parent pointers make the check cheap. Walking up costs one step per level. Without them, checking “is the target inside this item?” means searching the item’s whole subtree.
- One parent per node. The same SKU in two bundles is two
Productnodes, which share catalog data. Sharing one node between two parents would make it a graph, not a tree. Its parent pointer couldn’t be right for both. And a total over the tree would count it twice. - New operations are plain functions.
breakdownandpatharen’t methods onItem. They only needlabel(),children()andparent, so adding a CSV export or a “find excluded items” report doesn’t touch the classes. When an operation needs different code per node type for many node types, that’s the job of the Visitor pattern, described under “Commonly confused with”.
A merchandiser dragged the “Home office” bundle into its own “Desk setup” bundle. The admin tool accepted it, and every product page that shows the bundle crashed. How do you fix it?
Whether you refuse the cycle when the bundle is edited. You walk up parent pointers from the
target, before changing anything. And whether you also handle moves, so an item has one parent.
The red flag is catching the stack overflow at pricing time, or adding a depth counter to
quote(). That hides a broken tree, instead of preventing one.
A document layout tree
- Cached totals with dirty flags
- Invalidation that stops early
- Deep trees without recursion
- Immutable trees with shared parts
- Readers on other threads
A web document editor keeps its page as a layout tree: the document holds sections, sections hold paragraphs, paragraphs hold lines. Every block has a height, and a container’s height is its padding plus its children’s heights. The editor needs the document’s height for the scrollbar and each block’s position for drawing, after every keystroke. At 60 frames a second there are about 16 ms per frame for everything, and a long document has tens of thousands of blocks.
Two more facts make it harder. A background job (the spell checker, a PDF export, the code that syncs with other editors) reads the tree while the user types. And documents come from users: pasted HTML can nest blocks thousands of levels deep.
Why recomputing is too slow
Section titled “Why recomputing is too slow”The Easy level’s size() walks every block below it on every call. Typing one character into
a line changes one height, then recomputes all of them. Worse, the Easy level’s print asks
every folder for its size, so each leaf is summed once for every ancestor. Both are fine for a
sidebar of a few hundred files and far too slow for a layout that has to finish inside a frame.
Dirty flags: cache, then invalidate upwards
Section titled “Dirty flags: cache, then invalidate upwards”Keep each container’s height in a field, and a dirty flag that means “this cache may be
wrong”. The tree keeps one invariant: if a block is dirty, every ancestor is dirty too.
- An edit marks upwards and stops early. When a line’s height changes, its paragraph is marked dirty, then the section, then the document. The walk stops at the first block that is already dirty, because by the invariant everything above it is dirty too. Ten keystrokes in one paragraph before the next frame cost one walk to the root and nine walks of one step.
- A read recomputes only dirty blocks. Layout starts at the root. It goes down only into dirty containers, and reads the cached height of every clean child. One edit costs the path from the line to the root, plus one cached read per child of each block on that path.
This is how UI toolkits do it. Android’s View.requestLayout() walks up through the parents the same
way. It stops early when a parent has already asked for layout. Browsers mark boxes as needing
layout, and lay out only those on the next frame.
Wrap a line, then lay out, in each of the three modes. In “Dirty flags”, edit two lines in the same paragraph before laying out and watch the second edit mark nothing:
- Blocks touched by the last layout
- 0
- Recomputed
- 0
- Read as-is
- 0
- Marked dirty by the last edit
- 0
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayDeque;import java.util.ArrayList;import java.util.Deque;import java.util.List;import java.util.concurrent.atomic.AtomicReference;
// Counts the work one layout pass does, so full and incremental layout can be compared.final class Work { int recomputed; // containers whose height was summed again int read; // children whose height was used as-is: a line, or a clean container's cache public String toString() { return "(" + recomputed + " recomputed, " + read + " read)"; }}
// The component: any block in a document's layout tree. Its height in pixels is cached, and// `dirty` means the cache may be stale. Invariant: if a block is dirty, so is every ancestor.abstract class Block { Container parent; int height; boolean dirty;}
// A leaf. Its height is set from outside (a line wraps when text is typed), never computed.final class Line extends Block { Line(int px) { height = px; }
// Returns how many blocks this edit newly marked dirty. int setHeight(int px) { height = px; return parent == null ? 0 : parent.markDirty(); }}
// The composite: its height is its padding plus its children's, recomputed only when dirty.// Not thread-safe: reading height() can write the cache. Keep the tree on one thread, as UI// toolkits do, or share the immutable version below.final class Container extends Block { final int padding; final List<Block> children = new ArrayList<>(); Container(int padding) { this.padding = padding; dirty = true; } // nothing computed yet
Container add(Block child) { child.parent = this; children.add(child); markDirty(); return this; }
// Walk up, marking each block dirty, and stop at the first that already is: by the // invariant, everything above it is dirty too. Ten edits in one paragraph cost one walk. int markDirty() { int marked = 0; for (Container c = this; c != null && !c.dirty; c = c.parent) { c.dirty = true; marked++; } return marked; }
// Reading the height recomputes only what changed since the last read. int height(Work w) { if (dirty) layout(this, false, w); return height; }
// One layout pass. full = recompute every container (what a tree with no cache does); // otherwise only dirty containers, reading clean children's cached heights. // Iterative, with an explicit stack: recursion would overflow the thread's stack on a deep // enough tree, and the depth of a document is decided by whoever wrote it. static void layout(Container root, boolean full, Work w) { Deque<Container> stack = new ArrayDeque<>(); Deque<Container> order = new ArrayDeque<>(); // parents end up after their children stack.push(root); while (!stack.isEmpty()) { Container c = stack.pop(); order.push(c); for (Block child : c.children) { if (child instanceof Container k && (full || k.dirty)) stack.push(k); else w.read++; } } for (Container c : order) { int sum = c.padding; for (Block child : c.children) sum += child.height; c.height = sum; c.dirty = false; w.recomputed++; } }}
// The immutable alternative. A block never changes once built, so its height is computed once,// in the constructor, from children that are already final. No dirty flags, no parent// pointers (a shared block has many parents), and any number of threads can read it.record Frozen(int padding, List<Frozen> kids, int height) { static Frozen line(int px) { return new Frozen(0, List.of(), px); }
static Frozen box(int padding, List<Frozen> kids) { int h = padding; for (Frozen k : kids) h += k.height; return new Frozen(padding, List.copyOf(kids), h); }
// An edit copies the path from the root to the changed line and shares everything else: // depth + 1 new blocks. The old root is untouched, so whoever holds it keeps a whole view. Frozen withLine(int[] path, int px) { Frozen[] chain = new Frozen[path.length]; Frozen at = this; for (int i = 0; i < path.length; i++) { chain[i] = at; at = at.kids.get(path[i]); } Frozen fresh = line(px); for (int i = path.length - 1; i >= 0; i--) { List<Frozen> kids = new ArrayList<>(chain[i].kids); kids.set(path[i], fresh); fresh = box(chain[i].padding, kids); } return fresh; }}
class Main { // 8 sections of 10 paragraphs of 6 lines. Paddings: document 40, section 24, paragraph 8. static final int SECTIONS = 8, PARAGRAPHS = 10, LINES = 6;
static Frozen frozenDoc() { List<Frozen> sections = new ArrayList<>(); for (int s = 0; s < SECTIONS; s++) { List<Frozen> paragraphs = new ArrayList<>(); for (int p = 0; p < PARAGRAPHS; p++) { List<Frozen> lines = new ArrayList<>(); for (int l = 0; l < LINES; l++) lines.add(Frozen.line(20)); paragraphs.add(Frozen.box(8, lines)); } sections.add(Frozen.box(24, paragraphs)); } return Frozen.box(40, sections); }
public static void main(String[] args) { Container doc = new Container(40); Line[][][] lines = new Line[SECTIONS][PARAGRAPHS][LINES]; int blocks = 1; for (int s = 0; s < SECTIONS; s++) { Container section = new Container(24); for (int p = 0; p < PARAGRAPHS; p++) { Container paragraph = new Container(8); for (int l = 0; l < LINES; l++) paragraph.add(lines[s][p][l] = new Line(20)); section.add(paragraph); blocks += 1 + LINES; } doc.add(section); blocks++; } System.out.println(blocks + " blocks"); // 569 blocks
Work w = new Work(); Container.layout(doc, true, w); System.out.println("full layout: " + doc.height + " px " + w); // full layout: 10472 px (89 recomputed, 480 read)
// Two edits in one paragraph. The second finds its ancestors already dirty and stops. System.out.println("line 3.5.2 wraps to 40 px: " + lines[2][4][1].setHeight(40) + " blocks marked dirty"); // line 3.5.2 wraps to 40 px: 3 blocks marked dirty System.out.println("line 3.5.3 wraps to 40 px: " + lines[2][4][2].setHeight(40) + " blocks marked dirty"); // line 3.5.3 wraps to 40 px: 0 blocks marked dirty
// Only the path to the root is recomputed; its siblings are read from their caches. w = new Work(); System.out.println("incremental layout: " + doc.height(w) + " px " + w); // incremental layout: 10512 px (3 recomputed, 22 read) w = new Work(); System.out.println("nothing changed: " + doc.height(w) + " px " + w); // nothing changed: 10512 px (0 recomputed, 0 read) w = new Work(); Container.layout(doc, true, w); System.out.println("full layout again: " + doc.height + " px " + w); // full layout again: 10512 px (89 recomputed, 480 read)
// Immutable version: readers take a snapshot; a writer builds a new root and publishes it // atomically. With several writers, use updateAndGet so no edit is lost. Frozen v1 = frozenDoc(); AtomicReference<Frozen> current = new AtomicReference<>(v1); Frozen reader = current.get(); Frozen v2 = v1.withLine(new int[] {2, 4, 1}, 40); current.set(v2); System.out.println("section 1 shared: " + (v2.kids().get(0) == v1.kids().get(0)) + ", section 3 shared: " + (v2.kids().get(2) == v1.kids().get(2))); // section 1 shared: true, section 3 shared: false System.out.println("reader still sees " + reader.height() + " px, new readers see " + current.get().height() + " px"); // reader still sees 10472 px, new readers see 10492 px
// A pathological document: 100000 containers nested in a chain. The iterative layout // handles it; a recursive one would overflow the stack long before the bottom. Container deep = new Container(1); Container at = deep; for (int i = 1; i < 100_000; i++) { Container next = new Container(1); at.add(next); at = next; } at.add(new Line(20)); System.out.println("100000 levels deep: " + deep.height(new Work()) + " px"); // 100000 levels deep: 100020 px }}from __future__ import annotations
import threadingfrom dataclasses import dataclass
# Counts the work one layout pass does, so full and incremental layout can be compared.class Work: def __init__(self) -> None: self.recomputed = 0 # containers whose height was summed again self.read = 0 # children whose height was used as-is: a line, or a clean container's cache
def __str__(self) -> str: return f"({self.recomputed} recomputed, {self.read} read)"
# The component: any block in a document's layout tree. Its height in pixels is cached, and# `dirty` means the cache may be stale. Invariant: if a block is dirty, so is every ancestor.class Block: def __init__(self) -> None: self.parent: Container | None = None self.height = 0 self.dirty = False
# A leaf. Its height is set from outside (a line wraps when text is typed), never computed.class Line(Block): def __init__(self, px: int) -> None: super().__init__() self.height = px
# Returns how many blocks this edit newly marked dirty. def set_height(self, px: int) -> int: self.height = px return 0 if self.parent is None else self.parent.mark_dirty()
# The composite: its height is its padding plus its children's, recomputed only when dirty.# Not thread-safe: reading height() can write the cache. Keep the tree on one thread, as UI# toolkits do, or share the immutable version below.class Container(Block): def __init__(self, padding: int) -> None: super().__init__() self.padding = padding self.children: list[Block] = [] self.dirty = True # nothing computed yet
def add(self, child: Block) -> Container: child.parent = self self.children.append(child) self.mark_dirty() return self
# Walk up, marking each block dirty, and stop at the first that already is: by the # invariant, everything above it is dirty too. Ten edits in one paragraph cost one walk. def mark_dirty(self) -> int: marked = 0 c: Container | None = self while c is not None and not c.dirty: c.dirty = True marked += 1 c = c.parent return marked
# Reading the height recomputes only what changed since the last read. def get_height(self, w: Work) -> int: if self.dirty: layout(self, False, w) return self.height
# One layout pass. full = recompute every container (what a tree with no cache does);# otherwise only dirty containers, reading clean children's cached heights.# Iterative, with an explicit stack: recursion would overflow the stack on a deep enough tree# (CPython stops at 1000 frames by default), and the depth of a document is decided by# whoever wrote it.def layout(root: Container, full: bool, w: Work) -> None: stack = [root] order: list[Container] = [] # reversed at the end, so parents come after their children while stack: c = stack.pop() order.append(c) for child in c.children: if isinstance(child, Container) and (full or child.dirty): stack.append(child) else: w.read += 1 for c in reversed(order): c.height = c.padding + sum(child.height for child in c.children) c.dirty = False w.recomputed += 1
# The immutable alternative. A block never changes once built, so its height is computed once,# in the constructor, from children that are already final. No dirty flags, no parent# pointers (a shared block has many parents), and any number of threads can read it.@dataclass(frozen=True)class Frozen: padding: int kids: tuple[Frozen, ...] height: int
@staticmethod def line(px: int) -> Frozen: return Frozen(0, (), px)
@staticmethod def box(padding: int, kids: list[Frozen]) -> Frozen: return Frozen(padding, tuple(kids), padding + sum(k.height for k in kids))
# An edit copies the path from the root to the changed line and shares everything else: # depth + 1 new blocks. The old root is untouched, so whoever holds it keeps a whole view. def with_line(self, path: list[int], px: int) -> Frozen: chain: list[Frozen] = [] at = self for i in path: chain.append(at) at = at.kids[i] fresh = Frozen.line(px) for node, i in zip(reversed(chain), reversed(path)): kids = list(node.kids) kids[i] = fresh fresh = Frozen.box(node.padding, kids) return fresh
# 8 sections of 10 paragraphs of 6 lines. Paddings: document 40, section 24, paragraph 8.SECTIONS, PARAGRAPHS, LINES = 8, 10, 6
def frozen_doc() -> Frozen: return Frozen.box(40, [ Frozen.box(24, [Frozen.box(8, [Frozen.line(20) for _ in range(LINES)]) for _ in range(PARAGRAPHS)]) for _ in range(SECTIONS) ])
def main() -> None: doc = Container(40) lines: list[list[list[Line]]] = [] blocks = 1 for _ in range(SECTIONS): section = Container(24) lines.append([]) for _ in range(PARAGRAPHS): paragraph = Container(8) row = [Line(20) for _ in range(LINES)] for line in row: paragraph.add(line) lines[-1].append(row) section.add(paragraph) blocks += 1 + LINES doc.add(section) blocks += 1 print(f"{blocks} blocks") # 569 blocks
w = Work() layout(doc, True, w) print(f"full layout: {doc.height} px {w}") # full layout: 10472 px (89 recomputed, 480 read)
# Two edits in one paragraph. The second finds its ancestors already dirty and stops. print(f"line 3.5.2 wraps to 40 px: {lines[2][4][1].set_height(40)} blocks marked dirty") # line 3.5.2 wraps to 40 px: 3 blocks marked dirty print(f"line 3.5.3 wraps to 40 px: {lines[2][4][2].set_height(40)} blocks marked dirty") # line 3.5.3 wraps to 40 px: 0 blocks marked dirty
# Only the path to the root is recomputed; its siblings are read from their caches. w = Work() print(f"incremental layout: {doc.get_height(w)} px {w}") # incremental layout: 10512 px (3 recomputed, 22 read) w = Work() print(f"nothing changed: {doc.get_height(w)} px {w}") # nothing changed: 10512 px (0 recomputed, 0 read) w = Work() layout(doc, True, w) print(f"full layout again: {doc.height} px {w}") # full layout again: 10512 px (89 recomputed, 480 read)
# Immutable version: readers take a snapshot; a writer builds a new root and publishes it. # Writers take a lock so no edit is lost; readers just read the current reference. v1 = frozen_doc() current = v1 write_lock = threading.Lock() reader = current with write_lock: v2 = current.with_line([2, 4, 1], 40) current = v2 print(f"section 1 shared: {str(v2.kids[0] is v1.kids[0]).lower()}, " f"section 3 shared: {str(v2.kids[2] is v1.kids[2]).lower()}") # section 1 shared: true, section 3 shared: false print(f"reader still sees {reader.height} px, new readers see {current.height} px") # reader still sees 10472 px, new readers see 10492 px
# A pathological document: 100000 containers nested in a chain. The iterative layout # handles it; a recursive one would overflow the stack long before the bottom. deep = Container(1) at = deep for _ in range(1, 100_000): nxt = Container(1) at.add(nxt) at = nxt at.add(Line(20)) print(f"100000 levels deep: {deep.get_height(Work())} px") # 100000 levels deep: 100020 px
main()using System;using System.Collections.Generic;using System.Collections.Immutable;using System.Linq;using System.Threading;
// Counts the work one layout pass does, so full and incremental layout can be compared.public sealed class Work{ public int Recomputed; // containers whose height was summed again public int Read; // children whose height was used as-is: a line, or a clean container's cache public override string ToString() => $"({Recomputed} recomputed, {Read} read)";}
// The component: any block in a document's layout tree. Its height in pixels is cached, and// Dirty means the cache may be stale. Invariant: if a block is dirty, so is every ancestor.public abstract class Block{ public Container? Parent { get; internal set; } public int Height { get; internal set; } public bool Dirty { get; internal set; }}
// A leaf. Its height is set from outside (a line wraps when text is typed), never computed.public sealed class Line : Block{ public Line(int px) { Height = px; }
// Returns how many blocks this edit newly marked dirty. public int SetHeight(int px) { Height = px; return Parent?.MarkDirty() ?? 0; }}
// The composite: its height is its padding plus its children's, recomputed only when dirty.// Not thread-safe: reading the height can write the cache. Keep the tree on one thread, as UI// toolkits do, or share the immutable version below.public sealed class Container : Block{ private readonly List<Block> _children = new(); public Container(int padding) { Padding = padding; Dirty = true; } // nothing computed yet public int Padding { get; }
public Container Add(Block child) { child.Parent = this; _children.Add(child); MarkDirty(); return this; }
// Walk up, marking each block dirty, and stop at the first that already is: by the // invariant, everything above it is dirty too. Ten edits in one paragraph cost one walk. public int MarkDirty() { int marked = 0; for (Container? c = this; c != null && !c.Dirty; c = c.Parent) { c.Dirty = true; marked++; } return marked; }
// Reading the height recomputes only what changed since the last read. public int HeightWith(Work w) { if (Dirty) Layout(this, false, w); return Height; }
// One layout pass. full = recompute every container (what a tree with no cache does); // otherwise only dirty containers, reading clean children's cached heights. // Iterative, with an explicit stack: recursion would overflow the thread's stack on a deep // enough tree, and .NET can't catch a stack overflow: the process dies. public static void Layout(Container root, bool full, Work w) { var stack = new Stack<Container>(); var order = new Stack<Container>(); // parents end up after their children stack.Push(root); while (stack.Count > 0) { var c = stack.Pop(); order.Push(c); foreach (var child in c._children) { if (child is Container k && (full || k.Dirty)) stack.Push(k); else w.Read++; } } foreach (var c in order) { c.Height = c.Padding + c._children.Sum(child => child.Height); c.Dirty = false; w.Recomputed++; } }}
// The immutable alternative. A block never changes once built, so its height is computed once,// in the constructor, from children that are already final. No dirty flags, no parent// pointers (a shared block has many parents), and any number of threads can read it.public sealed class Frozen{ private Frozen(int padding, ImmutableArray<Frozen> kids, int height) { Padding = padding; Kids = kids; Height = height; } public int Padding { get; } public ImmutableArray<Frozen> Kids { get; } public int Height { get; }
public static Frozen Line(int px) => new(0, ImmutableArray<Frozen>.Empty, px); public static Frozen Box(int padding, IEnumerable<Frozen> kids) { var all = kids.ToImmutableArray(); return new(padding, all, padding + all.Sum(k => k.Height)); }
// An edit copies the path from the root to the changed line and shares everything else: // depth + 1 new blocks. The old root is untouched, so whoever holds it keeps a whole view. public Frozen WithLine(int[] path, int px) { var chain = new Frozen[path.Length]; var at = this; for (int d = 0; d < path.Length; d++) { chain[d] = at; at = at.Kids[path[d]]; } var fresh = Line(px); for (int d = path.Length - 1; d >= 0; d--) { fresh = Box(chain[d].Padding, chain[d].Kids.SetItem(path[d], fresh)); } return fresh; }}
public static class Program{ // 8 sections of 10 paragraphs of 6 lines. Paddings: document 40, section 24, paragraph 8. const int Sections = 8, Paragraphs = 10, Lines = 6;
static Frozen FrozenDoc() => Frozen.Box(40, Enumerable.Range(0, Sections).Select(s => Frozen.Box(24, Enumerable.Range(0, Paragraphs).Select(p => Frozen.Box(8, Enumerable.Range(0, Lines).Select(l => Frozen.Line(20)))))));
public static void Main() { var doc = new Container(40); var lines = new Line[Sections, Paragraphs, Lines]; int blocks = 1; for (int s = 0; s < Sections; s++) { var section = new Container(24); for (int p = 0; p < Paragraphs; p++) { var paragraph = new Container(8); for (int l = 0; l < Lines; l++) paragraph.Add(lines[s, p, l] = new Line(20)); section.Add(paragraph); blocks += 1 + Lines; } doc.Add(section); blocks++; } Console.WriteLine($"{blocks} blocks"); // 569 blocks
var w = new Work(); Container.Layout(doc, true, w); Console.WriteLine($"full layout: {doc.Height} px {w}"); // full layout: 10472 px (89 recomputed, 480 read)
// Two edits in one paragraph. The second finds its ancestors already dirty and stops. Console.WriteLine($"line 3.5.2 wraps to 40 px: {lines[2, 4, 1].SetHeight(40)} blocks marked dirty"); // line 3.5.2 wraps to 40 px: 3 blocks marked dirty Console.WriteLine($"line 3.5.3 wraps to 40 px: {lines[2, 4, 2].SetHeight(40)} blocks marked dirty"); // line 3.5.3 wraps to 40 px: 0 blocks marked dirty
// Only the path to the root is recomputed; its siblings are read from their caches. w = new Work(); Console.WriteLine($"incremental layout: {doc.HeightWith(w)} px {w}"); // incremental layout: 10512 px (3 recomputed, 22 read) w = new Work(); Console.WriteLine($"nothing changed: {doc.HeightWith(w)} px {w}"); // nothing changed: 10512 px (0 recomputed, 0 read) w = new Work(); Container.Layout(doc, true, w); Console.WriteLine($"full layout again: {doc.Height} px {w}"); // full layout again: 10512 px (89 recomputed, 480 read)
// Immutable version: readers take a snapshot; a writer builds a new root and publishes it // with a volatile write. With several writers, use Interlocked.CompareExchange in a loop. var v1 = FrozenDoc(); Frozen current = v1; var reader = Volatile.Read(ref current); var v2 = v1.WithLine(new[] { 2, 4, 1 }, 40); Volatile.Write(ref current, v2); Console.WriteLine($"section 1 shared: {(ReferenceEquals(v2.Kids[0], v1.Kids[0]) ? "true" : "false")}, " + $"section 3 shared: {(ReferenceEquals(v2.Kids[2], v1.Kids[2]) ? "true" : "false")}"); // section 1 shared: true, section 3 shared: false Console.WriteLine($"reader still sees {reader.Height} px, new readers see {Volatile.Read(ref current).Height} px"); // reader still sees 10472 px, new readers see 10492 px
// A pathological document: 100000 containers nested in a chain. The iterative layout // handles it; a recursive one would overflow the stack long before the bottom. var deep = new Container(1); var at = deep; for (int i = 1; i < 100_000; i++) { var next = new Container(1); at.Add(next); at = next; } at.Add(new Line(20)); Console.WriteLine($"100000 levels deep: {deep.HeightWith(new Work())} px"); // 100000 levels deep: 100020 px }}package main
import ( "fmt" "slices" "sync/atomic")
// Work counts what one layout pass does, so full and incremental layout can be compared.type Work struct { Recomputed int // containers whose height was summed again Read int // children whose height was used as-is: a line, or a clean container's cache}
func (w *Work) String() string { return fmt.Sprintf("(%d recomputed, %d read)", w.Recomputed, w.Read) }
// Block is the component: any block in a document's layout tree. Its height in pixels is// cached, and dirty means the cache may be stale. Invariant: if a block is dirty, so is every// ancestor.type Block interface { Height() int // the cached height setParent(*Container)}
// Line is a leaf. Its height is set from outside (a line wraps when text is typed), never computed.type Line struct { parent *Container px int}
func (l *Line) Height() int { return l.px }func (l *Line) setParent(c *Container) { l.parent = c }
// SetHeight returns how many blocks this edit newly marked dirty.func (l *Line) SetHeight(px int) int { l.px = px if l.parent == nil { return 0 } return l.parent.MarkDirty()}
// Container is the composite: its height is its padding plus its children's, recomputed only// when dirty. Not safe for concurrent use: reading the height can write the cache. Keep the// tree on one goroutine, as UI toolkits do, or share the immutable version below.type Container struct { parent *Container padding int children []Block height int dirty bool}
func NewContainer(padding int) *Container { return &Container{padding: padding, dirty: true} // nothing computed yet}
func (c *Container) Height() int { return c.height }func (c *Container) setParent(p *Container) { c.parent = p }
func (c *Container) Add(child Block) *Container { child.setParent(c) c.children = append(c.children, child) c.MarkDirty() return c}
// MarkDirty walks up, marking each block dirty, and stops at the first that already is: by// the invariant, everything above it is dirty too. Ten edits in one paragraph cost one walk.func (c *Container) MarkDirty() int { marked := 0 for at := c; at != nil && !at.dirty; at = at.parent { at.dirty = true marked++ } return marked}
// HeightWith recomputes only what changed since the last read.func (c *Container) HeightWith(w *Work) int { if c.dirty { Layout(c, false, w) } return c.height}
// Layout is one layout pass. full = recompute every container (what a tree with no cache// does); otherwise only dirty containers, reading clean children's cached heights.// Iterative, with an explicit stack. Go grows goroutine stacks, but only up to a limit (1 GB// on 64-bit by default), and the depth of a document is decided by whoever wrote it.func Layout(root *Container, full bool, w *Work) { stack := []*Container{root} var order []*Container // reversed at the end, so parents come after their children for len(stack) > 0 { c := stack[len(stack)-1] stack = stack[:len(stack)-1] order = append(order, c) for _, child := range c.children { if k, ok := child.(*Container); ok && (full || k.dirty) { stack = append(stack, k) } else { w.Read++ } } } for _, c := range slices.Backward(order) { sum := c.padding for _, child := range c.children { sum += child.Height() } c.height, c.dirty = sum, false w.Recomputed++ }}
// Frozen is the immutable alternative. A block never changes once built, so its height is// computed once, at construction, from children that are already final. No dirty flags, no// parent pointers (a shared block has many parents), and any number of goroutines can read it.type Frozen struct { padding int kids []*Frozen // never modified after construction height int}
func FrozenLine(px int) *Frozen { return &Frozen{height: px} }
func FrozenBox(padding int, kids []*Frozen) *Frozen { h := padding for _, k := range kids { h += k.height } return &Frozen{padding: padding, kids: kids, height: h}}
// WithLine copies the path from the root to the changed line and shares everything else:// depth + 1 new blocks. The old root is untouched, so whoever holds it keeps a whole view.func (f *Frozen) WithLine(path []int, px int) *Frozen { chain := make([]*Frozen, len(path)) at := f for d, i := range path { chain[d] = at at = at.kids[i] } fresh := FrozenLine(px) for d := len(path) - 1; d >= 0; d-- { kids := slices.Clone(chain[d].kids) kids[path[d]] = fresh fresh = FrozenBox(chain[d].padding, kids) } return fresh}
// 8 sections of 10 paragraphs of 6 lines. Paddings: document 40, section 24, paragraph 8.const sections, paragraphs, linesPer = 8, 10, 6
func frozenDoc() *Frozen { var secs []*Frozen for range sections { var paras []*Frozen for range paragraphs { var ls []*Frozen for range linesPer { ls = append(ls, FrozenLine(20)) } paras = append(paras, FrozenBox(8, ls)) } secs = append(secs, FrozenBox(24, paras)) } return FrozenBox(40, secs)}
func main() { doc := NewContainer(40) var lines [sections][paragraphs][linesPer]*Line blocks := 1 for s := range sections { section := NewContainer(24) for p := range paragraphs { paragraph := NewContainer(8) for l := range linesPer { lines[s][p][l] = &Line{px: 20} paragraph.Add(lines[s][p][l]) } section.Add(paragraph) blocks += 1 + linesPer } doc.Add(section) blocks++ } fmt.Printf("%d blocks\n", blocks) // 569 blocks
w := &Work{} Layout(doc, true, w) fmt.Printf("full layout: %d px %s\n", doc.height, w) // full layout: 10472 px (89 recomputed, 480 read)
// Two edits in one paragraph. The second finds its ancestors already dirty and stops. fmt.Printf("line 3.5.2 wraps to 40 px: %d blocks marked dirty\n", lines[2][4][1].SetHeight(40)) // line 3.5.2 wraps to 40 px: 3 blocks marked dirty fmt.Printf("line 3.5.3 wraps to 40 px: %d blocks marked dirty\n", lines[2][4][2].SetHeight(40)) // line 3.5.3 wraps to 40 px: 0 blocks marked dirty
// Only the path to the root is recomputed; its siblings are read from their caches. w = &Work{} fmt.Printf("incremental layout: %d px %s\n", doc.HeightWith(w), w) // incremental layout: 10512 px (3 recomputed, 22 read) w = &Work{} fmt.Printf("nothing changed: %d px %s\n", doc.HeightWith(w), w) // nothing changed: 10512 px (0 recomputed, 0 read) w = &Work{} Layout(doc, true, w) fmt.Printf("full layout again: %d px %s\n", doc.height, w) // full layout again: 10512 px (89 recomputed, 480 read)
// Immutable version: readers Load a snapshot; a writer builds a new root and Stores it. // With several writers, use CompareAndSwap in a loop so no edit is lost. v1 := frozenDoc() var current atomic.Pointer[Frozen] current.Store(v1) reader := current.Load() v2 := v1.WithLine([]int{2, 4, 1}, 40) current.Store(v2) fmt.Printf("section 1 shared: %t, section 3 shared: %t\n", v2.kids[0] == v1.kids[0], v2.kids[2] == v1.kids[2]) // section 1 shared: true, section 3 shared: false fmt.Printf("reader still sees %d px, new readers see %d px\n", reader.height, current.Load().height) // reader still sees 10472 px, new readers see 10492 px
// A pathological document: 100000 containers nested in a chain. The iterative layout // handles it without growing the stack at all. deep := NewContainer(1) at := deep for i := 1; i < 100_000; i++ { next := NewContainer(1) at.Add(next) at = next } at.Add(&Line{px: 20}) fmt.Printf("100000 levels deep: %d px\n", deep.HeightWith(&Work{})) // 100000 levels deep: 100020 px}// Counts the work one layout pass does, so full and incremental layout can be compared.class Work { recomputed = 0; // containers whose height was summed again read = 0; // children whose height was used as-is: a line, or a clean container's cache toString(): string { return `(${this.recomputed} recomputed, ${this.read} read)`; }}
// The component: any block in a document's layout tree. Its height in pixels is cached, and// `dirty` means the cache may be stale. Invariant: if a block is dirty, so is every ancestor.abstract class Block { parent: Container | null = null; height = 0; dirty = false;}
// A leaf. Its height is set from outside (a line wraps when text is typed), never computed.class Line extends Block { constructor(px: number) { super(); this.height = px; }
// Returns how many blocks this edit newly marked dirty. setHeight(px: number): number { this.height = px; return this.parent === null ? 0 : this.parent.markDirty(); }}
// The composite: its height is its padding plus its children's, recomputed only when dirty.// One thread reads and writes it, but height() still writes the cache on read, so code that// awaits between reading and editing must not assume the tree stayed the same.class Container extends Block { readonly children: Block[] = []; constructor(readonly padding: number) { super(); this.dirty = true; // nothing computed yet }
add(child: Block): this { child.parent = this; this.children.push(child); this.markDirty(); return this; }
// Walk up, marking each block dirty, and stop at the first that already is: by the // invariant, everything above it is dirty too. Ten edits in one paragraph cost one walk. markDirty(): number { let marked = 0; for (let c: Container | null = this; c !== null && !c.dirty; c = c.parent) { c.dirty = true; marked++; } return marked; }
// Reading the height recomputes only what changed since the last read. heightOf(w: Work): number { if (this.dirty) layout(this, false, w); return this.height; }}
// One layout pass. full = recompute every container (what a tree with no cache does);// otherwise only dirty containers, reading clean children's cached heights.// Iterative, with an explicit stack: recursion would overflow the stack on a deep enough tree,// and the depth of a document is decided by whoever wrote it.function layout(root: Container, full: boolean, w: Work): void { const stack: Container[] = [root]; const order: Container[] = []; // reversed at the end, so parents come after their children while (stack.length > 0) { const c = stack.pop()!; order.push(c); for (const child of c.children) { if (child instanceof Container && (full || child.dirty)) stack.push(child); else w.read++; } } for (let i = order.length - 1; i >= 0; i--) { const c = order[i]; c.height = c.children.reduce((sum, child) => sum + child.height, c.padding); c.dirty = false; w.recomputed++; }}
// The immutable alternative. A block never changes once built, so its height is computed once,// in the constructor, from children that are already final. No dirty flags, no parent// pointers (a shared block has many parents), and any reader can hold it as long as it likes.class Frozen { private constructor(readonly padding: number, readonly kids: readonly Frozen[], readonly height: number) { Object.freeze(this); }
static line(px: number): Frozen { return new Frozen(0, [], px); }
static box(padding: number, kids: Frozen[]): Frozen { return new Frozen(padding, Object.freeze([...kids]), kids.reduce((h, k) => h + k.height, padding)); }
// An edit copies the path from the root to the changed line and shares everything else: // depth + 1 new blocks. The old root is untouched, so whoever holds it keeps a whole view. withLine(path: number[], px: number): Frozen { const chain: Frozen[] = []; let at: Frozen = this; for (const i of path) { chain.push(at); at = at.kids[i]; } let fresh = Frozen.line(px); for (let d = path.length - 1; d >= 0; d--) { const kids = [...chain[d].kids]; kids[path[d]] = fresh; fresh = Frozen.box(chain[d].padding, kids); } return fresh; }}
// 8 sections of 10 paragraphs of 6 lines. Paddings: document 40, section 24, paragraph 8.const SECTIONS = 8, PARAGRAPHS = 10, LINES = 6;function times<T>(n: number, make: () => T): T[] { return Array.from({ length: n }, make);}
const frozenDoc = () => Frozen.box(40, times(SECTIONS, () => Frozen.box(24, times(PARAGRAPHS, () => Frozen.box(8, times(LINES, () => Frozen.line(20)))))));
const doc = new Container(40);const lines: Line[][][] = [];let blocks = 1;for (let s = 0; s < SECTIONS; s++) { const section = new Container(24); lines.push([]); for (let p = 0; p < PARAGRAPHS; p++) { const paragraph = new Container(8); const row = times(LINES, () => new Line(20)); for (const line of row) paragraph.add(line); lines[s].push(row); section.add(paragraph); blocks += 1 + LINES; } doc.add(section); blocks++;}console.log(`${blocks} blocks`); // 569 blocks
let w = new Work();layout(doc, true, w);console.log(`full layout: ${doc.height} px ${w}`); // full layout: 10472 px (89 recomputed, 480 read)
// Two edits in one paragraph. The second finds its ancestors already dirty and stops.console.log(`line 3.5.2 wraps to 40 px: ${lines[2][4][1].setHeight(40)} blocks marked dirty`);// line 3.5.2 wraps to 40 px: 3 blocks marked dirtyconsole.log(`line 3.5.3 wraps to 40 px: ${lines[2][4][2].setHeight(40)} blocks marked dirty`);// line 3.5.3 wraps to 40 px: 0 blocks marked dirty
// Only the path to the root is recomputed; its siblings are read from their caches.w = new Work();console.log(`incremental layout: ${doc.heightOf(w)} px ${w}`); // incremental layout: 10512 px (3 recomputed, 22 read)w = new Work();console.log(`nothing changed: ${doc.heightOf(w)} px ${w}`); // nothing changed: 10512 px (0 recomputed, 0 read)w = new Work();layout(doc, true, w);console.log(`full layout again: ${doc.height} px ${w}`); // full layout again: 10512 px (89 recomputed, 480 read)
// Immutable version: readers take a snapshot; a writer builds a new root and replaces the// reference. With one thread this still matters: a reader that awaits halfway through (say,// while exporting a PDF) keeps a consistent snapshot while edits land.const v1 = frozenDoc();let current = v1;const reader = current;const v2 = current.withLine([2, 4, 1], 40);current = v2;console.log(`section 1 shared: ${v2.kids[0] === v1.kids[0]}, section 3 shared: ${v2.kids[2] === v1.kids[2]}`);// section 1 shared: true, section 3 shared: falseconsole.log(`reader still sees ${reader.height} px, new readers see ${current.height} px`);// reader still sees 10472 px, new readers see 10492 px
// A pathological document: 100000 containers nested in a chain. The iterative layout// handles it; a recursive one would overflow the stack long before the bottom.const deep = new Container(1);let at = deep;for (let i = 1; i < 100_000; i++) { const next = new Container(1); at.add(next); at = next;}at.add(new Line(20));console.log(`100000 levels deep: ${deep.heightOf(new Work())} px`); // 100000 levels deep: 100020 pxWhat to notice
Section titled “What to notice”- The invariant is what makes the early stop safe. Say something marks a block dirty without
marking its ancestors. Then the walk can stop below a clean ancestor. The document shows a stale
height, with no error. That’s why
markDirtyis the only way to set the flag, andaddcalls it too. - The work follows the path, not the document. Take two edits in one paragraph of a 569-block document. Layout recomputed 3 containers, and reused 22 cached heights. A full layout recomputes 89 and reads 480. On a document ten times bigger, with the same depth, the incremental cost barely moves.
- Layout doesn’t recurse. The pass keeps its own stack, in a list. A recursive walk uses one call
frame per level. A pasted document 100,000 levels deep overflows the stack. Java throws
StackOverflowError. Python stops at its recursion limit (1000 by default). .NET kills the process. A loop moves the depth onto the heap. Also cap nesting where documents come in. Refusing a document nested 10,000 deep at import is kinder than laying it out. - Reading writes.
height()fills the cache, so two threads that only read can still race on the same fields. A mutable tree with lazy caches belongs to one thread, which is why UI toolkits make you touch views only on the UI thread. Locking every read works, but serializes the readers you wanted to run in parallel. - The immutable tree is the other answer. A
Frozenblock never changes, so its height is computed once, when it’s built. An edit builds a new line, a new paragraph, a new section and a new document (4 blocks). It shares the other 565 with the old version. A reader holding the old root keeps a whole, consistent document, for as long as it likes. The writer publishes the new root with one atomic reference swap (AtomicReference,atomic.Pointer,Volatile.Write). No reader ever sees half an edit. - Immutable trees drop parent pointers. Section 1 is shared by both versions, so it has two parents. Anything that needs “where am I?” carries the path from the root as it walks down, instead of following parent pointers up.
- Sums can skip the recompute. For a pure sum, an edit could add its difference (+20 px) to each ancestor directly. That breaks for anything subtraction can’t undo, like a maximum width, or “any child has an error”. So dirty flags are the general tool.
Our editor gets sluggish on long documents. Profiling shows most of each frame goes to recomputing heights after a keystroke. Meanwhile the PDF export, which runs on a worker thread, sometimes prints a page with half an edit in it. Fix both.
Whether you cache heights, mark dirty up to the root with an early stop, and recompute only the dirty path. Then, whether you see that the export problem is a reader racing a writer. You solve it by keeping the tree on one thread, and handing the exporter an immutable snapshot (or by making the tree persistent). The red flag is invalidating the whole tree on every edit. So is adding a lock around each height read.
Each container caches its height, with a dirty flag. I keep one invariant: a dirty block has dirty ancestors. An edit walks up marking dirty, and stops at the first dirty one. Layout goes down only into dirty containers, with a loop, and reads clean children from cache. For readers on other threads, I’d use an immutable tree. An edit copies the path to the root, shares the rest, and swaps the root atomically.
Tradeoffs
Section titled “Tradeoffs”Use Composite when the data is a part-whole hierarchy of unknown depth, and callers want to treat a part and a whole alike. Examples are files, UI elements, bundles, org units and expressions. Skip it when the structure is fixed and shallow: an order always has lines, and lines never hold orders. Also skip it when leaves and containers have little in common. A shared interface full of methods that half the nodes can’t honor is worse than two plain classes.
In each language
Section titled “In each language”| Language | Usual form | Built in |
|---|---|---|
| Java | An interface or abstract class; a sealed interface (Java 17) when the node types are fixed |
AWT’s Container extends Component, Swing’s JComponent, javax.swing.tree.TreeNode |
| Python | An ABC or duck typing; a frozen dataclass for immutable nodes |
ast.AST nodes walked with ast.walk and ast.NodeVisitor, xml.etree.ElementTree.Element |
| C# | An interface or abstract class; ImmutableArray for persistent children |
WPF’s visual tree, System.Linq.Expressions.Expression trees, Roslyn’s immutable syntax trees |
| Go | A small interface and a struct holding a slice of it; a type switch when node kinds are closed | go/ast with ast.Inspect, html.Node in golang.org/x/net/html |
| TypeScript | An interface and classes, or a discriminated union with a kind field |
The DOM’s Node, Element and Text; React element trees |
Roslyn, the C# compiler, is a well-known large example of the Hard level’s immutable tree. An edit produces a new syntax tree that shares most of its nodes with the old one. The old one stays valid, for anyone still using it.
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Composite because | The difference |
|---|---|---|
| Decorator | Both hold children of their own interface and forward calls | A decorator holds exactly one inner object and adds behavior to it. A composite holds many and combines their results. |
| Chain of responsibility | A request travels through linked objects; it often runs up a composite’s parent pointers | A chain passes one request along until a handler takes it. A composite asks all of its children and combines the answers. The logging framework uses both on the same logger tree. |
| Interpreter | Its expression tree is a composite | Interpreter is a composite with a purpose: each node type is a grammar rule, and evaluate is the operation. Every interpreter uses Composite; most composites aren’t interpreters. |
| Iterator | Both are about walking a collection | An iterator walks a structure without exposing its shape. A composite is the shape. A tree iterator (depth-first, breadth-first) is often how callers walk a composite. |
| Visitor | It walks a composite and does something per node | Visitor adds operations to a fixed set of node types without editing them, by double dispatch: one visitX method per type. Composite gives the tree; Visitor gives new operations over it. Compilers use both. |
Where it shows up
Section titled “Where it shows up”On this site:
- Logging framework: loggers form a tree by name, and settings on a branch apply to everything below it.
- Strategy: a shipping rule that holds one rule per zone nests like a
composite, and the whole tree is itself a
ShippingRate. - File system: files and directories as a composite tree, with paths and permissions.
In real systems, you’ll find composites in:
- The DOM, and every UI toolkit (Android’s
ViewGroup, AWT and Swing, WPF). - Syntax trees in compilers and linters.
- Scene graphs in game engines. Moving a parent moves everything attached to it.
- Bills of materials in manufacturing.
- Organization trees in cloud accounts. AWS service control policies attached to an organizational unit apply to every account below it.
Interview questions
Section titled “Interview questions”Interviewer asksWhere should add() and remove() go, the component or the composite?
On the component, every node looks the same. But a leaf has to throw. That breaks Liskov
substitution, and moves the type check to run time. On the composite, misuse doesn’t compile. But
code that edits trees needs the composite type. A good middle ground is transparent reads (every
node has children(), empty for a leaf) and safe writes (only the composite has add).
Interviewer asksHow do you stop someone adding a node to its own descendant?
Check in add, before changing anything: walk up from the target through parent pointers, and
refuse if you meet the node being added. That costs one step per level. Without parent pointers,
search the node’s subtree for the target instead. Also make adding a node that already has a
parent a move, so a node can’t end up in two places.
Interviewer asksHow do you add a new operation over every kind of node?
If it’s core to what a node is, add a method to the component, and implement it per class. If it only needs the shared interface and the children, write a plain function that walks the tree. Say it needs different code per node type, and there are many such operations over a stable set of types. Then use Visitor, which keeps each operation in one class.
Interviewer asksThe tree is huge and the total is read on every change. What do you do?
Cache each node’s total with a dirty flag. On a change, mark ancestors dirty up to the root, stopping at the first already dirty one. On a read, recompute only dirty nodes and use the cache for the rest, so the cost follows the depth, not the size. Traverse iteratively so deep trees can’t overflow the stack, and if other threads read the tree, give them immutable snapshots.
Interviewer asksCan a node have two parents?
Not in a tree with parent pointers. The pointer can’t be right for both, and totals count the node twice. Sometimes the data really is shared, like a component used in two assemblies. Then either copy it into each place, or switch to immutable nodes without parent pointers. There, sharing is safe, because nothing changes in place.
| Decision | We chose | We gave up |
|---|---|---|
| Leaf and tree | One interface; the composite sums its children | An interface that has to fit every node |
Where add lives |
Writes on the composite, reads (children()) on everything |
Code that edits trees needs the composite type |
| Pricing rules | A Quote value with discountable and fixed parts |
A plain number at each level |
| Where a node lives | A parent pointer set only by add; adding is moving |
Shared subtrees |
| Cycles | Refused in add, before anything changes |
One walk up the tree per add |
| Big trees | Cached totals, dirty flags, early stop | An invariant that one bad setter breaks |
| Deep trees | Iterative traversal with an explicit stack | Code that reads less naturally than recursion |
| Other readers | Immutable nodes, path copying, atomic root swap | An allocation per level on each edit, and parent pointers |
Further reading
Section titled “Further reading”- Decorator, the one-child cousin of Composite
- Liskov substitution, and why a leaf that throws on
addbreaks it - Logging framework, a composite logger tree with inherited settings
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Composite chapter
- Chris Okasaki, Purely Functional Data Structures, for the path copying behind the Hard level’s immutable tree
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.