Vasu Patel logoVasu Patel
← Patterns of Good Design
All chapters in this track
  1. 01Chapter 1: Why Patterns Exist — Vocabulary, Not a Checklist
  2. 02Chapter 2: Patterns and SOLID: How They Relate
  3. 03Chapter 3: Creational Patterns I: Factory Method & Abstract Factory
  4. 04Chapter 4: Creational Patterns II: Builder, Singleton, Prototype
  5. 05Chapter 5: Structural Patterns I: Adapter & Facade
  6. 06Chapter 6: Structural Patterns II: Decorator & Proxy
  7. 07Chapter 7: Structural Patterns III: Composite, Bridge & Flyweight
  8. 08Chapter 8: Behavioral Patterns I: Strategy & Template Method
  9. 09Chapter 9: Behavioral Patterns II: Observer & Mediator
  10. 10Chapter 10: Behavioral Patterns III: Command & Memento
  11. 11Chapter 11: Behavioral Patterns IV: State & Chain of Responsibility
  12. 12Chapter 12: Behavioral Patterns V: Iterator & Visitor
  13. 13Chapter 13: Anti-Patterns and Overuse: Recognizing "Pattern Fever"
  14. 14Chapter 14: How Dynamic and Functional Languages Solve These Problems Differently
  15. 15Chapter 15: Architectural Patterns I: MVC/MVVM & Layered/Hexagonal Architecture
  16. 16Chapter 16: Architectural Patterns II: Event-Driven Architecture & CQRS
  17. 17Chapter 17: Architectural Patterns III: The Repository Pattern
  18. 18Chapter 18: Recognizing Which Pattern Fits an Interview Design Prompt

Chapter 7 of 18

Chapter 7: Structural Patterns III: Composite, Bridge & Flyweight

Composite

The problem it solves: you have a tree of objects — individual items and groups of items — and you want callers to treat a single item and a group of items through the exact same interface, without needing to check which one they have.

Before — client code must distinguish leaf from group at every call site:

class File { constructor(public name: string, public sizeKb: number) {} }
class Folder { constructor(public name: string, public children: (File | Folder)[]) {} }

function totalSize(item: File | Folder): number {
  if (item instanceof File) {
    return item.sizeKb;
  } else {
    // Manual type-checking recursion — exactly the polymorphism gap from the OOP book's Chapter 6.
    return item.children.reduce((sum, child) => sum + totalSize(child), 0);
  }
}

After — Composite unifies leaf and group behind one interface:

interface FileSystemItem {
  getSizeKb(): number;
  getName(): string;
}

class File implements FileSystemItem {
  constructor(private name: string, private sizeKb: number) {}
  getSizeKb(): number { return this.sizeKb; }
  getName(): string { return this.name; }
}

class Folder implements FileSystemItem {
  private children: FileSystemItem[] = [];
  constructor(private name: string) {}

  add(item: FileSystemItem): void { this.children.push(item); }
  getName(): string { return this.name; }

  getSizeKb(): number {
    return this.children.reduce((sum, child) => sum + child.getSizeKb(), 0); // recurses uniformly
  }
}

const root = new Folder("root");
root.add(new File("readme.txt", 2));
const sub = new Folder("sub");
sub.add(new File("photo.png", 500));
root.add(sub);

console.log(root.getSizeKb()); // 502 — no type-checking needed anywhere in this call
from abc import ABC, abstractmethod

class FileSystemItem(ABC):
    @abstractmethod
    def get_size_kb(self) -> int: ...

class File(FileSystemItem):
    def __init__(self, name: str, size_kb: int) -> None:
        self.name = name
        self._size_kb = size_kb

    def get_size_kb(self) -> int:
        return self._size_kb

class Folder(FileSystemItem):
    def __init__(self, name: str) -> None:
        self.name = name
        self.children: list[FileSystemItem] = []

    def get_size_kb(self) -> int:
        return sum(child.get_size_kb() for child in self.children)

When to use it: the domain is naturally a tree (file systems, UI component trees, organizational hierarchies, nested comment threads) and operations should apply uniformly whether you're working with a leaf or an entire subtree.

When to avoid it: the data isn't actually recursive/tree-shaped — forcing a flat list of unrelated items into a Composite structure just to "use the pattern" adds a leaf/composite distinction that doesn't reflect anything real about the domain.

