← Back to principles

Design Principle

Composite Pattern

Learn the Composite pattern: compose objects into tree structures to represent part-whole hierarchies. Treat individual objects and compositions uniformly.

The Composite Pattern composes objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions uniformly.


What is the Composite Pattern?

Composite Pattern provides:

  • Tree structure: Represent hierarchical structures
  • Uniform treatment: Treat individual and composite objects the same
  • Recursive composition: Composites can contain other composites
  • Simplified client code: Client doesn't need to distinguish between leaf and composite

Use cases:

  • File system (files and folders)
  • UI components (containers and widgets)
  • Organization structures
  • Expression trees

Structure

Component (interface)
  ├─ operation()
  ├─ add(component)
  ├─ remove(component)
  └─ getChild(index)

Leaf (implements Component)
  └─ operation()

Composite (implements Component)
  └─ children: Component[]
  └─ operation() (calls operation on all children)
  └─ add(component)
  └─ remove(component)

Examples

File System Example

// Component interface
interface FileSystemComponent {
  getName(): string;
  getSize(): number;
  display(indent: string): void;
}

// Leaf (File)
class File implements FileSystemComponent {
  constructor(private name: string, private size: number) {}
  
  getName(): string {
    return this.name;
  }
  
  getSize(): number {
    return this.size;
  }
  
  display(indent: string): void {
    console.log(`${indent}📄 ${this.name} (${this.size} bytes)`);
  }
}

// Composite (Directory)
class Directory implements FileSystemComponent {
  private children: FileSystemComponent[] = [];
  
  constructor(private name: string) {}
  
  getName(): string {
    return this.name;
  }
  
  getSize(): number {
    return this.children.reduce((total, child) => total + child.getSize(), 0);
  }
  
  add(component: FileSystemComponent): void {
    this.children.push(component);
  }
  
  remove(component: FileSystemComponent): void {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }
  
  getChild(index: number): FileSystemComponent | null {
    return this.children[index] || null;
  }
  
  display(indent: string): void {
    console.log(`${indent}📁 ${this.name}/ (${this.getSize()} bytes)`);
    this.children.forEach(child => {
      child.display(indent + "  ");
    });
  }
}

// Usage
const root = new Directory("root");
const documents = new Directory("documents");
const pictures = new Directory("pictures");

const file1 = new File("readme.txt", 1024);
const file2 = new File("notes.txt", 2048);
const file3 = new File("photo.jpg", 5120);

documents.add(file1);
documents.add(file2);
pictures.add(file3);

root.add(documents);
root.add(pictures);

root.display("");
// Output:
// 📁 root/ (8192 bytes)
//   📁 documents/ (3072 bytes)
//     📄 readme.txt (1024 bytes)
//     📄 notes.txt (2048 bytes)
//   📁 pictures/ (5120 bytes)
//     📄 photo.jpg (5120 bytes)

UI Component Example

// Component interface
interface UIComponent {
  render(): void;
  add(component: UIComponent): void;
  remove(component: UIComponent): void;
  getChild(index: number): UIComponent | null;
}

// Leaf (Button)
class Button implements UIComponent {
  constructor(private label: string) {}
  
  render(): void {
    console.log(`<button>${this.label}</button>`);
  }
  
  add(component: UIComponent): void {
    throw new Error("Cannot add to leaf");
  }
  
  remove(component: UIComponent): void {
    throw new Error("Cannot remove from leaf");
  }
  
  getChild(index: number): UIComponent | null {
    return null;
  }
}

// Leaf (Text)
class Text implements UIComponent {
  constructor(private content: string) {}
  
  render(): void {
    console.log(`<text>${this.content}</text>`);
  }
  
  add(component: UIComponent): void {
    throw new Error("Cannot add to leaf");
  }
  
  remove(component: UIComponent): void {
    throw new Error("Cannot remove from leaf");
  }
  
  getChild(index: number): UIComponent | null {
    return null;
  }
}

