The ticket says “keep receipts on SendGrid, add SMS through Twilio, and do not copy the paid-order mailer again.” You already have EmailSendGridNotifier. The fastest path is SmsTwilioNotifier, then PushFirebaseNotifier, then EmailMailgunNotifier when marketing switches ESP. Each class repeats “format a paid-order message” and welds it to one SDK. Channel times vendor is now a grid, and OrderProcessor still should not name Twilio.

That is Bridge’s entire complaint. From the Design Patterns Roadmap: an abstraction and its implementations should vary independently — not as one subclass per pairing.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We split how a notice is worded from which vendor delivers it, because those two axes were multiplying.

The notifier grid that multiplies

Here is email-on-SendGrid after the first SMS ticket landed as a sibling class. Nobody wrote this badly on purpose:

public final class EmailSendGridNotifier {

    private final SendGridClient sendGrid;

    public EmailSendGridNotifier(SendGridClient sendGrid) {
        this.sendGrid = sendGrid;
    }

    public void notifyPaid(Order order, PaymentResult result) {
        sendGrid.sendHtml(
                order.customerEmail(),
                "Order " + order.id() + " paid",
                "<p>Ref " + result.reference() + "</p>");
    }
}

public final class SmsTwilioNotifier {

    private final TwilioClient twilio;

    public SmsTwilioNotifier(TwilioClient twilio) {
        this.twilio = twilio;
    }

    public void notifyPaid(Order order, PaymentResult result) {
        twilio.sendSms(order.customerPhone(), "Paid " + order.id() + " ref " + result.reference());
    }
}

Now price the next two pairings:

Email through Mailgun            -> EmailMailgunNotifier, copy the HTML template
SMS through Nexmo                -> SmsNexmoNotifier, copy the 160-char template
Push through Firebase            -> PushFirebaseNotifier, third copy of "order paid"
Swap SendGrid without touching SMS -> hope nobody shared a helper that imported both SDKs

Four costs, and none of them are about charging a card. Every channel×vendor pair has become its own type.

Note: Two notifiers that each wrap one SDK can be Adapter. The problem here is the product of two hierarchies: wording (email / SMS / push) and delivery (SendGrid / Twilio / Firebase). Adapter translates one mismatch. It does not stop the next pairing from cloning the last class.

What Bridge actually is

Four names, one promise: the high-level type holds the low-level type instead of subclassing it.

PartIn this labJob
AbstractionOrderNotifierWhen and how the notice is worded. Holds a MessageSender.
ImplementorMessageSenderDeliver an already-worded payload. Does not format receipts.
Refined abstractionEmailNotifier, SmsNotifierChannel-specific wording.
Concrete implementorSendGridSender, TwilioSenderOne vendor SDK.

The abstraction depends on the implementor interface. A channel class never imports an SDK. If EmailNotifier names SendGridClient, you did not bridge; you hid a subclass inside a field.

You do not need a class named Bridge. You need a verb the paid-order path already understands, plus a second verb the vendors already understand:

public abstract class OrderNotifier {

    protected final MessageSender sender;

    protected OrderNotifier(MessageSender sender) {
        this.sender = sender;
    }

    public abstract void notifyPaid(Order order, PaymentResult result);
}

public interface MessageSender {

    void deliver(String destination, String body);
}

That is the seam. Email vs SMS is a wording choice. SendGrid vs Twilio is a delivery choice. They compose; they do not subclass each other.

Vendors stay behind deliver

Each SDK becomes a MessageSender. Currency, MIME, and Twilio status codes live here because they are the vendor’s problem, not the receipt’s:

public final class SendGridSender implements MessageSender {

    private final SendGridClient client;

    public SendGridSender(SendGridClient client) {
        this.client = client;
    }

    @Override
    public void deliver(String destination, String body) {
        client.sendHtml(destination, "Checkout", body);
    }
}

public final class TwilioSender implements MessageSender {

    private final TwilioClient client;

    public TwilioSender(TwilioClient client) {
        this.client = client;
    }

    @Override
    public void deliver(String destination, String body) {
        client.sendSms(destination, body);
    }
}