Complexity cost: low once the domain is genuinely tree-shaped — the interface unification is close to free, since operations that need to work uniformly across leaves and groups are usually needed anyway in a real tree structure.

Bridge

The problem it solves: you have two independent dimensions of variation (say, "what kind of shape" and "what kind of rendering engine") and inheritance alone would force you into one combined hierarchy that grows multiplicatively as both dimensions grow.

Before — a combined hierarchy grows multiplicatively:

class VectorCircle { render(): string { return "Vector circle"; } }
class RasterCircle { render(): string { return "Raster circle"; } }
class VectorSquare { render(): string { return "Vector square"; } }
class RasterSquare { render(): string { return "Raster square"; } }
// Adding a "Triangle" shape or a "3D" renderer means the class count grows multiplicatively.

After — Bridge separates the two dimensions so each grows independently:

interface Renderer {
  renderCircle(radius: number): string;
  renderSquare(side: number): string;
}
class VectorRenderer implements Renderer {
  renderCircle(radius: number): string { return `Vector circle r=${radius}`; }
  renderSquare(side: number): string { return `Vector square s=${side}`; }
}
class RasterRenderer implements Renderer {
  renderCircle(radius: number): string { return `Raster circle r=${radius}`; }
  renderSquare(side: number): string { return `Raster square s=${side}`; }
}

abstract class Shape {
  constructor(protected renderer: Renderer) {} // the "bridge" to the other dimension
  abstract render(): string;
}
class Circle extends Shape {
  constructor(renderer: Renderer, private radius: number) { super(renderer); }
  render(): string { return this.renderer.renderCircle(this.radius); }
}
class Square extends Shape {
  constructor(renderer: Renderer, private side: number) { super(renderer); }
  render(): string { return this.renderer.renderSquare(this.side); }
}

// Adding a new shape needs no changes to Renderer implementations, and vice versa:
const circle = new Circle(new VectorRenderer(), 5);
console.log(circle.render()); // "Vector circle r=5"
from abc import ABC, abstractmethod

class Renderer(ABC):
    @abstractmethod
    def render_circle(self, radius: float) -> str: ...

class VectorRenderer(Renderer):
    def render_circle(self, radius: float) -> str:
        return f"Vector circle r={radius}"

class Shape(ABC):
    def __init__(self, renderer: Renderer) -> None:
        self._renderer = renderer

class Circle(Shape):
    def __init__(self, renderer: Renderer, radius: float) -> None:
        super().__init__(renderer)
        self._radius = radius

    def render(self) -> str:
        return self._renderer.render_circle(self._radius)