// Composite (Container)
class Container implements UIComponent {
  private children: UIComponent[] = [];
  
  constructor(private name: string) {}
  
  render(): void {
    console.log(`<div class="${this.name}">`);
    this.children.forEach(child => child.render());
    console.log(`</div>`);
  }
  
  add(component: UIComponent): void {
    this.children.push(component);
  }
  
  remove(component: UIComponent): void {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }
  
  getChild(index: number): UIComponent | null {
    return this.children[index] || null;
  }
}

// Usage
const page = new Container("page");
const header = new Container("header");
const content = new Container("content");
const footer = new Container("footer");

header.add(new Text("Welcome"));
header.add(new Button("Login"));

content.add(new Text("Main content here"));

footer.add(new Text("Copyright 2024"));

page.add(header);
page.add(content);
page.add(footer);

page.render();

Common Pitfalls

  • Leaf operations: Adding operations to leaf that don't make sense. Fix: Use null object pattern or throw exceptions
  • Performance: Traversing large trees can be slow. Fix: Use caching, lazy evaluation
  • Circular references: Composites can reference themselves. Fix: Add cycle detection
  • Too many operations: Component interface becomes too large. Fix: Use visitor pattern for complex operations

Interview Questions

Beginner

Q: What is the Composite pattern and when would you use it?

A:

Composite Pattern composes objects into tree structures to represent part-whole hierarchies.

Key characteristics:

  • Tree structure: Represents hierarchical structures
  • Uniform treatment: Treat individual and composite objects the same
  • Recursive composition: Composites can contain other composites
  • Simplified client: Client doesn't distinguish between leaf and composite

Example:

// Both File and Directory implement same interface
interface FileSystemComponent {
  getSize(): number;
  display(): void;
}

// Leaf
class File implements FileSystemComponent { /* ... */ }

// Composite
class Directory implements FileSystemComponent {
  private children: FileSystemComponent[] = [];
  // Can contain Files or other Directories
}

Use cases:

  • File systems: Files and folders
  • UI components: Containers and widgets
  • Organization structures: Departments and employees
  • Expression trees: Operations and operands

Intermediate

Q: Explain how the Composite pattern works. How do you handle operations that don't apply to leaves?

A:

Composite Pattern Structure:

1. Component Interface:

interface Component {
  operation(): void;
  add(component: Component): void;
  remove(component: Component): void;
}

2. Leaf:

class Leaf implements Component {
  operation(): void {
    // Leaf operation
  }
  
  add(component: Component): void {
    throw new Error("Cannot add to leaf");
  }
  
  remove(component: Component): void {
    throw new Error("Cannot remove from leaf");
  }
}

3. Composite:

class Composite implements Component {
  private children: Component[] = [];
  
  operation(): void {
    // Operate on all children
    this.children.forEach(child => child.operation());
  }
  
  add(component: Component): void {
    this.children.push(component);
  }
  
  remove(component: Component): void {
    // Remove logic
  }
}

Handling Leaf Operations:

Option 1: Throw Exception

class Leaf implements Component {
  add(component: Component): void {
    throw new Error("Cannot add to leaf");
  }
}

Option 2: Do Nothing

class Leaf implements Component {
  add(component: Component): void {
    // Do nothing
  }
}

Option 3: Null Object Pattern

class NullComponent implements Component {
  add(component: Component): void {
    // Do nothing
  }
}

Senior

Q: Design a composite system for a document editor that supports nested sections, paragraphs, images, and tables. Handle operations like word count, export, and formatting that work differently for different component types.

A:

// Component interface
interface DocumentComponent {
  getWordCount(): number;
  export(format: ExportFormat): string;
  format(style: Style): void;
  add(component: DocumentComponent): void;
  remove(component: DocumentComponent): void;
}

// Leaf - Paragraph
class Paragraph implements DocumentComponent {
  private text: string = "";
  private style: Style | null = null;
  
  constructor(text: string) {
    this.text = text;
  }
  
