Bridge

Decouple abstraction from implementation so both can vary independently. Instead of a single hierarchy that explodes with every combination, Bridge uses composition to let two dimensions evolve on their own.

The Problem

You start with a Shape class and add Circle and Square. Then you need colour variants: RedCircle, BlueCircle, RedSquare, BlueSquare. Two new shapes? Eight classes. Three colours? Twelve. The hierarchy grows as M × N where M is shapes and N is colours. Adding one dimension forces changes across the entire hierarchy.

Shape
├── Circle
│   ├── RedCircle
│   └── BlueCircle
├── Square
│   ├── RedSquare
│   └── BlueSquare
└── Triangle
    ├── RedTriangle
    └── BlueTriangle   ← 6 classes for 3 shapes × 2 colours

The Solution

Split the hierarchy into two: Abstraction (shape) and Implementation (rendering/colour). The Abstraction holds a reference to an Implementation object and delegates platform-specific work to it. The two hierarchies grow independently.

«abstract» Abstraction - impl: Implementor RefinedAbstraction + feature(): void «interface» Implementor + operationImpl() ImplA + operationImpl() ImplB + operationImpl() bridge
Bridge — Abstraction holds an Implementor reference; both hierarchies evolve independently

Real-World Analogy

A TV remote control (abstraction) is separate from the TV (implementation). The remote sends commands; the TV responds. You can replace the TV with a different brand and keep the same remote (if they share the interface). You can replace the remote with an app without changing the TV. Both evolve independently because they communicate through a defined channel.


TypeScript Example

// Implementation interface — the "rendering backend"
interface Renderer {
  renderCircle(radius: number): string;
  renderSquare(side: number): string;
}

// Concrete implementations
class VectorRenderer implements Renderer {
  renderCircle(radius: number): string {
    return `<circle r="${radius}" /> (vector)`;
  }
  renderSquare(side: number): string {
    return `<rect width="${side}" height="${side}" /> (vector)`;
  }
}

class RasterRenderer implements Renderer {
  renderCircle(radius: number): string {
    return `Drawing circle pixels, radius=${radius} (raster)`;
  }
  renderSquare(side: number): string {
    return `Drawing square pixels, side=${side} (raster)`;
  }
}

// Abstraction
abstract class Shape {
  constructor(protected renderer: Renderer) {}
  abstract draw(): string;
  abstract resize(factor: number): void;
}

// Refined abstractions
class Circle extends Shape {
  constructor(renderer: Renderer, private radius: number) {
    super(renderer);
  }

  draw(): string {
    return this.renderer.renderCircle(this.radius);
  }

  resize(factor: number): void {
    this.radius *= factor;
  }
}

class Square extends Shape {
  constructor(renderer: Renderer, private side: number) {
    super(renderer);
  }

  draw(): string {
    return this.renderer.renderSquare(this.side);
  }

  resize(factor: number): void {
    this.side *= factor;
  }
}

// Usage — mix and match freely
const vectorCircle = new Circle(new VectorRenderer(), 5);
console.log(vectorCircle.draw());
// → <circle r="5" /> (vector)

const rasterSquare = new Square(new RasterRenderer(), 10);
console.log(rasterSquare.draw());
// → Drawing square pixels, side=10 (raster)

// Adding a WebGLRenderer requires zero changes to Circle or Square
class WebGLRenderer implements Renderer {
  renderCircle(radius: number): string {
    return `gl.drawArrays(TRIANGLE_FAN, 0, ${radius * 2}) // circle`;
  }
  renderSquare(side: number): string {
    return `gl.drawArrays(TRIANGLE_STRIP, 0, 4) // square ${side}x${side}`;
  }
}

const glCircle = new Circle(new WebGLRenderer(), 8);
console.log(glCircle.draw());
// → gl.drawArrays(TRIANGLE_FAN, 0, 16) // circle

When to Use

  • You want to avoid a permanent binding between abstraction and implementation (switch at runtime)
  • Both abstraction and implementation should be extensible via subclassing independently
  • Your class hierarchy is growing as a cross-product of two or more dimensions
  • You’re hiding implementation details from client code (e.g. platform-specific code in implementation)

Pros

  • Open/Closed — add new abstractions and implementations independently
  • Eliminates the M × N class explosion
  • Client code only depends on the abstraction — implementation can change or be injected at runtime
  • High-level logic stays in abstraction; platform details stay in implementation

Cons

  • Adds complexity by introducing two hierarchies
  • Can be confusing if the abstraction/implementation split isn’t natural for your domain

  • Adapter makes two existing incompatible classes work together; Bridge is designed upfront to allow independent variation
  • Abstract Factory can create and configure a Bridge
  • Strategy is similar — both use composition to swap behaviour — but Strategy is about interchangeable algorithms, Bridge about separating two whole hierarchies