Bridge

Part of the free Low-Level Design course on LogicWiz, in the chapter: Structural patterns. The class-diagram exercises run in the app.

Bridge: message kinds and channels that vary on their own

"We have OrderPlacedSms, OrderPlacedPush, OrderDelayedSms and six more. Marketing wants WhatsApp. How many new classes is that, Arjun?"

Priya, reading the notifications folder


The problem: every kind in every channel

SwiftKart sends three kinds of message to customers:

  • order placed: "Order A12 arrives in 10 minutes";
  • order delayed: "Order A12 is 12 minutes late";
  • promo: an offer.

Each kind can go out on three channels. A channel is a way of reaching the customer: an SMS, a push notification (a message the app shows on the phone) or an email. Arjun made one class for every pair, so there are nine classes.

public class OrderPlacedSms extends Notification {
    // fields and constructor omitted
    @Override
    public void send(String phone) {
        String text = "SwiftKart: order " + orderId + " is placed. Arriving in 10 minutes.";
        if (text.length() > 160) text = text.substring(0, 160); // the SMS limit
        System.out.println("SMS to " + phone + ": " + text);
    }
}

public class OrderDelayedSms extends Notification {
    // fields and constructor omitted
    @Override
    public void send(String phone) {
        String text = "SwiftKart: order " + orderId + " is " + minutes + " minutes late. Sorry!";
        if (text.length() > 140) text = text.substring(0, 140); // a different limit
        System.out.println("SMS to " + phone + ": " + text);
    }
}

Count what this costs:

  • The classes multiply. Three kinds times three channels is 9. WhatsApp adds 3 more, so 12. A new kind, "refund processed", adds 4 more. With 5 kinds and 4 channels you maintain 20 classes.
  • Channel rules are copied. The SMS limit is written in every SMS class. One says 160 and another says 140, because someone copied a class and edited it.
  • One change touches many files. If the SMS provider changes, Arjun opens every SMS class.

Arjun said, "I will keep one Notification class with a channel field and a switch in send." Priya answered, "That puts the nine classes into one class, three times over. Each new channel edits every kind's code." The switch hides the problem and does not remove it.

Look at what varies. A new kind of message does not change how SMS works. A new channel does not change what "order delayed" means. The two things change on their own, but the class tree forces every pair to be written out.

The idea: write the letter, then choose the courier

Think of a letter. Writing it and sending it are two jobs. You can write one wedding invitation and send it by post, by courier or by hand. A new courier company does not make you rewrite your letters. A new kind of letter does not need a new courier.

In code, split the class into two families and connect them with a reference. One family is the kinds of message. The other is the channels. A kind holds a channel and hands the finished text to it. This is the bridge pattern: split a class that varies in two independent ways into two hierarchies, joined by composition (one object holding a reference to the other).

Why composition and not more inheritance? A Java class has only one parent. Inheritance can capture one of the two dimensions: either the kind or the channel. The second dimension needs a field that holds an object.

The pattern has two role names. The abstraction is the side the caller uses, here Notification and its kinds. The implementor is the side that does the low-level work, here Channel and its channels. Both words come from the original pattern book, and they mean something different from the "abstraction" you met in the object-oriented basics. The abstraction here is not a hidden detail. It is the high-level side. The implementor is not about the Java word implements. It is the low-level side, and in Java it is usually an interface.

The letter picture stops in one place. A courier does not change what your letter says, but a channel does. An SMS cannot show a picture, and it holds 160 characters. So the channel interface must be the right size: what every channel can do. Here that is "deliver a title and a body". Whatever does not fit through that door gets hard.

The structure

classDiagram
  class Notification {
    <<abstract>>
    -Channel channel
    #title() String
    #body() String
    +send(to)
  }
  class Channel {
    <<interface>>
    +deliver(to, title, body)
  }
  Notification <|-- OrderPlaced
  Notification <|-- OrderDelayed
  Notification <|-- Promo
  Notification --> Channel : bridge
  Channel <|.. SmsChannel
  Channel <|.. PushChannel
  Channel <|.. EmailChannel

The roles:

  • Abstraction: Notification. It holds a Channel and offers send to the caller.
  • Refined abstraction: one kind of message, such as OrderDelayed. It extends the abstraction.
  • Implementor: the Channel interface.
  • Concrete implementor: SmsChannel, PushChannel, EmailChannel.

The arrow labelled "bridge" is the whole pattern. Kinds and channels never meet except through that one reference.

Build it step by step

Step 1: the implementor. A channel receives plain data. It does not know which kind of message it is carrying, so the dependency runs one way only.

public interface Channel {
    void deliver(String to, String title, String body);
}

public class SmsChannel implements Channel {
    private static final int LIMIT = 160;

    @Override
    public void deliver(String to, String title, String body) {
        String text = title + ": " + body;
        if (text.length() > LIMIT) {
            text = text.substring(0, LIMIT);
        }
        System.out.println("SMS to " + to + " (" + text.length() + " chars): " + text);
    }
}

The SMS limit now lives in one place. PushChannel and EmailChannel are the same few lines with their own formatting.

Step 2: the abstraction and its kinds. Notification holds the channel. Each kind only says what its title and body are.

public abstract class Notification {
    private final Channel channel; // the bridge

