Bridge splits one large hierarchy into two smaller ones that can vary independently: an abstraction (what the code does, at a high level) and an implementation (how the low-level work gets done). The abstraction holds a reference to an implementation and delegates to it. The reference is the bridge.

Difficulty: ★★★☆☆. Popularity: ★★★☆☆. You will not write one every week in JavaScript, but you will recognise the problem it solves the moment a class name starts containing two nouns.

The problem: classes named after two things

A platform sells three kinds of things: memberships (recurring), one-time orders (with shipping), and tips to creators (small, no receipt). It accepts three payment rails: cards, an e-wallet and bank transfer QR codes. Designed with inheritance, every combination needs a class:

Purchase
├── MembershipByCard      ├── OrderByCard      ├── TipByCard
├── MembershipByWallet    ├── OrderByWallet    ├── TipByWallet
└── MembershipByBankQr    ├── OrderByBankQr    └── TipByBankQr

Nine classes for three times three. Add a fourth rail and you write three more; add a fourth purchase type and you write four more. Worse, the code inside is duplicated along both axes: every ...ByCard class repeats card handling, and every Membership... class repeats the membership rules. A fix to card handling must be applied three times.

The name gives it away. MembershipByWallet contains two independent ideas, and inheritance can only vary one dimension at a time.

The pattern: two hierarchies and a reference

Split along the two dimensions. The implementation side is the payment rail: the primitive operations that move money.

export interface PaymentRail {
  readonly name: string;
  readonly supportsRecurring: boolean;
  charge(amountVnd: number, reference: string): Promise<{ transactionId: string }>;
  setUpRecurring(customerId: string): Promise<{ mandateId: string }>;
}

class CardRail implements PaymentRail {
  readonly name = "card";
  readonly supportsRecurring = true;
  async charge(amountVnd: number, reference: string) {
    return { transactionId: `card-${reference}-${amountVnd}` };
  }
  async setUpRecurring(customerId: string) {
    return { mandateId: `card-token-${customerId}` };
  }
}

class WalletRail implements PaymentRail {
  readonly name = "wallet";
  readonly supportsRecurring = true;
  async charge(amountVnd: number, reference: string) {
    return { transactionId: `wallet-${reference}-${amountVnd}` };
  }
  async setUpRecurring(customerId: string) {
    return { mandateId: `wallet-link-${customerId}` };
  }
}

class BankQrRail implements PaymentRail {
  readonly name = "bank-qr";
  readonly supportsRecurring = false;
  async charge(amountVnd: number, reference: string) {
    return { transactionId: `qr-${reference}-${amountVnd}` };
  }
  async setUpRecurring(): Promise<never> {
    throw new Error("Bank QR payments cannot be recurring");
  }
}

The abstraction side is the purchase: the business rules for each kind of thing you sell. It holds a rail and builds its high-level operation, checkout, out of the rail's primitives:

export abstract class Purchase {
  constructor(protected readonly rail: PaymentRail) {}
  abstract checkout(customerId: string): Promise<string>;
}

export class Membership extends Purchase {
  constructor(
    rail: PaymentRail,
    private readonly plan: "monthly" | "yearly",
  ) {
    super(rail);
  }

  async checkout(customerId: string) {
    if (!this.rail.supportsRecurring) throw new Error(`${this.rail.name} cannot pay for memberships`);
    const { mandateId } = await this.rail.setUpRecurring(customerId);
    const amount = this.plan === "monthly" ? 59_000 : 590_000;
    const { transactionId } = await this.rail.charge(amount, `member-${customerId}`);
    return `${this.plan} membership via ${this.rail.name}: ${transactionId}, renews with ${mandateId}`;
  }
}

export class OneTimeOrder extends Purchase {
  constructor(
    rail: PaymentRail,
    private readonly items: { price: number; quantity: number }[],
  ) {
    super(rail);
  }

  async checkout(customerId: string) {
    const subtotal = this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const shipping = subtotal >= 500_000 ? 0 : 30_000;
    const { transactionId } = await this.rail.charge(subtotal + shipping, `order-${customerId}`);
    return `order via ${this.rail.name}: ${transactionId}`;
  }
}

export class Tip extends Purchase {
  constructor(
    rail: PaymentRail,
    private readonly amountVnd: number,
  ) {
    super(rail);
  }

