Structural Pattern (GoF)

Bridge

Decouples an abstraction from its implementation so both can vary independently — using composition instead of inheritance to avoid the combinatorial explosion of subclasses.

Intent

Decouple abstraction from implementation through composition, so that both axes can vary and evolve completely independently. The abstraction delegates to the implementor instead of inheriting from it — replacing the N×M hierarchy of subclasses with N + M classes.

Cataloged by the GoF (1994) as a structural pattern, the Bridge is the planned answer to the combinatorial explosion problem of inheritance: when a class has two (or more) independent axes of variation, creating a subclass for every combination scales unsustainably. The Bridge solves this by separating the two axes into distinct hierarchies and connecting them through composition.

Problem

Imagine a notification system with two independent axes of variation: the type (normal, urgent) and the send channel (e-mail, SMS). If you model this with pure inheritance, you need a subclass for every combination:

  • NormalNotificationByEmail
  • NormalNotificationBySms
  • UrgentNotificationByEmail
  • UrgentNotificationBySms

With just 2 types and 2 channels, that's already 4 classes. Adding a WhatsApp channel and a Scheduled type raises it to 3×3 = 9 classes. With 5 types and 5 channels: 25 classes. The growth is multiplicative — and any change in one dimension requires updating the other.

The Bridge eliminates the explosion: each axis has its own hierarchy. Types and channels evolve completely independently. Adding WhatsApp is creating a WhatsAppSender class — without touching any notification type.

Bridge vs Adapter: the critical distinction

This is the most frequent confusion with the Bridge. The difference is in the timing and the intent:

  • The Adapter is applied afterwards — you have two incompatible interfaces that already exist independently, and the Adapter reconciles them. It's a corrective solution to an existing integration problem.
  • The Bridge is planned from the start — you identify two independent axes of variation in the design and intentionally separate them so they can evolve without affecting each other. It's a preventive architecture decision.

In short: Adapter reconciles existing incompatibilities; Bridge prevents coupling between dimensions of variation from the design stage.

Bridge vs Strategy

Both use composition to delegate behavior, and the code structure can be identical. The difference is conceptual:

  • The Strategy is a behavioral pattern with a single axis of variation: the algorithm. The context chooses which Strategy to use and can swap it at runtime. The focus is on the interchangeability of algorithms.
  • The Bridge is a structural pattern with two independent axes of variation: abstraction and implementation. Both can vary and have their own hierarchies. The focus is on separating structural dimensions of the design.

Solution

The Bridge organizes the code into four participants:

  1. Abstraction: defines the high-level interface and keeps a reference to the Implementor. Can be an abstract or concrete class. E.g.: abstract class Notification with a reference to SendChannel.
  2. RefinedAbstraction: extends the Abstraction, adding behavior specific to each variant. E.g.: NormalNotification, UrgentNotification.
  3. Implementor (interface): defines the contract of the implementation axis — typically low-level operations. E.g.: SendChannel interface with send(recipient, message).
  4. ConcreteImplementor: fulfills the Implementor contract. E.g.: EmailSender, SmsSender. New implementations don't affect any abstraction class.

The "bridge" is the reference the Abstraction keeps to the Implementor. Since this reference is typed as the SendChannel interface (not a concrete class), any ConcreteImplementor can be injected — at construction time or at runtime.

Structure

  Abstraction axis                                    Implementation axis

  Notification (abstract)                 «interface» SendChannel
  ┌────────────────────────────────┐                  ┌──────────────────────────────┐
  │ # channel: SendChannel         │─────────────────▶│ + send(recipient, msg): void │
  │ + notify(recipient, msg): void │                  └──────────────────────────────┘
  └────────────────────────────────┘                                  ▲
                   ▲                                  ┌───────────────┴───────────┐
  ┌────────────────┴────────────────────────────┐     EmailSender        SmsSender
   NormalNotification       UrgentNotification
  (Refined Abstraction)    (Refined Abstraction)


Without Bridge — N×M explosion of subclasses:

  2 types × 2 channels = 4 classes
  NormalNotificationByEmail
  NormalNotificationBySms
  UrgentNotificationByEmail
  UrgentNotificationBySms
  → with 5 types and 5 channels: 25 classes

With Bridge — N + M classes:

  2 types + 2 channels = 4 classes
  NormalNotification, UrgentNotification
  EmailSender, SmsSender
  → with 5 types and 5 channels: only 10 classes