SendGridSender may be a thin Adapter around SendGridClient. That is allowed. Adapter is how one foreign type meets deliver. Bridge is why EmailNotifier never meets SendGridClient.

Channels stay behind notifyPaid

EmailNotifier and SmsNotifier format. They call sender.deliver. They do not construct a client:

public final class EmailNotifier extends OrderNotifier {

    public EmailNotifier(MessageSender sender) {
        super(sender);
    }

    @Override
    public void notifyPaid(Order order, PaymentResult result) {
        String body = "<p>Order " + order.id() + " paid. Ref " + result.reference() + "</p>";
        sender.deliver(order.customerEmail(), body);
    }
}

public final class SmsNotifier extends OrderNotifier {

    public SmsNotifier(MessageSender sender) {
        super(sender);
    }

    @Override
    public void notifyPaid(Order order, PaymentResult result) {
        String body = "Paid " + order.id() + " ref " + result.reference();
        sender.deliver(order.customerPhone(), body);
    }
}

OrderProcessor still does not know SendGrid. After the charge it talks to the abstraction it already had — or to Observer’s OrderEvents, with one listener that is an OrderNotifier. Wiring happens at the edge:

OrderNotifier email = new EmailNotifier(new SendGridSender(sendGrid));
OrderNotifier sms = new SmsNotifier(new TwilioSender(twilio));
OrderProcessor processor = new OrderProcessor(gateway, discount, orders, email);

Email through Mailgun is new EmailNotifier(new MailgunSender(mailgun)). No EmailMailgunNotifier. SMS through SendGrid, if the product actually wants that pairing, is the same SmsNotifier with a different sender. n channels plus m vendors, not n times m classes.

Note: Keep both sides dumb. The moment TwilioSender starts applying DiscountPolicy, or EmailNotifier starts charging a card, you have rebuilt OrderProcessor in the mailer. Word. Deliver. Stop.

What the diff looks like now

Same feature request, both designs:

Before — add Mailgun email plus Nexmo SMS
  A EmailMailgunNotifier.java   copy of EmailSendGridNotifier with a new SDK
  A SmsNexmoNotifier.java       copy of SmsTwilioNotifier with a new SDK
  M OrderProcessor.java         maybe a new constructor argument per pairing

After — add MailgunSender plus NexmoSender
  A MailgunSender.java          deliver() only
  A NexmoSender.java            deliver() only
  M NotificationConfig.java     two lines of wiring

EmailNotifier and SmsNotifier do not change. OrderProcessor does not change. Adding push is a third wording class, not a third grid of vendor subclasses.

Proving the split without sending mail

The seam pays a second dividend: wording tests never construct an SDK, and sender tests never format HTML.

final class RecordingSender implements MessageSender {

    String lastTo;
    String lastBody;

    @Override
    public void deliver(String destination, String body) {
        lastTo = destination;
        lastBody = body;
    }
}

An EmailNotifier test asserts the receipt contains the order id and went to the customer — no SendGrid, no processor:

@Test
void paidEmailNamesOrderAndCustomer() {
    RecordingSender sender = new RecordingSender();
    OrderNotifier notifier = new EmailNotifier(sender);
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));

    notifier.notifyPaid(order, PaymentResult.approved("ref-9"));

    assertEquals("a@b.com", sender.lastTo);
    assertTrue(sender.lastBody.contains("o-1"));
    assertTrue(sender.lastBody.contains("ref-9"));
}

A declined-charge test still uses a fake PaymentGateway from the Strategy post. It should not construct TwilioClient. If “SMS body has the reference” needs a live Twilio session, you have mixed two reasons to change.

Bridge is not Adapter

Both sit next to a vendor. Review comments that say “this is just Adapter” after you introduced MessageSender are naming the SDK wrap, not the two hierarchies.

AdapterBridge
WhenA type you will not change does not match an interface you already haveTwo axes of variation would otherwise subclass each other
HoldsThe foreign object (PayPalClient)The implementor (MessageSender)
Callers already knewPaymentGateway.chargeOrderNotifier.notifyPaid and you still need a delivery seam
New pairingNew adapter for a new SDKNew sender or new channel — not both

