Symptom

A class hierarchy that varies along two independent axes (shape kind × rendering backend, document type × storage format, message type × transport) explodes into a cross-product of subclasses. Adding a new value on either axis multiplies the work; the class count tracks N×M instead of N+M.

Goal

Two hierarchies that compose: an abstraction holds a reference to its implementation and delegates the variable part. Adding a new shape is one class; adding a new renderer is one class; the cross-product is implicit in the composition, not enumerated as classes.

Before the pattern

class CanvasCircle {
constructor(x, y, r) { this.x = x; this.y = y; this.r = r; }
draw(ctx) { ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI); ctx.stroke(); }
}
class SvgCircle {
constructor(x, y, r) { this.x = x; this.y = y; this.r = r; }
draw() { return `<circle cx="${this.x}" cy="${this.y}" r="${this.r}"/>`; }
}
class CanvasSquare {
constructor(x, y, side) { this.x = x; this.y = y; this.side = side; }
draw(ctx) { ctx.strokeRect(this.x, this.y, this.side, this.side); }
}
class SvgSquare {
constructor(x, y, side) { this.x = x; this.y = y; this.side = side; }
draw() { return `<rect x="${this.x}" y="${this.y}" width="${this.side}" height="${this.side}"/>`; }
}
// Adding Triangle = 2 new classes. Adding WebglRenderer = 3 new classes.
// Two shapes × two renderers = 4 classes; N × M scales combinatorially.

After the pattern

class CanvasRenderer {
drawCircle(x, y, r) { this.ctx.beginPath(); this.ctx.arc(x, y, r, 0, 2 * Math.PI); this.ctx.stroke(); }
drawSquare(x, y, side) { this.ctx.strokeRect(x, y, side, side); }
}
class SvgRenderer {
drawCircle(x, y, r) { return `<circle cx="${x}" cy="${y}" r="${r}"/>`; }
drawSquare(x, y, side) { return `<rect x="${x}" y="${y}" width="${side}" height="${side}"/>`; }
}
class Circle {
constructor(renderer, x, y, r) { this.renderer = renderer; this.x = x; this.y = y; this.r = r; }
draw() { return this.renderer.drawCircle(this.x, this.y, this.r); }
}
class Square {
constructor(renderer, x, y, side) { this.renderer = renderer; this.x = x; this.y = y; this.side = side; }
draw() { return this.renderer.drawSquare(this.x, this.y, this.side); }
}
// Adding Triangle = 1 new shape + 1 new method per renderer.
// Adding WebglRenderer = 1 new class. Two shapes + two renderers = 4 classes; N + M scales linearly.
Example source: Illustrative example written for this site in the spirit of Design Patterns (Gamma, Helm, Johnson, Vlissides, Addison-Wesley, 1994), chapter 4. The book's running example is a cross-platform Window hierarchy with XWindow / PMWindow implementations; this JavaScript adaptation uses Shape × Renderer to make the N+M vs N×M class-count payoff visible in code, not just in prose.
Pressure

Every new variant on either axis is Shotgun Surgery: adding WebGL forces editing every shape class; adding Triangle forces editing every renderer class. The work scales multiplicatively; tests scale multiplicatively; review burden scales multiplicatively.

Tradeoff

Two hierarchies are harder to learn than one — the reader must understand both the abstraction interface and the implementation interface before the system makes sense. Composition introduces indirection: 'where does drawCircle actually run?' becomes a navigation step the reader did not have to make under inheritance.

Relief

Class count and edit cost both drop from N×M to N+M. Each axis varies independently; new combinations require zero code. Tests for the renderer cover all shapes that delegate to it; tests for a shape cover its behaviour against any renderer.

Trap

Bridge applied before the two-axis variation actually exists is Speculative Generality at its most expensive — two hierarchies and an indirection layer to support exactly one renderer. Wait for the second renderer (or second shape, or whichever axis varies) to ship before bridging.