Call flow:

  urgent.notify("ops@company.com", "Server is down!")
      │
      ▼ (UrgentNotification.notify)
      │  "[URGENT]" prefix applied
      │
      ▼ (delegates to this.channel.send)
  EmailSender.send("ops@company.com", "[URGENT] Server is down!")
      │
      ▼
  [EMAIL to ops@company.com] [URGENT] Server is down!

Code examples

Example 1 — Notifications by type and channel

The abstraction (Notification) and the implementor (SendChannel) vary completely independently. Adding a new channel or a new notification type doesn't require changing the other axis.

// ── Implementor — send channel axis ────────────────────────────
interface SendChannel {
  send(recipient: string, message: string): void;
}

// ── ConcreteImplementors ──────────────────────────────────────
class EmailSender implements SendChannel {
  send(recipient: string, message: string): void {
    console.log(`[EMAIL to ${recipient}] ${message}`);
  }
}

class SmsSender implements SendChannel {
  send(recipient: string, message: string): void {
    console.log(`[SMS to ${recipient}] ${message}`);
  }
}

// Adding WhatsAppSender doesn't affect any Notification class:
class WhatsAppSender implements SendChannel {
  send(recipient: string, message: string): void {
    console.log(`[WA to ${recipient}] ${message}`);
  }
}

// ── Abstraction — notification type axis ───────────────────────
abstract class Notification {
  // The "bridge": reference to the implementor — injected in the constructor.
  constructor(protected readonly channel: SendChannel) {}

  abstract notify(recipient: string, content: string): void;
}

// ── Refined abstractions ────────────────────────────────────────
class NormalNotification extends Notification {
  notify(recipient: string, content: string): void {
    this.channel.send(recipient, content);
  }
}

class UrgentNotification extends Notification {
  notify(recipient: string, content: string): void {
    this.channel.send(recipient, `[URGENT] ${content}`);
  }
}

// Adding ScheduledNotification doesn't affect any SendChannel class:
class ScheduledNotification extends Notification {
  constructor(channel: SendChannel, private readonly scheduledFor: string) {
    super(channel);
  }
  notify(recipient: string, content: string): void {
    this.channel.send(
      recipient,
      `[SCHEDULED ${this.scheduledFor}] ${content}`
    );
  }
}

// ── Usage — free combinations without new subclasses ──────────
const email = new EmailSender();
const sms   = new SmsSender();
const wa    = new WhatsAppSender();

new NormalNotification(email).notify(
  "ana@example.com", "Your order has been confirmed."
);
// [EMAIL to ana@example.com] Your order has been confirmed.

new UrgentNotification(email).notify(
  "ops@company.com", "Server is down!"
);
// [EMAIL to ops@company.com] [URGENT] Server is down!

new UrgentNotification(sms).notify(
  "+1-555-0000", "Server is down!"
);
// [SMS to +1-555-0000] [URGENT] Server is down!

new ScheduledNotification(wa, "2026-07-01 09:00").notify(
  "+1-555-0001", "Meeting reminder"
);
// [WA to +1-555-0001] [SCHEDULED 2026-07-01 09:00] Meeting reminder

Example 2 — Geometric shapes with interchangeable renderers

The classic GoF example: shapes (abstraction) vary independently from the renderer (implementation). A new shape type doesn't require any new renderer, and a new renderer doesn't require any new shape.

// ── Implementor — renderer axis ────────────────────────────────
interface Renderer {
  renderShape(type: string, params: string): void;
}

class VectorRenderer implements Renderer {
  renderShape(type: string, params: string): void {
    console.log(`[SVG]    ${type}(${params})`);
  }
}

class RasterRenderer implements Renderer {
  renderShape(type: string, params: string): void {
    console.log(`[Canvas] ${type}(${params})`);
  }
}

// ── Abstraction — geometric shape axis ──────────────────────────
abstract class Shape {
  constructor(protected renderer: Renderer) {}
  abstract draw(): void;
  // The implementor can be swapped at runtime:
  switchRenderer(renderer: Renderer): void {
    this.renderer = renderer;
  }
}

// ── Refined abstractions ────────────────────────────────────────
class Circle extends Shape {
  constructor(renderer: Renderer, private readonly radius: number) {
    super(renderer);
  }
  draw(): void {
    this.renderer.renderShape("circle", `r=${this.radius}`);
  }
}

class Rectangle extends Shape {
  constructor(
    renderer: Renderer,
    private readonly width: number,
    private readonly height: number
  ) {
    super(renderer);
  }
  draw(): void {
    this.renderer.renderShape(
      "rectangle", `w=${this.width} h=${this.height}`
    );
  }
}

