php
article··updated ·6 min read

The Bridge pattern: separating what you send from how you send it

#php #design-patterns #bridge #architecture #notifications #abstraction

Bridge splits a class hierarchy along two axes that change independently and connects them through composition. Textbooks usually show it with shapes and renderers, and GoF with windows and windowing systems. A notification system is closer to everyday work, since it has exactly two axes: what the message says and how it gets delivered.

The problem: M × N classes

Take a system that sends four notification types (payment confirmation, low inventory alert, account suspension, weekly report) through three channels: email, SMS and Slack. If each combination is its own class, you end up with:

PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack
LowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack
AccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack
WeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack

Twelve classes. Add a fourth channel and that's four more; a fifth notification type adds three. The count is M × N, and every class duplicates the content logic of its type as well as the delivery logic of its channel, so a change in how SMS messages get shortened has to be made in four places.

Each of those classes makes two decisions: what to say and how to deliver it. Bridge splits them into two hierarchies.

Channels on one side, messages on the other

The implementor side describes how a message is delivered:

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($recipient, $subject, $body);
    }
}

final class SlackChannel implements NotificationChannel
{
    public function __construct(private readonly SlackClient $slack) {}

    public function send(string $recipient, string $subject, string $body): void
    {
        // Slack has no subject line, so it goes into the message as bold text
        $this->slack->postMessage($recipient, "*{$subject}*\n{$body}");
    }
}

final class SmsChannel implements NotificationChannel
{
    public function __construct(
        private readonly SmsProvider $sms,
        private readonly int $maxLength = 70,
    ) {}

    public function send(string $recipient, string $subject, string $body): void
    {
        $text = "{$subject}: " . strip_tags($body);
        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));
    }
}

Mailer, SlackClient and SmsProvider stand for whatever clients the application already has. The SMS limit is a parameter on purpose. One segment holds 160 characters in the GSM-7 alphabet, but a single character outside it, Polish diacritics for instance, switches the whole message to UCS-2, where a segment holds only 70. That's also why it's mb_substr: plain substr counts bytes and can cut a multibyte character in half. mb_substr counts characters while UCS-2 counts 16-bit units, so an emoji takes two of the 70. Characters from the GSM-7 extension table, like € or [, count double as well.

The abstraction side decides what to send and hands delivery over to a channel:

final readonly class Payment
{
    public function __construct(
        public string $reference,
        public int $amountMinor,
        public string $currency,
        public DateTimeImmutable $paidAt,
    ) {}
}

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,
            "Payment confirmed: {$this->payment->reference}",
            sprintf(
                "Your payment of %s %s has been confirmed.\nReference: %s\nDate: %s",
                number_format($this->payment->amountMinor / 100, 2),
                $this->payment->currency,
                $this->payment->reference,
                $this->payment->paidAt->format('Y-m-d H:i'),
            ),
        );
    }
}

Now it's M + N, four notification classes and three channel classes. A new channel is one class. So is a new notification type, and neither of them touches the other hierarchy.

The combination of channel and notification is chosen at construction time:

(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))
    ->send($user->email);

(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))
    ->send($user->phone);

In an application this should live in one place, usually a dispatcher that knows the available channels and how to build each notification type:

final class NotificationDispatcher
{
    /**
     * @param array<string, NotificationChannel> $channels
     * @param array<string, Closure(NotificationChannel, array): Notification> $factories
     */
    public function __construct(
        private readonly array $channels,
        private readonly array $factories,
    ) {}

    public function dispatch(string $type, array $payload, User $user): void
    {
        $factory = $this->factories[$type]
            ?? throw new InvalidArgumentException("Unknown notification type: {$type}");

        foreach ($user->enabledChannels() as $name) {
            $channel = $this->channels[$name]
                ?? throw new InvalidArgumentException("Unknown channel: {$name}");

            $factory($channel, $payload)->send($user->contactFor($name));
        }
    }
}

enabledChannels() and contactFor() are methods on your own User model. The dispatcher is the only class that knows both hierarchies.

When the dimensions aren't independent

Bridge assumes every notification makes sense on every channel. That breaks as soon as some type has content that fits only one channel, like a weekly report rendered as an HTML table. You'll recognize it by a type check in the abstraction:

final class WeeklyReportNotification extends Notification
{
    public function send(string $recipient): void
    {
        if ($this->channel instanceof SmsChannel) {
            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');
            return;
        }

        // renderHtmlReport() omitted from the example
        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());
    }
}

With that instanceof the notification depends on a concrete channel, which is exactly what the pattern was supposed to prevent. From now on every new channel means going through all notifications to look for branches like this one.

If it's one type, the simplest thing is to admit this case is M × N and write separate classes, WeeklyReportEmailNotification and WeeklyReportSmsSummaryNotification.

When many types need channel-specific variants, it's the contract that should change. The notification builds a message object with several representations (full body, short text) and each channel picks the one it can deliver.

Laravel's notification system sits somewhere in between. A notification class declares its channels in via() and implements a method per channel (toMail(), toArray() and so on), which gives up to M × N methods inside M classes. In exchange you write the content for each channel separately and never need instanceof.

Testing

Each hierarchy can be tested without the other. You test a channel against a mocked client and a notification against a mocked channel:

use PHPUnit\Framework\TestCase;

final class EmailChannelTest extends TestCase
{
    public function testDelegatesToMailer(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with('[email protected]', 'Test', '<p>Body</p>');

        (new EmailChannel($mailer))->send('[email protected]', 'Test', '<p>Body</p>');
    }
}

final class PaymentConfirmationNotificationTest extends TestCase
{
    public function testBuildsSubjectAndBody(): void
    {
        $channel = $this->createMock(NotificationChannel::class);
        $channel->expects($this->once())
            ->method('send')
            ->with(
                '[email protected]',
                $this->stringContains('PAY-2024-001'),
                $this->stringContains('100.00'),
            );

        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));

        (new PaymentConfirmationNotification($channel, $payment))->send('[email protected]');
    }
}

There are as many tests as classes, and none of them needs a real mail server or the Slack API. In a project these are two files, because PHPUnit looks for a class named after the file. createMock(Mailer::class) works if Mailer is an interface or a non-final class.

Is it worth it here

Class names are usually the first hint. PaymentEmailNotification, PdfInvoiceExporter, CsvAuditLogFormatter: each glues two concepts together. If both keep changing over time (new channels and new message types, new export formats and new report types) and almost every combination is valid, Bridge pays off. With only one dimension varying it doesn't. A single channel needs nothing more than an interface on the notification side. If many combinations need special handling, the problem is M × N, and explicit classes or per-channel methods describe it more honestly. And with two or three combinations in total, the extra indirection costs more than the duplication it removes.

end of node