Bridge Pattern: Abstraction from Implementation

Inheritance is a powerful tool, but it can quickly become a liability when you're dealing with multiple dimensions of variation. Consider a simple scenario: you're building a notification system that...

Key Insights

  • The Bridge pattern separates abstraction from implementation, allowing both to evolve independently without causing a combinatorial explosion of subclasses.
  • Unlike inheritance-based designs, Bridge uses composition to connect two parallel hierarchies, making it ideal when you have multiple orthogonal dimensions of variation.
  • The pattern shines in scenarios like cross-platform development, rendering systems, and any situation where you need to switch implementations at runtime.

The Problem of Tight Coupling

Inheritance is a powerful tool, but it can quickly become a liability when you’re dealing with multiple dimensions of variation. Consider a simple scenario: you’re building a notification system that needs to support different message types (Alert, Reminder, Promotion) across different channels (Email, SMS, Push). Using inheritance alone, you’d end up with classes like EmailAlert, SMSAlert, PushAlert, EmailReminder, SMSReminder, and so on.

This is class explosion in action. With 3 message types and 3 channels, you need 9 classes. Add a new channel? That’s 3 more classes. Add a new message type? Another 3 classes. The math gets ugly fast, and the maintenance burden becomes unsustainable.

The Bridge pattern solves this by recognizing that you have two independent dimensions of variation and keeping them separate. Instead of a single inheritance hierarchy trying to handle everything, you create two parallel hierarchies connected by composition—a “bridge” between abstraction and implementation.

Understanding the Bridge Pattern Structure

The Bridge pattern consists of four key components:

Abstraction: The high-level interface that clients interact with. It contains a reference to the Implementor and delegates implementation-specific work to it.

Refined Abstraction: Extended versions of the Abstraction that add more specific behavior while still delegating to the Implementor.

Implementor: The interface that defines the operations that concrete implementations must provide. This is typically lower-level and more primitive than the Abstraction’s interface.

Concrete Implementor: Actual implementations of the Implementor interface, each providing platform-specific or variant-specific behavior.

The “bridge” is the composition relationship between Abstraction and Implementor. The Abstraction holds a reference to an Implementor and forwards requests to it. This decoupling means you can vary the abstraction hierarchy and the implementation hierarchy independently.

Visually, imagine two parallel vertical hierarchies. On the left, you have Abstraction at the top with Refined Abstractions below. On the right, Implementor at the top with Concrete Implementors below. A horizontal line—the bridge—connects Abstraction to Implementor through composition.

When to Use the Bridge Pattern

The Bridge pattern is appropriate in several scenarios:

Platform-independent abstractions: When you need to support multiple platforms (operating systems, databases, rendering engines) with the same high-level API.

Multiple orthogonal dimensions: When you have two or more independent aspects that can vary. The notification example above is classic—message type and delivery channel are orthogonal concerns.

Runtime implementation switching: When you need to change the implementation at runtime. Since the bridge is a composition relationship, you can swap implementations without affecting client code.

Hiding implementation details: When you want to completely hide implementation specifics from clients. The Abstraction presents a clean interface while the Implementor handles the messy details.

Avoiding permanent binding: When you want to avoid compile-time binding between abstraction and implementation, especially in scenarios where implementations might be selected based on configuration or user input.

Basic Implementation Example

Let’s implement the classic remote control and device example. The remote control is our abstraction—it provides a user interface for controlling devices. The devices (TV, Radio) are implementations that perform the actual work.

// Implementor interface
interface Device {
  isEnabled(): boolean;
  enable(): void;
  disable(): void;
  getVolume(): number;
  setVolume(volume: number): void;
  getChannel(): number;
  setChannel(channel: number): void;
}

// Concrete Implementor: TV
class TV implements Device {
  private enabled = false;
  private volume = 30;
  private channel = 1;

  isEnabled(): boolean {
    return this.enabled;
  }

  enable(): void {
    this.enabled = true;
    console.log("TV is now ON");
  }

  disable(): void {
    this.enabled = false;
    console.log("TV is now OFF");
  }

  getVolume(): number {
    return this.volume;
  }

  setVolume(volume: number): void {
    this.volume = Math.max(0, Math.min(100, volume));
    console.log(`TV volume set to ${this.volume}`);
  }

  getChannel(): number {
    return this.channel;
  }

  setChannel(channel: number): void {
    this.channel = channel;
    console.log(`TV channel set to ${this.channel}`);
  }
}

// Concrete Implementor: Radio
class Radio implements Device {
  private enabled = false;
  private volume = 20;
  private channel = 88;

  isEnabled(): boolean {
    return this.enabled;
  }

  enable(): void {
    this.enabled = true;
    console.log("Radio is now ON");
  }

  disable(): void {
    this.enabled = false;
    console.log("Radio is now OFF");
  }

  getVolume(): number {
    return this.volume;
  }

  setVolume(volume: number): void {
    this.volume = Math.max(0, Math.min(100, volume));
    console.log(`Radio volume set to ${this.volume}`);
  }

  getChannel(): number {
    return this.channel;
  }

  setChannel(channel: number): void {
    this.channel = channel;
    console.log(`Radio frequency set to ${this.channel} FM`);
  }
}

// Abstraction
class RemoteControl {
  protected device: Device;

  constructor(device: Device) {
    this.device = device;
  }

  togglePower(): void {
    if (this.device.isEnabled()) {
      this.device.disable();
    } else {
      this.device.enable();
    }
  }

  volumeUp(): void {
    this.device.setVolume(this.device.getVolume() + 10);
  }

  volumeDown(): void {
    this.device.setVolume(this.device.getVolume() - 10);
  }

