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.
| Part | In this lab | Job |
|---|---|---|
| Abstraction | OrderNotifier | When and how the notice is worded. Holds a MessageSender. |
| Implementor | MessageSender | Deliver an already-worded payload. Does not format receipts. |
| Refined abstraction | EmailNotifier, SmsNotifier | Channel-specific wording. |
| Concrete implementor | SendGridSender, TwilioSender | One 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.
| Adapter | Bridge | |
|---|---|---|
| When | A type you will not change does not match an interface you already have | Two axes of variation would otherwise subclass each other |
| Holds | The foreign object (PayPalClient) | The implementor (MessageSender) |
| Callers already knew | PaymentGateway.charge | OrderNotifier.notifyPaid and you still need a delivery seam |
| New pairing | New adapter for a new SDK | New 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.
EmailNotifiercan holdSendGridClient— orprocesscan call aMailerof one. AMessageSenderwith a singleSendGridSenderis 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.sendHtmlvsnotifyPaid(Order, PaymentResult)is Adapter. Do not inventOrderNotifierplusMessageSenderplusSendGridSenderso the slide says Bridge. - The refined abstractions add no wording.
EmailNotifierandSmsNotifierthat both passorder.toString()intodeliverare 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 SendGridSenderputs 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), notNotificationBridge. - Put SDK types, API keys, and MIME inside the sender. Put templates inside the notifier.
- Test wording with a recording
MessageSender; testprocesswith a fake notifier. - Reuse Adapter inside a sender when the SDK method does not match
deliver.
Don’t:
- Create
EmailSendGridNotifierandSmsTwilioNotifieras the plan for a third pairing. - Let
EmailNotifierimportSendGridClient“just this once.” - Introduce
MessageSenderfor 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.