// ── Usage ───────────────────────────────────────────────────────
const vector = new VectorRenderer();
const raster = new RasterRenderer();

const c = new Circle(vector, 50);
c.draw();
// [SVG]    circle(r=50)

new Circle(raster, 50).draw();
// [Canvas] circle(r=50)

new Rectangle(vector, 200, 100).draw();
// [SVG]    rectangle(w=200 h=100)

new Rectangle(raster, 200, 100).draw();
// [Canvas] rectangle(w=200 h=100)

// Swapping the renderer at runtime — no new subclass:
c.switchRenderer(raster);
c.draw();
// [Canvas] circle(r=50)

When to use

  • When you identify two independent axes of variation: any structure where "type of X" and "implementation of Y" vary separately and you don't want to create a subclass for every combination.
  • When abstraction and implementation must be independently extensible: different teams maintain each axis; new implementations shouldn't affect the abstractions and vice versa.
  • When you want to swap the implementation at runtime: since the implementor is injected through composition, it can be replaced without recreating the abstraction.
  • To hide implementation details from client code: the client only knows the abstraction's interface; the ConcreteImplementors remain fully encapsulated.

When to avoid

  • When there's only one axis of variation: if only the implementation varies (and not the abstraction), a simple Strategy already solves it without the Bridge's extra structure.
  • When the design isn't clear yet: the Bridge requires identifying the two axes of variation ahead of time. Applying it too early to an unstable design creates premature abstraction that hinders subsequent changes.
  • When the hierarchy is small and stable: if you have 2 types and 2 implementations that rarely change, 4 direct subclasses can be simpler and more readable than the Bridge's formal structure.

Pros and cons

Pros

  • Eliminates the combinatorial explosion of subclasses — N + M classes instead of N × M.
  • Open/Closed Principle: new abstraction or implementation types can be added without changing the other axis.
  • Single Responsibility Principle: each class has a single axis of variation.
  • Allows swapping the implementation at runtime through dependency injection.
  • Hides implementation details from client code — only the abstraction is exposed.

Cons

  • Adds structural complexity — more classes, more indirection, a steeper learning curve for whoever reads the code for the first time.
  • Requires identifying the two axes of variation in the initial design — can be difficult in systems that are still evolving.
  • The relationship between abstraction and implementation can be counterintuitive for developers unfamiliar with the pattern.

Common pitfalls

1. Using Bridge when Strategy would suffice

If only the implementation varies (and there's no hierarchy of abstractions), the Bridge is needlessly complex. The rule of thumb: if you have a single abstraction class and several implementors, use Strategy. If you have hierarchies on both sides, use Bridge.

2. Confusing Bridge with Adapter

Warning: the temptation to use Bridge as an Adapter is common. Remember: the Adapter reconciles two incompatible interfaces that already exist independently — it's a retroactive solution. The Bridge is designed from the start to separate two axes of variation — it's a prospective decision. If you're "adapting" an existing interface, use Adapter. If you're designing two independent axes, use Bridge.

3. Accidental coupling between the two axes

The goal of the Bridge is for abstraction and implementation to vary independently. If the abstraction starts calling ConcreteImplementor-specific methods (by downcasting or calling methods beyond the Implementor interface), the decoupling has been broken. The Implementor should expose only what is necessary for all abstractions. If different abstractions need different contracts from the implementor, reassess whether Bridge is still the right pattern.

4. Not injecting the implementor — instantiating it inside the abstraction

A subtle mistake is the abstraction concretely instantiating the implementor inside itself (this.channel = new EmailSender()). This restores the coupling the Bridge is meant to eliminate. The implementor should always be injected from outside — via constructor, setter, or Abstract Factory — so both axes remain independent and testable.

Related patterns

The Bridge is frequently compared to other patterns that also use composition to delegate behavior:

The Adapter is the pattern most confused with Bridge: Adapter reconciles existing incompatible interfaces (retroactive solution); Bridge separates two independent axes from the design stage (prospective solution). The Strategy also uses composition to delegate, but has a single axis of variation (the algorithm); Bridge has two axes with their own hierarchies. The Abstract Factory can be combined with Bridge to create the abstraction + implementor pair consistently — the factory instantiates the correct implementation and injects it into the abstraction. The Proxy also uses composition, but represents a single object and controls access to it; Bridge structurally separates two independent axes of variation.