  channelUp(): void {
    this.device.setChannel(this.device.getChannel() + 1);
  }

  channelDown(): void {
    this.device.setChannel(this.device.getChannel() - 1);
  }
}

// Refined Abstraction: Advanced Remote with additional features
class AdvancedRemote extends RemoteControl {
  mute(): void {
    this.device.setVolume(0);
    console.log("Device muted");
  }

  setChannelDirect(channel: number): void {
    this.device.setChannel(channel);
  }
}

// Usage
const tv = new TV();
const radio = new Radio();

const basicRemote = new RemoteControl(tv);
basicRemote.togglePower(); // TV is now ON
basicRemote.volumeUp(); // TV volume set to 40

const advancedRemote = new AdvancedRemote(radio);
advancedRemote.togglePower(); // Radio is now ON
advancedRemote.mute(); // Radio volume set to 0

Notice how we can add new devices without touching the remote classes, and add new remote types without modifying devices. Each hierarchy evolves independently.

Real-World Application: Cross-Platform Rendering

A more practical example is a cross-platform rendering system. Shapes are abstractions that need to be rendered, but the rendering mechanism varies by platform.

// Implementor: Renderer interface
interface Renderer {
  renderCircle(x: number, y: number, radius: number): void;
  renderRectangle(x: number, y: number, width: number, height: number): void;
}

// Concrete Implementor: OpenGL
class OpenGLRenderer implements Renderer {
  renderCircle(x: number, y: number, radius: number): void {
    console.log(
      `OpenGL: Drawing circle at (${x}, ${y}) with radius ${radius}`
    );
    // Actual OpenGL calls would go here
  }

  renderRectangle(x: number, y: number, width: number, height: number): void {
    console.log(
      `OpenGL: Drawing rectangle at (${x}, ${y}) with dimensions ${width}x${height}`
    );
  }
}

// Concrete Implementor: DirectX
class DirectXRenderer implements Renderer {
  renderCircle(x: number, y: number, radius: number): void {
    console.log(
      `DirectX: Drawing circle at (${x}, ${y}) with radius ${radius}`
    );
  }

  renderRectangle(x: number, y: number, width: number, height: number): void {
    console.log(
      `DirectX: Drawing rectangle at (${x}, ${y}) with dimensions ${width}x${height}`
    );
  }
}

// Abstraction: Shape
abstract class Shape {
  protected renderer: Renderer;

  constructor(renderer: Renderer) {
    this.renderer = renderer;
  }

  abstract draw(): void;
  abstract resize(factor: number): void;
}

// Refined Abstraction: Circle
class Circle extends Shape {
  private x: number;
  private y: number;
  private radius: number;

  constructor(renderer: Renderer, x: number, y: number, radius: number) {
    super(renderer);
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  draw(): void {
    this.renderer.renderCircle(this.x, this.y, this.radius);
  }

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

// Refined Abstraction: Rectangle
class Rectangle extends Shape {
  private x: number;
  private y: number;
  private width: number;
  private height: number;

  constructor(
    renderer: Renderer,
    x: number,
    y: number,
    width: number,
    height: number
  ) {
    super(renderer);
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
  }

  draw(): void {
    this.renderer.renderRectangle(this.x, this.y, this.width, this.height);
  }

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

// Usage: Same shapes, different renderers
const opengl = new OpenGLRenderer();
const directx = new DirectXRenderer();

const shapes: Shape[] = [
  new Circle(opengl, 100, 100, 50),
  new Rectangle(opengl, 200, 200, 100, 50),
  new Circle(directx, 100, 100, 50),
  new Rectangle(directx, 200, 200, 100, 50),
];

shapes.forEach((shape) => shape.draw());

Adding a new shape (Triangle, Polygon) requires no changes to renderers. Adding a new renderer (Vulkan, Metal) requires no changes to shapes. This is the power of the Bridge pattern.

Bridge vs. Similar Patterns

Bridge vs. Adapter: Adapter makes incompatible interfaces work together—it’s applied after the design to fix integration issues. Bridge is designed upfront to let abstractions and implementations vary independently. Adapter is reactive; Bridge is proactive.

Bridge vs. Strategy: Strategy encapsulates interchangeable algorithms and is a behavioral pattern focused on varying behavior. Bridge is structural, focused on separating abstraction from implementation. Strategy typically has one hierarchy; Bridge explicitly manages two.

Bridge with Abstract Factory: These patterns often work together. Abstract Factory can create the Concrete Implementors that Bridge uses. This combination is powerful for cross-platform systems where you want to create families of related implementations.

Tradeoffs and Best Practices

Added complexity: Bridge introduces additional classes and indirection. For simple scenarios with one dimension of variation, it’s overkill. Don’t use Bridge when inheritance would suffice.

Identifying the split: The hardest part is correctly identifying what belongs in the abstraction versus the implementation. Ask yourself: “What is the high-level operation clients care about?” versus “What are the platform-specific details of how it’s done?”

Combine with dependency injection: Instead of having abstractions create their implementors, inject them. This makes testing easier and aligns with the Dependency Inversion Principle.

// Good: Inject the renderer
class ShapeFactory {
  constructor(private renderer: Renderer) {}

  createCircle(x: number, y: number, radius: number): Circle {
    return new Circle(this.renderer, x, y, radius);
  }
}

Start simple: Don’t introduce Bridge preemptively. If you see class explosion happening or anticipate multiple dimensions of variation, then refactor to Bridge. Premature abstraction is as harmful as premature optimization.

The Bridge pattern is a fundamental tool for managing complexity in systems with multiple varying dimensions. Use it when you genuinely have orthogonal concerns that need independent evolution, and you’ll find your codebase significantly more maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.