When your system needs to support multiple notification types across different delivery channels, the number of classes explodes exponentially. The bridge pattern solves this by decoupling abstraction from implementation, letting you combine them freely. Bridge pattern: Design Patterns best practices explained teaches us that the most scalable solutions often come from separating what something does from how it's done.
The exponential complexity problem
Imagine building a notification system for an invoicing platform. You need to send invoices and reminders through email and SMS. That seems simple enough — four scenarios, four classes:
- EmailInvoiceNotice
- SmsInvoiceNotice
- EmailReminder
- SmsReminder
Now your product manager adds push notifications. Four more classes. Then Slack integration. Four more. When you add a new notification type — payment confirmations — you're creating four additional classes just for that type.
This is the N × M explosion problem. With N notification types and M delivery channels, you end up with N × M classes. The maintenance burden becomes crushing. Changes to how email works must be replicated across every email class. A new channel forces you to create a class for every existing notification type.
The bridge pattern breaks this dependency chain by introducing a separate dimension for each axis of change.
Two dimensions of change: Abstraction and implementation
The bridge pattern recognizes that your system has two independent reasons to change:
- What you're sending changes: New notification types (invoices, reminders, receipts, alerts)
- How you're sending it changes: New channels (email, SMS, push, webhooks, carrier pigeons)
These are distinct concerns. Your notification abstraction describes what conceptual message you're delivering. Your channel implementation describes the technical mechanism for delivery. Decoupling them lets both evolve independently.
In production systems handling millions of messages, this separation becomes crucial. The team responsible for notification business logic shouldn't need to know about SMTP connection pooling. The infrastructure team optimizing Kafka delivery shouldn't need to modify invoice generation code.
The structure: Bridge connects abstractions to implementations
The bridge pattern introduces two hierarchies:
- Abstraction hierarchy: Notice, Reminder, Invoice, Receipt — describes what you're sending
- Implementation hierarchy: Channel, EmailChannel, SmsChannel, PushChannel — describes how you're sending it
The abstraction holds a reference to an implementation. Instead of inheriting from specific channel implementations, abstractions delegate to them:
interface Channel {
send(message: string): Promise<void>;
getName(): string;
}
class EmailChannel implements Channel {
async send(message: string): Promise<void> {
// SMTP logic, retry policy, templating
console.log(`Sending email: ${message}`);
}
getName(): string {
return 'email';
}
}
class SmsChannel implements Channel {
async send(message: string): Promise<void> {
// Twilio/AWS SNS logic, character limits, encoding
console.log(`Sending SMS: ${message}`);
}
getName(): string {
return 'sms';
}
}
class PushChannel implements Channel {
async send(message: string): Promise<void> {
// Firebase Cloud Messaging logic, device token management
console.log(`Sending push: ${message}`);
}
getName(): string {
return 'push';
}
}
// The abstraction (Notice) holds a reference to implementation (Channel)
abstract class Notice {
protected channel: Channel;
constructor(channel: Channel) {
this.channel = channel;
}
abstract getContent(): string;
async send(): Promise<void> {
const content = this.getContent();
await this.channel.send(content);
}
getDeliveryMethod(): string {
return this.channel.getName();
}
}
class InvoiceNotice extends Notice {
private invoiceId: string;
constructor(invoiceId: string, channel: Channel) {
super(channel);
this.invoiceId = invoiceId;
}
getContent(): string {
return `Your invoice ${this.invoiceId} is ready for payment.`;
}
}
class PaymentReminderNotice extends Notice {
private invoiceId: string;
private daysOverdue: number;
constructor(invoiceId: string, daysOverdue: number, channel: Channel) {
super(channel);
this.invoiceId = invoiceId;
this.daysOverdue = daysOverdue;
}
getContent(): string {
return `Invoice ${this.invoiceId} is ${this.daysOverdue} days overdue.`;
}
}Now composition replaces inheritance. To send an invoice via SMS, you create an InvoiceNotice with an SmsChannel. To send the same invoice via email tomorrow, you create an InvoiceNotice with an EmailChannel. The Notice class doesn't care how delivery happens — it delegates to the Channel.
From N × M to N + M
With the bridge pattern, complexity becomes additive instead of multiplicative:
| Approach | Classes needed |
| Without bridge (inheritance) | N × M (3 notice types × 4 channels = 12 classes) |
| With bridge (composition) | N + M (3 notice types + 4 channels = 7 classes) |
At scale, this difference is dramatic. In a mature system with 8 notification types and 6 channels, you'd need 48 classes without bridge or 14 classes with it. Adding a seventh channel requires 1 new class instead of 8.
Here's how you'd use this in practice:
const emailChannel = new EmailChannel();
const smsChannel = new SmsChannel();
const pushChannel = new PushChannel();
// Send the same invoice through multiple channels
const invoiceNotice = new InvoiceNotice('INV-12345', emailChannel);
await invoiceNotice.send(); // "Sending email: Your invoice INV-12345..."
// Later, send via SMS without creating a new class
const invoiceSms = new InvoiceNotice('INV-12345', smsChannel);
await invoiceSms.send(); // "Sending SMS: Your invoice INV-12345..."
// Add a payment reminder through the new channel
const reminderPush = new PaymentReminderNotice('INV-12345', 7, pushChannel);
await reminderPush.send(); // "Sending push: Invoice INV-12345 is 7 days overdue."The beauty emerges when requirements change. Your team needs to add a Slack channel for internal notifications? You implement SlackChannel once and it immediately works with every Notice type. No modifying InvoiceNotice, PaymentReminderNotice, or anything else.
Real-world scenario: E-commerce order notifications
Consider an e-commerce platform sending order notifications:
- Abstractions: OrderConfirmation, ShipmentNotification, DeliveryAlert, CancelledOrder
- Implementations: EmailChannel, SmsChannel, WebhookChannel (for third-party integrations), InAppChannel
Early in your system, you might have chosen inheritance: OrderConfirmationEmail, OrderConfirmationSms, ShipmentNotificationEmail, ShipmentNotificationSms, etc. It works for a while.
Then analytics shows 40% of your users never open emails — they prefer SMS. So you add SMS to everything. Then international customers need localized templates. Then you add webhook delivery for partner systems. Then push notifications when traffic surges.
With the bridge pattern already in place, these aren't architectural crises — they're one new Channel implementation. The order notification logic remains clean and focused.
class WebhookChannel implements Channel {
private url: string;
constructor(url: string) {
this.url = url;
}
async send(message: string): Promise<void> {
await fetch(this.url, {
method: 'POST',
body: JSON.stringify({ message }),
headers: { 'Content-Type': 'application/json' }
});
}
getName(): string {
return 'webhook';
}
}
// Works immediately with every Notice subclass
const partnerWebhook = new WebhookChannel('https://partner.example.com/orders');
const orderConfirmation = new OrderConfirmation(orderId, partnerWebhook);
await orderConfirmation.send();Common mistakes
Over-bridging for single dimensions. If you only have one axis of variation, the bridge pattern adds unnecessary complexity. If you're only changing notification types but channels are fixed, just use inheritance or a simple strategy pattern. Bridge pays dividends when you genuinely have independent dimensions of change.
Treating the bridge as a catch-all for variability. The pattern specifically addresses variation in howwhat
Making channels too smart. Channel implementations should handle delivery mechanics only — connection pooling, retry logic, rate limiting, protocol specifics. They shouldn't know about invoice amounts or reminder thresholds. That belongs in the Notice abstraction. When channels start accessing business logic, you've violated the separation that makes bridge valuable.
Inadequate channel interfaces. If you discover new delivery requirements only after implementing channels, your interface was incomplete. Think ahead about error handling, metadata, delivery confirmation, and logging requirements that every channel must support. A well-designed Channel interface makes adding implementations straightforward.
Key takeaways
- Bridge pattern solves N × M explosion by separating abstraction (what you're sending) from implementation (how you're sending it), reducing class count from multiplicative to additive.
- Use composition over inheritance: Abstractions hold references to implementations rather than inheriting from them, allowing free combination of types and channels.
- Independent evolution: Teams can add new notification types or delivery channels without modifying existing code, reducing integration risk and merge conflicts.
- Recognizes genuine dimensions of change: Bridge is powerful when you have two or more independent axes of variation. Don't over-apply it to simple problems.
- Interface clarity is crucial: Invest time designing the Channel interface comprehensively. Adding new implementations should be obvious and low-risk.
- Maintains single responsibility: Abstractions handle business logic and message content; implementations handle delivery mechanics. The separation makes code easier to test and reason about.






Comments
Loading comments…