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?

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.                       │
  └─────────────────────────────────────────────────────────────┘
ConceptReal-world example
ComponentFileSystemEntry (interface)
LeafA file (has no children)
CompositeA folder (contains files and other folders)
OperationgetSize() -- 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)      │
                       └───────────────────────┘
RoleDescription
ComponentCommon interface (getName, getSize, display, search)
LeafEnd node with no children; implements operations directly
CompositeContainer 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:

OperationLeaf returnsComposite aggregates
getSize()Own sizeSum of children sizes
getCount()1Sum of children counts
display()Print selfPrint self + children
search(term)Match or emptyConcat all children matches
toJSON()Own dataObject with children array
validate()Own validityAll children valid?
clone()Copy of selfCopy 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:

ScenarioWhy Composite helps
Tree-like structures (file systems, org charts, menus)Natural representation of part-whole hierarchies
Client should treat individuals and groups uniformlySingle 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 unknownPattern handles any depth
UI component trees (React component hierarchy)Render trees naturally

Avoid Composite when:

ScenarioWhy Composite is wrong
Flat list of items (no nesting)Overkill; just use an array
Leaves and composites have very different interfacesUniform interface becomes awkward
Performance-critical with millions of nodesRecursion overhead and memory for tree structure
Operations do not naturally aggregateIf leaves and composites behave completely differently, the pattern adds confusion

10. Key Takeaways

  1. The Composite pattern represents part-whole hierarchies as tree structures where leaves and composites share the same interface.
  2. The client calls the same method on a single file or an entire directory -- the structure handles recursion internally.
  3. Three participants: Component (interface), Leaf (end node), Composite (container that delegates to children).
  4. Real-world uses: file systems, UI component trees, menu systems, org charts, pricing bundles, permission hierarchies, XML/HTML DOM.
  5. Recursive operations are the pattern's superpower: getSize(), display(), search(), count(), validate() all follow the same recursive structure.
  6. 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:

  1. What are the three participants in the Composite pattern and what does each do?
  2. How does getSize() work recursively in a file system composite?
  3. Why is it important that leaves and composites share the same interface?
  4. Give three real-world examples of tree structures that could use the Composite pattern.
  5. Draw an ASCII tree showing a project with 2 directories, each containing 2 files.

Navigation: <- 9.4.d -- Decorator | 9.4 Overview ->