    protected Notification(Channel channel) { this.channel = channel; }

    protected abstract String title();
    protected abstract String body();

    public void send(String to) {
        channel.deliver(to, title(), body());
    }
}

public class OrderDelayed extends Notification {
    // fields orderId and minutes; the constructor passes the channel to super
    @Override protected String title() { return "Order delayed"; }
    @Override protected String body() { return "Order " + orderId + " is " + minutes + " minutes late. Sorry!"; }
}

Follow one call. send asks the kind for its title and body, then passes both to the channel, which formats and sends them. After the object is built, the caller only calls send.

OrderDelayed knows nothing about SMS. SmsChannel knows nothing about delays. Promo is another small class like OrderDelayed.

Step 3: put them together while the app runs.

Channel sms = new SmsChannel();
Channel push = new PushChannel();

new OrderPlaced("A12", sms).send("98450-00000");
// SMS to 98450-00000 (46 chars): Order placed: Order A12 arrives in 10 minutes.
new OrderDelayed("A12", 12, push).send("device-7");
// PUSH to device-7 [Order delayed] Order A12 is 12 minutes late. Sorry!

Channel chosen = preferred.get("user-7"); // the customer's own choice, read at runtime
new OrderDelayed("A12", 12, chosen).send("98450-00000");

Testing gets easier too. To test OrderDelayed, pass it a fake channel that stores what it receives. No SMS is sent, and you check the title and body directly.

A 213-character promo sent through SmsChannel came out at exactly 160 characters, because the limit is applied in the one place that owns it. Now add WhatsApp:

public class WhatsAppChannel implements Channel {
    @Override
    public void deliver(String to, String title, String body) {
        System.out.println("WHATSAPP to " + to + ": *" + title + "* " + body);
    }
}
// no other file changes: every kind can now go out on WhatsApp

The count is now three kinds plus four channels, which is 7 classes. The old design needed 12. With 5 kinds and 4 channels the bridge needs 5 + 4 = 9 classes, where the old design needed 5 × 4 = 20. Multiplying turns into adding.

Bridge, Strategy and Adapter

Two more patterns look like the bridge. A strategy puts one rule, such as how a delivery fee is calculated, behind an interface so it can be swapped. An adapter wraps a class whose interface does not fit. Draw a bridge and a strategy, and the pictures match: a class holds a reference to an interface. The difference is intent. In a mock interview, a candidate said, "Bridge is strategy with a longer name." The pictures are close, but the problems are different. A strategy swaps one rule inside one class. A bridge keeps two families of classes from multiplying.

Bridge Strategy Adapter
Problem Two things vary independently, and a subclass per pair explodes One rule inside a class must be swappable An existing class has the wrong interface
When it is made Up front, before the class tree grows When a rule starts to change Later, to fit code you cannot change
The holder Has its own subclasses (OrderPlaced, Promo) Usually one class (Checkout) Wraps one object and translates calls
The held object A platform or backend (a channel) One algorithm (a fee rule) The mismatched class (a vendor SDK)

Two questions tell them apart. Does the holder have subclasses of its own that vary on their own? If yes, it is a bridge. Did you control both sides from the start? If no, it is an adapter. The two patterns can work together: a vendor email SDK is wrapped by an adapter, and that adapter is one of the channels.

Use a bridge when:

  • two dimensions vary on their own and both are likely to grow;
  • you want kinds written once, without platform details inside them.

Do not use it when:

  • only one dimension varies. Channels with fixed messages are an interface and a few classes;
  • the second dimension has one implementation and no second in sight. A bridge with one channel is an interface you do not need yet;
  • the dimensions are not independent. If a promo needs an image on push and nothing on SMS, half the pairs need special code, and the bridge hides little. You may be better off with the subclasses, or with a method on the channel that says what it supports.

The costs are two hierarchies to learn, one more step when you debug, and a design decision that is hard to undo. Getting the implementor interface right is the hard part. Too wide, and every channel must implement things it cannot do. Too narrow, and the kinds cannot say what they need.

In interviews, bridges appear as shapes drawn by different renderers, remote controls working different devices, and loggers with several outputs. A report with several export formats is another.

Try it: split the notification classes

The starter has a subclass for each pair of kind and channel. Remove them. Add a channel interface that the notification holds, and keep the kinds as subclasses of the notification.

📌 Summary: A bridge splits two independent dimensions into two hierarchies joined by one reference, so classes add instead of multiply. Use it only when both dimensions really grow. Intent separates it from strategy and adapter.

What Arjun learns next

The notifications are tidy, but the live map of riders runs out of memory. It holds 20,000 markers that each carry their own copy of the same three icons. Next: the flyweight pattern.

Check your understanding

  1. SwiftKart has 5 kinds of message and 4 channels. How many classes does each design need for them?
  2. `Checkout` holds a `DeliveryFeeRule` interface and has no subclasses of its own. A teammate calls this a bridge. What fits better?
  3. SwiftKart sends only SMS today, and someone might add push one day. A teammate wants a Channel interface and a Notification hierarchy now. What is the best advice?
  4. The email vendor ships a class with `sendMail(from, to, subject, html)`. You write an `EmailChannel` that calls it. Which pattern does that wrapping show?