We pull the grid apart. "What we say" becomes one hierarchy. "How we send it" becomes another. A single field connects them.
Side 1: the channel (implementation)
interface MessageSender {
void send(String to, String text);
}
class SmsSender implements MessageSender {
public void send(String to, String text) {
System.out.println("[SMS via gateway] to " + to + ": " + text);
}
}EmailSender and WhatsAppSender look the same. None of them know what an OTP or a promo is. They only deliver a string.
Side 2: the message (abstraction)
abstract class Notification {
protected final MessageSender sender; // this is the bridge
protected Notification(MessageSender sender) {
this.sender = sender;
}
abstract String text();
void sendTo(String to) {
sender.send(to, text());
}
}
class OtpNotification extends Notification {
...
String text() {
return "Your Swiggy OTP is " + otp + ". Valid for 5 minutes.";
}
}Each message type only decides its text. sendTo hands the delivery to whatever sender was passed in, through the sender field. The ... in OtpNotification stands for its otp field and constructor, which the full code has.
Using it
MessageSender sms = new SmsSender();
MessageSender whatsApp = new WhatsAppSender();
new OtpNotification(sms, "482913").sendTo("+91 98450 12345");
new OtpNotification(whatsApp, "771204").sendTo("+91 98450 12345");Same OTP class, two channels. The SMS outage fallback from the Before section is now a different argument, not a different class. The channel is chosen when the object is built.
Adding a channel
class PushSender implements MessageSender {
public void send(String to, String text) {
System.out.println("[Push via FCM] to " + to + ": " + text);
}
}One class. OTP, Promo and Order update all work with it straight away. No existing code was edited.
Before vs after
| Before | After | |
|---|---|---|
| Concrete classes for 3 messages x 3 channels | 9 | 6 (3 + 3) |
| Concrete classes after adding Push | 12 | 7 |
| Where the OTP text lives | 3 files | OtpNotification only |
| Where the SMS code lives | 3 files | SmsSender only |
| Changing channel at runtime | Pick a different class | Pass a different sender |
| Who edits what | Growth and platform teams in the same files | Growth owns messages, platform owns senders |
What the run prints
[SMS via gateway] to +91 98450 12345: Your Swiggy OTP is 482913. Valid for 5 minutes.
[Email via SMTP] to priya@example.com: Flat 50% off up to Rs 100 on your next order. Code: TRYNEW
[WhatsApp via Business API] to +91 98450 12345: Order #SW1043 from Meghana Foods is out for delivery.
[WhatsApp via Business API] to +91 98450 12345: Your Swiggy OTP is 771204. Valid for 5 minutes.
[Push via FCM] to device-8812: Order #SW1044 from Truffles is out for delivery.
[Push via FCM] to device-8812: Flat 50% off up to Rs 100 on your next order. Code: TRYNEW
Things to keep in mind
- Keep
MessageSendersmall. It should only have what every channel can do. Think twice before addingsendWithSubjectfor email, because every other channel would then have to fake it. - The senders keep no data between messages, so create them once and share them (in Spring they would just be beans, objects the framework creates and shares for you).
- If a sender wraps a vendor SDK with a different API (MSG91, Gupshup, SES), that wrapper is an Adapter. Adapter and Bridge work well together.
Run this version
Run these commands from the repository root:
mkdir -p out
javac -d out src/DesignPatterns/StructuralPatterns/BridgeDesignPattern/After/Main.java
java -cp out DesignPatterns.StructuralPatterns.BridgeDesignPattern.After.MainThe teaching example keeps all interfaces and classes in Main.java, but think of them as separate parts with separate jobs. The file count does not matter. What matters is that either axis can gain a class without multiplying or editing the other axis.