PayPalGateway from the Adapter post is one foreign payment shape behind charge. There is no second hierarchy called “word the receipt.” If you only ever have EmailSendGridNotifier and will not add a second ESP or a second channel, Adapter (or a single class) is the honest wrap.

You can adapt inside a concrete implementor: TwilioSender translates TwilioClient.sendSms into deliver. That nested Adapter does not make the outer design Adapter. If there is only one implementor and no second on the roadmap, you do not have a Bridge.

When Bridge is the wrong move

Wave 3 is easy to over-apply. Skip the two hierarchies when:

  • There is one implementor forever. SendGrid is the mailer. There is no Twilio ticket, no Mailgun eval, no second ESP in the contract. EmailNotifier can hold SendGridClient — or process can call a Mailer of one. A MessageSender with a single SendGridSender is two types and a constructor argument that never varies.
  • The two axes do not actually vary independently. Email is always SendGrid. SMS is always Twilio. Push is always Firebase. That is three adapters (or three small classes), not a matrix. Bridge pays for remixing — email through Mailgun without rewriting EmailNotifier.
  • You are adapting one SDK. SendGridClient.sendHtml vs notifyPaid(Order, PaymentResult) is Adapter. Do not invent OrderNotifier plus MessageSender plus SendGridSender so the slide says Bridge.
  • The refined abstractions add no wording. EmailNotifier and SmsNotifier that both pass order.toString() into deliver are not two abstractions. They are one method and a destination field. Wait until the payloads actually differ.
  • You subclassed the implementor to change the abstraction. SendGridEmailNotifier extends SendGridSender puts wording back on the vendor type. The arrow is wrong: the notifier has a sender; it does not is-a sender.

The healthy trigger is a second channel and a second vendor, or a ticket that remixes an existing pair. Email vs SMS vs push are three wordings. SendGrid vs Twilio vs Mailgun are three deliveries. Email-on-SendGrid as the only pairing is a class, not a pattern.

Bridge also is not Strategy. Strategy swaps one algorithm the context already holds (DiscountPolicy). Bridge splits two hierarchies that were fused by subclassing. If you only need “swap the mailer,” a Mailer interface is Strategy (or Adapter). If you need “swap the mailer and swap the wording without a subclass per pair,” that is this post.

Cheat sheet

Abstraction   OrderNotifier            wording; holds MessageSender; notifyPaid
Implementor   MessageSender.deliver    vendor delivery; no HTML / SMS templates
Refined       EmailNotifier, SmsNotifier   one class per channel, not per vendor
Concrete      SendGridSender, TwilioSender one class per SDK, not per channel

Trigger to apply: channel and vendor both vary, or a remix of an existing pair
Trigger to stop:  one vendor forever, or each channel welded to one SDK
Scoreboard:       new vendor = new sender; new channel = new notifier; no grid
Not Adapter:      two hierarchies compose vs one foreign type is translated

Do:

  • Name the abstraction after the verb checkout already uses (notifyPaid), not NotificationBridge.
  • Put SDK types, API keys, and MIME inside the sender. Put templates inside the notifier.
  • Test wording with a recording MessageSender; test process with a fake notifier.
  • Reuse Adapter inside a sender when the SDK method does not match deliver.

Don’t:

  • Create EmailSendGridNotifier and SmsTwilioNotifier as the plan for a third pairing.
  • Let EmailNotifier import SendGridClient “just this once.”
  • Introduce MessageSender for a single SendGrid class that will not gain a neighbor.
  • Call a lone Adapter a Bridge because both wrap vendors.

Wrap-up

Bridge is an abstraction that holds an implementor so two axes can change without a subclass per pairing. The paid-order notifiers were expensive because every channel×vendor copy owned both the receipt text and the SDK. OrderNotifier plus MessageSender makes Mailgun a new sender, push a new wording class, and leaves OrderProcessor on notifyPaid — which was never SendGrid’s verb to subclass.

Wave 3 is the set that is easy to over-apply. One ESP and one channel stay one class.

Next optional step in the series Share SKU metadata across cart rows instead of copying a PNG path forty thousand times. Flyweight: Share What Does Not Change