  getWordCount(): number {
    return this.text.split(/\s+/).filter(word => word.length > 0).length;
  }
  
  export(format: ExportFormat): string {
    switch (format) {
      case "html":
        return `<p>${this.text}</p>`;
      case "markdown":
        return this.text;
      case "plain":
        return this.text;
      default:
        return this.text;
    }
  }
  
  format(style: Style): void {
    this.style = style;
  }
  
  add(component: DocumentComponent): void {
    throw new Error("Cannot add to paragraph");
  }
  
  remove(component: DocumentComponent): void {
    throw new Error("Cannot remove from paragraph");
  }
}

// Leaf - Image
class Image implements DocumentComponent {
  constructor(private src: string, private alt: string) {}
  
  getWordCount(): number {
    return 0; // Images don't contribute to word count
  }
  
  export(format: ExportFormat): string {
    switch (format) {
      case "html":
        return `<img src="${this.src}" alt="${this.alt}">`;
      case "markdown":
        return `![${this.alt}](${this.src})`;
      default:
        return `[Image: ${this.alt}]`;
    }
  }
  
  format(style: Style): void {
    // Apply image-specific formatting
  }
  
  add(component: DocumentComponent): void {
    throw new Error("Cannot add to image");
  }
  
  remove(component: DocumentComponent): void {
    throw new Error("Cannot remove from image");
  }
}

// Composite - Section
class Section implements DocumentComponent {
  private children: DocumentComponent[] = [];
  private title: string;
  
  constructor(title: string) {
    this.title = title;
  }
  
  getWordCount(): number {
    return this.children.reduce((total, child) => total + child.getWordCount(), 0);
  }
  
  export(format: ExportFormat): string {
    const childrenContent = this.children
      .map(child => child.export(format))
      .join("\n");
    
    switch (format) {
      case "html":
        return `<section><h2>${this.title}</h2>${childrenContent}</section>`;
      case "markdown":
        return `## ${this.title}\n\n${childrenContent}`;
      default:
        return `${this.title}\n${childrenContent}`;
    }
  }
  
  format(style: Style): void {
    this.children.forEach(child => child.format(style));
  }
  
  add(component: DocumentComponent): void {
    this.children.push(component);
  }
  
  remove(component: DocumentComponent): void {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }
}

// Composite - Document
class Document implements DocumentComponent {
  private children: DocumentComponent[] = [];
  
  getWordCount(): number {
    return this.children.reduce((total, child) => total + child.getWordCount(), 0);
  }
  
  export(format: ExportFormat): string {
    return this.children
      .map(child => child.export(format))
      .join("\n\n");
  }
  
  format(style: Style): void {
    this.children.forEach(child => child.format(style));
  }
  
  add(component: DocumentComponent): void {
    this.children.push(component);
  }
  
  remove(component: DocumentComponent): void {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }
}

// Usage
const doc = new Document();
const intro = new Section("Introduction");
intro.add(new Paragraph("This is the introduction."));
intro.add(new Image("intro.jpg", "Introduction image"));

const body = new Section("Body");
body.add(new Paragraph("Main content here."));
body.add(new Paragraph("More content."));

doc.add(intro);
doc.add(body);

console.log(`Word count: ${doc.getWordCount()}`);
console.log(doc.export("html"));

Features:

  1. Uniform interface: All components implement same interface
  2. Type-specific behavior: Each type handles operations differently
  3. Recursive operations: Operations propagate through tree
  4. Flexible composition: Mix different component types

Key Takeaways

  • Composite pattern: Represents part-whole hierarchies
  • Uniform treatment: Treat individual and composite objects the same
  • Tree structure: Builds tree structures recursively
  • Use cases: File systems, UI components, document structures
  • Leaf operations: Handle operations that don't apply to leaves
  • Best practices: Use exceptions or null object for leaf operations, consider performance

Keep exploring

Principles work best in chorus. Pair this lesson with another concept and observe how your architecture conversations change.