Episode 9 — System Design / 9.4 — Structural Design Patterns
9.4.e -- Composite Pattern
In one sentence: The Composite pattern lets you build tree-like structures of objects and treat individual objects (leaves) and groups of objects (composites) through the same interface -- like how a file system treats both files and folders uniformly: you can get the size of a file or the size of a folder (which recursively sums its contents).
Navigation: <- 9.4.d -- Decorator | 9.4 Overview ->
Table of Contents
- 1. What is the Composite Pattern?
- 2. The Problem It Solves
- 3. Structure: Component, Leaf, Composite
- 4. File System Example -- Full Code
- 5. Menu and Submenu Example
- 6. Organization Hierarchy Example
- 7. Recursive Operations
- 8. Before and After Comparison
- 9. When to Use and When to Avoid
- 10. Key Takeaways
- 11. Explain-It Challenge
1. What is the Composite Pattern?
The Composite pattern composes objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly.
┌─────────────────────────────────────────────────────────────┐
│ COMPOSITE PATTERN │
│ │
│ The KEY insight: both leaves and composites share │
│ the same interface. The client does not need to know │
│ whether it is working with a single item or a group. │
│ │
│ [Root Composite] │
│ / | \ │
│ [Composite] [Leaf] [Composite] │
│ / \ / | \ │
│ [Leaf] [Leaf] [Leaf] [Leaf] [Composite] │
│ / \ │
│ [Leaf] [Leaf] │
│ │
│ Every node responds to the same methods: │
│ getSize(), display(), search(), etc. │
└─────────────────────────────────────────────────────────────┘
| Concept | Real-world example |
|---|---|
| Component | FileSystemEntry (interface) |
| Leaf | A file (has no children) |
| Composite | A folder (contains files and other folders) |
| Operation | getSize() -- a file returns its own size; a folder sums all children |
2. The Problem It Solves
Without the Composite pattern, you must distinguish between individual items and groups everywhere:
// BAD: Client must check types and handle them differently
function calculateCost(item) {
if (item.type === 'product') {
return item.price;
} else if (item.type === 'bundle') {
let total = 0;
for (const child of item.items) {
if (child.type === 'product') {
total += child.price;
} else if (child.type === 'bundle') {
// Nested bundles? More conditionals...
for (const grandchild of child.items) {
// What about bundles inside bundles inside bundles?
// This gets ugly fast.
}
}
}
return total * (1 - item.discount);
}
}
With the Composite pattern:
// GOOD: Client treats everything uniformly
function calculateCost(item) {
return item.getCost(); // works for products AND bundles
}
3. Structure: Component, Leaf, Composite
┌──────────────────────────────────┐
│ <<interface>> │
│ FileSystemEntry │
│──────────────────────────────────│
│ + getName(): string │
│ + getSize(): number │
│ + display(indent): void │
│ + search(term): Entry[] │
└──────────────┬───────────────────┘
│
┌─────────┴──────────┐
│ │
┌────▼────────────┐ ┌───▼──────────────────┐
│ File (Leaf) │ │ Directory (Composite)│
│─────────────────│ │──────────────────────│
│ - name: string │ │ - name: string │
│ - size: number │ │ - children: Entry[] │
│─────────────────│ │──────────────────────│
│ + getName() │ │ + getName() │
│ + getSize() │ │ + getSize() <--. │
│ returns size │ │ sums children | │
│ + display() │ │ + display() | │
│ + search() │ │ + search() recursive│
└─────────────────┘ │ + add(entry) │
│ + remove(entry) │
└───────────────────────┘
| Role | Description |
|---|---|
| Component | Common interface (getName, getSize, display, search) |
| Leaf | End node with no children; implements operations directly |
| Composite | Container node; stores children and implements operations by delegating to children recursively |
4. File System Example -- Full Code
// ============================================================
// FILE SYSTEM -- COMPOSITE PATTERN (FULL IMPLEMENTATION)
// ============================================================
// ---- COMPONENT (base class) ----
class FileSystemEntry {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
getSize() {
throw new Error('getSize() must be implemented');
}
display(indent = '') {
throw new Error('display() must be implemented');
}
search(term) {
throw new Error('search() must be implemented');
}
getPath(parentPath = '') {
return parentPath ? `${parentPath}/${this.name}` : this.name;
}
}
// ---- LEAF: File ----
class File extends FileSystemEntry {
constructor(name, size, extension = '') {
super(name);
this.size = size;
this.extension = extension || name.split('.').pop();
this.createdAt = new Date().toISOString();
}
getSize() {
return this.size;
}
display(indent = '') {
const sizeStr = this._formatSize(this.size);
console.log(`${indent}-- ${this.name} (${sizeStr})`);
}
search(term) {
if (this.name.toLowerCase().includes(term.toLowerCase())) {
return [this];
}
return [];
}
getFileCount() {
return 1;
}
_formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
}
// ---- COMPOSITE: Directory ----
class Directory extends FileSystemEntry {
constructor(name) {
super(name);
this.children = [];
}
add(entry) {
this.children.push(entry);
return this; // chainable
}
remove(entryName) {
this.children = this.children.filter((c) => c.getName() !== entryName);
return this;
}
getChild(name) {
return this.children.find((c) => c.getName() === name) || null;
}
// RECURSIVE: sum sizes of all children (files and subdirectories)
getSize() {
return this.children.reduce((total, child) => total + child.getSize(), 0);
}
// RECURSIVE: display tree structure
display(indent = '') {
const sizeStr = this._formatSize(this.getSize());
const count = this.getFileCount();
console.log(`${indent}[${this.name}/] (${sizeStr}, ${count} files)`);
for (const child of this.children) {
child.display(indent + ' ');
}
}
// RECURSIVE: search all descendants
search(term) {
let results = [];
// Check own name
if (this.name.toLowerCase().includes(term.toLowerCase())) {
results.push(this);
}
// Search children recursively
for (const child of this.children) {
results = results.concat(child.search(term));
}
return results;
}
// RECURSIVE: count all files
getFileCount() {
return this.children.reduce((count, child) => {
if (child instanceof File) return count + 1;
if (child instanceof Directory) return count + child.getFileCount();
return count;
}, 0);
}
// RECURSIVE: find files by extension
findByExtension(ext) {
let results = [];
for (const child of this.children) {
if (child instanceof File && child.extension === ext) {
results.push(child);
} else if (child instanceof Directory) {
results = results.concat(child.findByExtension(ext));
}
}
return results;
}
_formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
}
// ============================================================
// BUILD A FILE SYSTEM TREE
// ============================================================
const root = new Directory('project');
// src directory
const src = new Directory('src');
src.add(new File('index.js', 2048))
.add(new File('app.js', 4096))
.add(new File('utils.js', 1024));
const components = new Directory('components');
components.add(new File('Header.jsx', 3072))
.add(new File('Footer.jsx', 2560))
.add(new File('Sidebar.jsx', 4608));
const styles = new Directory('styles');
styles.add(new File('main.css', 8192))
.add(new File('components.css', 5120));
src.add(components).add(styles);
// public directory
const publicDir = new Directory('public');
publicDir.add(new File('index.html', 1536))
.add(new File('favicon.ico', 15360))
.add(new File('logo.png', 102400));
// test directory
const tests = new Directory('tests');
tests.add(new File('app.test.js', 3584))
.add(new File('utils.test.js', 2048));
// Root level
root.add(src)
.add(publicDir)
.add(tests)
.add(new File('package.json', 512))
.add(new File('README.md', 4096));
// ============================================================
// USE IT -- uniform interface for files and directories
// ============================================================
console.log('=== File System Tree ===\n');
root.display();
console.log(`\n=== Total Size ===`);
console.log(`Project size: ${(root.getSize() / 1024).toFixed(1)} KB`);
console.log(`File count: ${root.getFileCount()}`);
console.log(`\n=== Search for "test" ===`);
const testResults = root.search('test');
testResults.forEach((r) => console.log(` Found: ${r.getName()}`));
console.log(`\n=== Find all .jsx files ===`);
const jsxFiles = root.findByExtension('jsx');
jsxFiles.forEach((f) => console.log(` ${f.getName()} (${f.getSize()} bytes)`));
console.log(`\n=== Size of src/ only ===`);
console.log(`src size: ${(src.getSize() / 1024).toFixed(1)} KB`);
console.log(`src file count: ${src.getFileCount()}`);
Output:
=== File System Tree ===
[project/] (159.3 KB, 13 files)
[src/] (30.0 KB, 8 files)
-- index.js (2.0 KB)
-- app.js (4.0 KB)
-- utils.js (1.0 KB)
[components/] (10.0 KB, 3 files)
-- Header.jsx (3.0 KB)
-- Footer.jsx (2.5 KB)
-- Sidebar.jsx (4.5 KB)
[styles/] (13.0 KB, 2 files)
-- main.css (8.0 KB)
-- components.css (5.0 KB)
[public/] (116.5 KB, 3 files)
-- index.html (1.5 KB)
-- favicon.ico (15.0 KB)
-- logo.png (100.0 KB)
[tests/] (5.5 KB, 2 files)
-- app.test.js (3.5 KB)
-- utils.test.js (2.0 KB)
-- package.json (512 B)
-- README.md (4.0 KB)
=== Total Size ===
Project size: 159.3 KB
File count: 13
=== Search for "test" ===
Found: tests
Found: app.test.js
Found: utils.test.js
=== Find all .jsx files ===
Header.jsx (3072 bytes)
Footer.jsx (2560 bytes)
Sidebar.jsx (4608 bytes)
=== Size of src/ only ===
src size: 30.0 KB
src file count: 8
5. Menu and Submenu Example
// ============================================================
// MENU / SUBMENU -- COMPOSITE PATTERN
// ============================================================
class MenuItem {
constructor(name, url, icon = '') {
this.name = name;
this.url = url;
this.icon = icon;
}
render(indent = '') {
console.log(`${indent}${this.icon} ${this.name} -> ${this.url}`);
}
getItemCount() {
return 1;
}
findByUrl(url) {
return this.url === url ? [this] : [];
}
}
class SubMenu {
constructor(name, icon = '') {
this.name = name;
this.icon = icon;
this.items = [];
}
add(item) {
this.items.push(item);
return this;
}
render(indent = '') {
console.log(`${indent}${this.icon} ${this.name} (submenu)`);
for (const item of this.items) {
item.render(indent + ' ');
}
}
getItemCount() {
return this.items.reduce((count, item) => count + item.getItemCount(), 0);
}
findByUrl(url) {
let results = [];
for (const item of this.items) {
results = results.concat(item.findByUrl(url));
}
return results;
}
}
// ---- Build navigation ----
const mainMenu = new SubMenu('Main Navigation', '#');
mainMenu.add(new MenuItem('Home', '/', '~'));
mainMenu.add(new MenuItem('About', '/about', 'i'));
const productsMenu = new SubMenu('Products', '>');
productsMenu
.add(new MenuItem('All Products', '/products', '*'))
.add(new MenuItem('New Arrivals', '/products/new', '+'))
.add(new MenuItem('On Sale', '/products/sale', '%'));
const categoriesMenu = new SubMenu('Categories', '>');
categoriesMenu
.add(new MenuItem('Electronics', '/products/electronics', '-'))
.add(new MenuItem('Clothing', '/products/clothing', '-'))
.add(new MenuItem('Books', '/products/books', '-'));
productsMenu.add(categoriesMenu);
mainMenu.add(productsMenu);
const accountMenu = new SubMenu('Account', '>');
accountMenu
.add(new MenuItem('Profile', '/account/profile', '*'))
.add(new MenuItem('Orders', '/account/orders', '*'))
.add(new MenuItem('Settings', '/account/settings', '*'));
mainMenu.add(accountMenu);
mainMenu.add(new MenuItem('Contact', '/contact', '@'));
// ---- Use it ----
console.log('=== Navigation Menu ===\n');
mainMenu.render();
console.log(`\nTotal menu items: ${mainMenu.getItemCount()}`);
console.log(`\nSearch for /products/sale:`);
const found = mainMenu.findByUrl('/products/sale');
found.forEach((item) => console.log(` Found: ${item.name}`));
6. Organization Hierarchy Example
// ============================================================
// ORGANIZATION HIERARCHY -- COMPOSITE PATTERN
// ============================================================
class Employee {
constructor(name, role, salary) {
this.name = name;
this.role = role;
this.salary = salary;
}
getSalary() {
return this.salary;
}
getHeadCount() {
return 1;
}
display(indent = '') {
console.log(`${indent}- ${this.name} (${this.role}) $${this.salary.toLocaleString()}`);
}
findByRole(role) {
return this.role.toLowerCase().includes(role.toLowerCase()) ? [this] : [];
}
}
class Department {
constructor(name) {
this.name = name;
this.members = []; // employees and sub-departments
}
add(member) {
this.members.push(member);
return this;
}
// RECURSIVE: total salary budget
getSalary() {
return this.members.reduce((total, member) => total + member.getSalary(), 0);
}
// RECURSIVE: total headcount
getHeadCount() {
return this.members.reduce((count, member) => count + member.getHeadCount(), 0);
}
// RECURSIVE: display hierarchy
display(indent = '') {
const budget = this.getSalary().toLocaleString();
const count = this.getHeadCount();
console.log(`${indent}[${this.name}] (${count} people, $${budget} budget)`);
for (const member of this.members) {
member.display(indent + ' ');
}
}
// RECURSIVE: find employees by role
findByRole(role) {
let results = [];
for (const member of this.members) {
results = results.concat(member.findByRole(role));
}
return results;
}
// RECURSIVE: average salary
getAverageSalary() {
const count = this.getHeadCount();
return count > 0 ? Math.round(this.getSalary() / count) : 0;
}
}
// ---- Build the org chart ----
const company = new Department('Acme Corp');
// Engineering
const engineering = new Department('Engineering');
const frontend = new Department('Frontend Team');
frontend.add(new Employee('Alice', 'Senior Frontend Dev', 130000))
.add(new Employee('Bob', 'Frontend Dev', 95000))
.add(new Employee('Carol', 'Junior Frontend Dev', 70000));
const backend = new Department('Backend Team');
backend.add(new Employee('Dave', 'Senior Backend Dev', 140000))
.add(new Employee('Eve', 'Backend Dev', 100000));
const devops = new Department('DevOps');
devops.add(new Employee('Frank', 'DevOps Engineer', 120000));
engineering.add(new Employee('Grace', 'VP Engineering', 200000))
.add(frontend)
.add(backend)
.add(devops);
// Product
const product = new Department('Product');
product.add(new Employee('Heidi', 'Product Manager', 135000))
.add(new Employee('Ivan', 'Product Designer', 110000));
// Sales
const sales = new Department('Sales');
sales.add(new Employee('Judy', 'Sales Director', 150000))
.add(new Employee('Karl', 'Account Executive', 90000))
.add(new Employee('Laura', 'Account Executive', 85000));
company.add(new Employee('Mallory', 'CEO', 250000))
.add(engineering)
.add(product)
.add(sales);
// ---- Use it ----
console.log('=== Organization Chart ===\n');
company.display();
console.log(`\n=== Company Stats ===`);
console.log(`Total headcount: ${company.getHeadCount()}`);
console.log(`Total salary budget: $${company.getSalary().toLocaleString()}`);
console.log(`Average salary: $${company.getAverageSalary().toLocaleString()}`);
console.log(`\n=== Engineering only ===`);
console.log(`Headcount: ${engineering.getHeadCount()}`);
console.log(`Budget: $${engineering.getSalary().toLocaleString()}`);
console.log(`\n=== Find all DevOps roles ===`);
const devopsEngineers = company.findByRole('DevOps');
devopsEngineers.forEach((e) => console.log(` ${e.name} - ${e.role}`));
Output:
=== Organization Chart ===
[Acme Corp] (13 people, $1,675,000 budget)
- Mallory (CEO) $250,000
[Engineering] (7 people, $855,000 budget)
- Grace (VP Engineering) $200,000
[Frontend Team] (3 people, $295,000 budget)
- Alice (Senior Frontend Dev) $130,000
- Bob (Frontend Dev) $95,000
- Carol (Junior Frontend Dev) $70,000
[Backend Team] (2 people, $240,000 budget)
- Dave (Senior Backend Dev) $140,000
- Eve (Backend Dev) $100,000
[DevOps] (1 people, $120,000 budget)
- Frank (DevOps Engineer) $120,000
[Product] (2 people, $245,000 budget)
- Heidi (Product Manager) $135,000
- Ivan (Product Designer) $110,000
[Sales] (3 people, $325,000 budget)
- Judy (Sales Director) $150,000
- Karl (Account Executive) $90,000
- Laura (Account Executive) $85,000
7. Recursive Operations
The power of the Composite pattern lies in recursive operations. Here is a visual walkthrough of how getSize() works on a file system:
getSize() call on root:
project.getSize()
|
+-- src.getSize()
| |
| +-- index.js.getSize() --> 2048
| +-- app.js.getSize() --> 4096
| +-- components.getSize()
| | +-- Header.jsx --> 3072
| | +-- Footer.jsx --> 2560
| | +-- Sidebar.jsx --> 4608
| | = 10240
| +-- styles.getSize()
| | +-- main.css --> 8192
| | +-- components.css --> 5120
| | = 13312
| = 2048 + 4096 + 10240 + 13312 = 29696 (+ utils.js 1024 = 30720)
|
+-- public.getSize() --> 119296
+-- tests.getSize() --> 5632
+-- package.json.getSize() --> 512
+-- README.md.getSize() --> 4096
|
= 30720 + 119296 + 5632 + 512 + 4096 = 160256 bytes
Pattern for any recursive composite operation:
class Composite {
someOperation() {
// 1. (Optional) Do something for this node
let result = initialValue;
// 2. Recurse into children
for (const child of this.children) {
result = combine(result, child.someOperation());
}
// 3. Return aggregated result
return result;
}
}
Examples of recursive operations:
| Operation | Leaf returns | Composite aggregates |
|---|---|---|
getSize() | Own size | Sum of children sizes |
getCount() | 1 | Sum of children counts |
display() | Print self | Print self + children |
search(term) | Match or empty | Concat all children matches |
toJSON() | Own data | Object with children array |
validate() | Own validity | All children valid? |
clone() | Copy of self | Copy of self + cloned children |
8. Before and After Comparison
Before (type-checking everywhere)
// BAD: Must check types and handle nesting manually
function calculateProjectSize(items) {
let total = 0;
for (const item of items) {
if (item.type === 'file') {
total += item.size;
} else if (item.type === 'directory') {
// Must manually recurse
total += calculateProjectSize(item.children);
}
// What if someone adds a 'symlink' type? Must update here too.
}
return total;
}
function displayProject(items, indent = '') {
for (const item of items) {
if (item.type === 'file') {
console.log(`${indent}- ${item.name}`);
} else if (item.type === 'directory') {
console.log(`${indent}[${item.name}/]`);
displayProject(item.children, indent + ' ');
}
}
}
function searchProject(items, term) {
// ... yet another function that checks types and recurses
}
// Problem: every operation duplicates the type-check and recursion logic
// Problem: adding a new node type means updating every function
After (composite pattern)
// GOOD: Uniform interface, recursion is built into the structure
const project = new Directory('project');
project.add(src).add(publicDir).add(tests);
// Every operation works the same way on any node:
project.getSize(); // works on file, directory, or entire tree
project.display(); // works on file, directory, or entire tree
project.search('test'); // works on file, directory, or entire tree
// Adding a new node type (e.g., Symlink) only requires implementing the interface:
class Symlink extends FileSystemEntry {
getSize() { return this.target.getSize(); }
display(indent) { console.log(`${indent}-> ${this.name} (link)`); }
search(term) { return this.name.includes(term) ? [this] : []; }
}
// No changes needed to existing code!
9. When to Use and When to Avoid
Use Composite when:
| Scenario | Why Composite helps |
|---|---|
| Tree-like structures (file systems, org charts, menus) | Natural representation of part-whole hierarchies |
| Client should treat individuals and groups uniformly | Single interface for leaves and composites |
| You need recursive operations (sum, count, search, display) | Recursion is built into the pattern |
| The nesting depth is variable or unknown | Pattern handles any depth |
| UI component trees (React component hierarchy) | Render trees naturally |
Avoid Composite when:
| Scenario | Why Composite is wrong |
|---|---|
| Flat list of items (no nesting) | Overkill; just use an array |
| Leaves and composites have very different interfaces | Uniform interface becomes awkward |
| Performance-critical with millions of nodes | Recursion overhead and memory for tree structure |
| Operations do not naturally aggregate | If leaves and composites behave completely differently, the pattern adds confusion |
10. Key Takeaways
- The Composite pattern represents part-whole hierarchies as tree structures where leaves and composites share the same interface.
- The client calls the same method on a single file or an entire directory -- the structure handles recursion internally.
- Three participants: Component (interface), Leaf (end node), Composite (container that delegates to children).
- Real-world uses: file systems, UI component trees, menu systems, org charts, pricing bundles, permission hierarchies, XML/HTML DOM.
- Recursive operations are the pattern's superpower:
getSize(),display(),search(),count(),validate()all follow the same recursive structure. - The Open/Closed Principle is maintained: add new leaf types (e.g., Symlink) without modifying existing code.
11. Explain-It Challenge
Without looking back, explain in your own words:
- What are the three participants in the Composite pattern and what does each do?
- How does
getSize()work recursively in a file system composite? - Why is it important that leaves and composites share the same interface?
- Give three real-world examples of tree structures that could use the Composite pattern.
- Draw an ASCII tree showing a project with 2 directories, each containing 2 files.
Navigation: <- 9.4.d -- Decorator | 9.4 Overview ->