The Bridge pattern: separating what you send from how you send it
The Bridge pattern is explained in most textbooks with shapes and rendering APIs. A Shape hierarchy and a Renderer hierarchy, bridged together. The examples are correct and entirely useless as design guidance because nobody's production system is drawing shapes.
The pattern solves a specific, recognisable problem: you have two independently variable dimensions, and you need to combine them without creating a class for every combination. I have seen this problem most often in notification systems, and that is the example this article uses.
The problem: M × N classes
You have a notification system. It sends notifications. It sends them via three channels: Email, SMS, and Slack. It sends four types of notifications: PaymentConfirmation, LowInventoryAlert, AccountSuspended, and WeeklyReport.
Without a structure:
PaymentConfirmationEmail
PaymentConfirmationSMS
PaymentConfirmationSlack
LowInventoryAlertEmail
LowInventoryAlertSMS
LowInventoryAlertSlack
AccountSuspendedEmail
AccountSuspendedSMS
AccountSuspendedSlack
WeeklyReportEmail
WeeklyReportSMS
WeeklyReportSlack
12 classes. Add a fourth channel (push notifications): 16 classes. Add a fifth notification type (PasswordReset): 20 classes. The structure scales as M × N, and each class contains the logic for one type of notification formatted for one channel.
The Bridge extracted
The Bridge separates the two hierarchies and connects them through composition rather than inheritance:
// The "implementor" side: how to send
interface NotificationChannel
{
public function send(string $recipient, string $subject, string $body): void;
}
final class EmailChannel implements NotificationChannel
{
public function __construct(private readonly Mailer $mailer) {}
public function send(string $recipient, string $subject, string $body): void
{
$this->mailer->send(
to: $recipient,
subject: $subject,
html: $body,
);
}
}
final class SlackChannel implements NotificationChannel
{
public function __construct(private readonly SlackClient $slack) {}
public function send(string $recipient, string $subject, string $body): void
{
// Slack does not have a subject — prepend it to the body
$this->slack->postMessage(
channel: $recipient,
text: "*{$subject}*\n{$body}",
);
}
}
final class SmsChannel implements NotificationChannel
{
public function __construct(private readonly SmsProvider $sms) {}
public function send(string $recipient, string $subject, string $body): void
{
// SMS is length-constrained — truncate body to 160 chars
$text = "{$subject}: " . substr(strip_tags($body), 0, 140);
$this->sms->send(phone: $recipient, message: $text);
}
}
// The "abstraction" side: what to send
abstract class Notification
{
public function __construct(
protected readonly NotificationChannel $channel,
) {}
abstract public function send(string $recipient): void;
}
final class PaymentConfirmationNotification extends Notification
{
public function __construct(
NotificationChannel $channel,
private readonly Payment $payment,
) {
parent::__construct($channel);
}
public function send(string $recipient): void
{
$this->channel->send(
recipient: $recipient,
subject: "Payment confirmed — {$this->payment->reference}",
body: $this->renderBody(),
);
}
private function renderBody(): string
{
return sprintf(
"Your payment of %s %s has been confirmed.\nReference: %s\nDate: %s",
number_format($this->payment->amount / 100, 2),
$this->payment->currency,
$this->payment->reference,
$this->payment->created_at->format('Y-m-d H:i'),
);
}
}
Now the structure is M + N: 4 notification classes plus 3 channel classes. Adding a fourth channel (PushNotification) is one new class. Adding a fifth notification type (PasswordReset) is one new class. Nothing else changes.
The composition point
The Bridge happens at construction: you compose a notification type with a channel:
// Wired by the application layer — typically a notification dispatcher
$notification = new PaymentConfirmationNotification(
channel: new EmailChannel($mailer),
payment: $payment,
);
$notification->send(recipient: $user->email);
// Same notification type, different channel
$notification = new PaymentConfirmationNotification(
channel: new SmsChannel($smsProvider),
payment: $payment,
);
$notification->send(recipient: $user->phone);
In practice, this composition is handled by a NotificationDispatcher that looks up which channels a given user has opted into:
final class NotificationDispatcher
{
/** @param NotificationChannel[] $channels */
public function __construct(private readonly array $channels) {}
public function dispatch(string $notificationType, array $payload, User $user): void
{
foreach ($user->enabledChannels() as $channelName) {
$channel = $this->channels[$channelName] ?? throw new UnknownChannelException($channelName);
$notification = $this->buildNotification($notificationType, $channel, $payload);
$notification->send($user->contactFor($channelName));
}
}
}
Where the abstraction starts leaking
The Bridge works cleanly when the two dimensions are genuinely independent. They stop being independent when a notification type has content that only makes sense on one channel, a detailed HTML report for email, with no meaningful SMS equivalent.
// This is the first sign of a leaking abstraction
final class WeeklyReportNotification extends Notification
{
public function send(string $recipient): void
{
if ($this->channel instanceof SmsChannel) {
// What do we send? A summary? A link? Nothing?
$this->channel->send($recipient, 'Weekly report', 'See your email for the full report.');
return;
}
$this->channel->send($recipient, 'Weekly Report', $this->renderFullHtmlReport());
}
}
The instanceof check is the Bridge pattern breaking down. The WeeklyReport notification is not channel-agnostic. It has different behaviour per channel, which means the abstraction no longer holds.
The honest fix is to not force the pattern. Some notifications have channel-specific implementations. Create WeeklyReportEmailNotification and WeeklyReportSmsSummaryNotification as separate classes. Forcing M + N where the problem is genuinely M × N produces worse code than just writing the M × N classes clearly.
Testing the pattern
The value of the Bridge for testing is that each dimension is independently testable:
// Test the channel independently
class EmailChannelTest extends TestCase
{
public function testSendsDelegatesCorrectlyToMailer(): void
{
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
->method('send')
->with(to: '[email protected]', subject: 'Test', html: '<p>Body</p>');
(new EmailChannel($mailer))->send('[email protected]', 'Test', '<p>Body</p>');
}
}
// Test the notification independently using a mock channel
class PaymentConfirmationNotificationTest extends TestCase
{
public function testSendsCorrectSubjectAndBody(): void
{
$channel = $this->createMock(NotificationChannel::class);
$channel->expects($this->once())
->method('send')
->with(
recipient: '[email protected]',
subject: $this->stringContains('PAY-2024-001'),
body: $this->stringContains('100.00'),
);
$payment = new Payment(id: 1, reference: 'PAY-2024-001', amount: 10000, currency: 'PLN', ...);
(new PaymentConfirmationNotification($channel, $payment))->send('[email protected]');
}
}
Twelve notification–channel combinations, zero tests that spin up a real mailer or Slack API. Each test is fast, isolated, and covers exactly one unit of behaviour.
When to reach for Bridge
One clear signal: you are about to write a class whose name is two concepts joined together, PaymentEmailNotification, PdfReportExporter, CsvLogFormatter. The name itself tells you that two independent dimensions have been fused into a single class. That is the moment to ask whether they could be separated and composed instead.