  async checkout(customerId: string) {
    if (this.amountVnd < 10_000) throw new RangeError("Minimum tip is 10,000đ");
    const { transactionId } = await this.rail.charge(this.amountVnd, `tip-${customerId}`);
    return `tip via ${this.rail.name}: ${transactionId}`;
  }
}

Any purchase works with any rail, chosen at runtime:

const card = new CardRail();
const wallet = new WalletRail();
const bankQr = new BankQrRail();

console.log(await new Membership(wallet, "monthly").checkout("u1"));
// monthly membership via wallet: wallet-member-u1-59000, renews with wallet-link-u1
console.log(await new OneTimeOrder(bankQr, [{ price: 180_000, quantity: 2 }]).checkout("u2"));
// order via bank-qr: qr-order-u2-390000
console.log(await new Tip(card, 20_000).checkout("u3"));
// tip via card: card-tip-u3-20000

await new Membership(bankQr, "yearly").checkout("u4").catch((e) => console.log(e.message));
// bank-qr cannot pay for memberships

Count the classes: three purchases plus three rails is six, not nine. A fourth rail is one class, and it immediately works with memberships, orders and tips. A fourth purchase type, say gift cards, is one class that works with every rail. Card handling lives in exactly one place, and so do the membership rules.

Notice also how the two sides talk. The abstraction does not call "pay with card". It uses a small vocabulary of primitives (charge, setUpRecurring, supportsRecurring) and composes them into business operations. Designing that vocabulary well is most of the work in a Bridge: too specific and every new rail breaks it, too vague and each purchase has to know what rail it is talking to.

Bridge, Adapter and Strategy

These three get confused because the code looks similar: an object holding a reference to an interface.

Bridge Adapter Strategy
Intent Let two dimensions vary independently Make an existing, incompatible interface fit Swap one algorithm for another
When it is designed Up front, before the combinations explode After the fact, when integrating something you do not control Whenever a single behaviour varies
Hierarchies Two: abstraction and implementation both have subclasses One wrapper per adaptee One family of strategies; the context usually has no subclasses
Typical sign Class names with two nouns (MembershipByWallet) A vendor API with different names and units An if/switch over a type or mode

In practice they combine. The CardRail above would usually be implemented as an adapter around a vendor SDK (chapter 9). The Bridge decides the shape of the rail interface; the adapter fits a vendor to it.

Where you meet it

  • Database drivers. An ORM's query API is the abstraction; the PostgreSQL, MySQL and SQLite drivers are implementations. Both evolve separately.
  • Rendering. A charting library's chart types (line, bar, pie) are abstractions over renderers (SVG, Canvas, WebGL).
  • Notifications. Message types (OTP, marketing, order update) over channels (SMS, email, push, Zalo).
  • Logging. Logger APIs (structured, levelled) over transports.

When to use it, and when not to

Use it when:

  • Two independent dimensions vary, and you can see the cross product of classes coming.
  • You want to pick or switch the implementation at runtime.
  • The two sides are owned by different teams or change at different speeds.

Skip it when:

  • Only one dimension varies. That is a Strategy or a simple interface.
  • The combinations are few and fixed. Two classes are not an explosion.

Common pitfalls

  1. Implementation interfaces that leak the abstraction. If PaymentRail gains chargeMembership() and chargeTip(), the bridge has collapsed back into the cross product.
  2. Capability checks everywhere. supportsRecurring is fine for one capability. If the abstraction checks five flags, the rails are not really interchangeable, and you may need separate interfaces for separate capabilities.
  3. Building it too early. The pattern pays off when both sides really vary. With one rail and one purchase type, it is speculative structure.
  4. Inheritance on both sides when composition would do. The abstraction side often does not need subclasses at all; plain objects or functions that take a rail work just as well.

Exercises

  1. Add a GiftCard purchase that charges the face value plus a 2% fee and cannot be paid by bank QR. Did you touch any rail?
  2. Add an InstallmentRail that splits a charge into three monthly payments. Which purchases should refuse it, and where does that rule live?
  3. Replace the supportsRecurring flag with a separate RecurringRail interface. Which version do you prefer, and why?
  4. Draw the class diagram for a notification system with three message types and four channels, first without Bridge and then with it.