[SENIOR INSIGHT] Bridge is essentially "favor composition over inheritance" (the OOP book's Chapter 7) applied specifically to the case of two or more independent axes of variation — the exact scenario the OOP book used to motivate composition over inheritance in the first place, now given its own name at the structural-pattern level.

When to use it: two (or more) dimensions of variation exist independently, and modeling them as one combined inheritance hierarchy would grow multiplicatively as either dimension grows.

When to avoid it: there's genuinely only one axis of variation — introducing a Bridge for a single dimension is unjustified indirection with no multiplicative growth to prevent.

Complexity cost: moderate — introduces an extra interface and composition relationship, but this cost is paid once and prevents the much larger cost of combinatorial subclass growth, making it a good value trade specifically when both dimensions are real and growing.

Flyweight

The problem it solves: you need to create a very large number of similar objects, and the memory cost of giving each one its own full copy of shared, unchanging data would be prohibitive.

// Without Flyweight: every character on a page carries its own full font/style data — wasteful at scale.
class Glyph {
  constructor(public char: string, public font: string, public size: number, public x: number, public y: number) {}
}

// With Flyweight: shared, unchanging data (font, size) is separated from unique per-instance data (position).
interface GlyphStyle { // the "flyweight" — shared, immutable, reused across many glyphs
  font: string;
  size: number;
}

class GlyphStyleFactory {
  private styles = new Map<string, GlyphStyle>();

  getStyle(font: string, size: number): GlyphStyle {
    const key = `${font}-${size}`;
    if (!this.styles.has(key)) {
      this.styles.set(key, { font, size }); // created once, reused for every glyph with this style
    }
    return this.styles.get(key)!;
  }
}

class Glyph {
  constructor(public char: string, public style: GlyphStyle, public x: number, public y: number) {}
  // A document with a million characters in the same font now shares ONE style object,
  // instead of a million copies of the same {font, size} data.
}

const styleFactory = new GlyphStyleFactory();
const style = styleFactory.getStyle("Arial", 12);
const glyphs = [new Glyph("H", style, 0, 0), new Glyph("i", style, 10, 0)]; // both share `style`
class GlyphStyleFactory:
    def __init__(self) -> None:
        self._styles: dict[tuple[str, int], dict] = {}

    def get_style(self, font: str, size: int) -> dict:
        key = (font, size)
        if key not in self._styles:
            self._styles[key] = {"font": font, "size": size}
        return self._styles[key]

When to use it: genuinely large numbers of objects (thousands to millions — text rendering, particle systems, map tile rendering) where shared, immutable data dominates memory usage, and profiling has actually shown memory to be a real problem.

When to avoid it: small or moderate object counts, where the memory savings are negligible relative to the added indirection — this is a pattern that should be justified by an actual measured or clearly-anticipated memory problem, not applied speculatively.

Complexity cost: real — introduces a factory and a split between shared ("intrinsic") and per-instance ("extrinsic") state, which complicates the object model. Only worth it when the object count is large enough that the memory savings clearly outweigh that complexity.

Common mistakes

  1. Forcing non-tree-shaped data into a Composite structure just to gain interface uniformity — if the domain genuinely isn't recursive, this adds a distinction (leaf vs. composite) that doesn't map to anything real.
  2. Reaching for Bridge with only one axis of variation. Without a second independent dimension actually growing, Bridge's composition layer is unjustified — plain inheritance or Strategy may be simpler.
  3. Applying Flyweight without evidence of a real memory problem. This is one of the easiest patterns to apply prematurely (Chapter 1's discipline) — it should follow from a measured or very clearly anticipated scale, not a guess.
  4. Mutating what's supposed to be shared, immutable Flyweight state. If two glyphs share one GlyphStyle object and something mutates it, every glyph using that style changes unexpectedly — Flyweight's shared state must be treated as immutable.

Checklist: choosing Composite, Bridge, or Flyweight

  1. Composite: is the domain genuinely a tree, where leaf and group operations should be uniform? If not, skip it.
  2. Bridge: are there two or more independent axes of variation that would otherwise multiply a single inheritance hierarchy? If only one axis exists, skip it.
  3. Flyweight: is object count large enough (measured or clearly anticipated) that shared-state memory savings are worth the added complexity? If not, skip it.
  4. For Flyweight, always treat shared/intrinsic state as immutable — never mutate a flyweight instance that multiple objects reference.

Discussion / interview questions

  1. "When would you use the Composite pattern, and what's the payoff?" Strong answer: When the domain is naturally tree-shaped (file systems, UI trees) and you want to treat leaves and groups through one uniform interface — the payoff is eliminating manual type-checking recursion, tying back to the OOP book's polymorphism chapter.

  2. "How does Bridge relate to 'favor composition over inheritance'?" Strong answer: Bridge is that principle applied specifically to two-or-more independent axes of variation, separating them into their own hierarchies connected by composition, instead of one combined hierarchy that grows multiplicatively.

  3. "What problem does Flyweight solve, and how would you know if you actually need it?" Strong answer: Reduces memory for large numbers of similar objects by sharing immutable common state; you'd know you need it from an actual measured memory problem at real scale (thousands+ of objects), not by applying it speculatively.

  4. "What's the danger of mutating a Flyweight object's shared state?" Strong answer: Since many objects reference the same shared instance, mutating it changes the apparent state of every object using it simultaneously — an unexpected, hard-to-trace bug if the shared state is treated as if it were per-instance.

Chapter summary / cheat sheet

  • Composite: unifies leaf and group objects behind one interface for tree-shaped domains — eliminates manual type-checking recursion.
  • Bridge: separates two or more independent axes of variation into their own hierarchies connected by composition, preventing multiplicative subclass growth.
  • Flyweight: shares immutable common ("intrinsic") state across large numbers of objects to reduce memory — justified by real, measured scale, not speculation.
  • All three, like the rest of Part 2, should be introduced because a real, current shape of problem exists (a real tree, a real second axis of variation, a real memory constraint) — not because the pattern sounds applicable.