php/notification-hub@v1.0.0
artykuł··9 min czytania

Wzorzec Bridge: oddzielenie tego, co wysyłasz, od tego, jak to wysyłasz

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

Wzorzec Bridge jest wyjaśniany w większości podręczników na przykładzie kształtów i API renderowania. Hierarchia Shape i hierarchia Renderer, połączone mostem. Przykłady są poprawne i całkowicie bezużyteczne jako wskazówka projektowa, bo żaden produkcyjny system nikogo nie rysuje kształtów.

Wzorzec rozwiązuje konkretny, rozpoznawalny problem: masz dwa niezależnie zmienne wymiary i musisz je łączyć bez tworzenia klasy dla każdej kombinacji. Najczęściej spotykałem ten problem w systemach notyfikacji i na tym przykładzie opieram ten artykuł.

Problem: M × N klas

Masz system notyfikacji. Wysyła notyfikacje. Wysyła je przez trzy kanały: Email, SMS i Slack. Wysyła cztery typy notyfikacji: PaymentConfirmation, LowInventoryAlert, AccountSuspended i WeeklyReport.

Bez żadnej struktury:

PaymentConfirmationEmail
PaymentConfirmationSMS
PaymentConfirmationSlack
LowInventoryAlertEmail
LowInventoryAlertSMS
LowInventoryAlertSlack
AccountSuspendedEmail
AccountSuspendedSMS
AccountSuspendedSlack
WeeklyReportEmail
WeeklyReportSMS
WeeklyReportSlack

12 klas. Dodaj czwarty kanał (push notifications): 16 klas. Dodaj piąty typ notyfikacji (PasswordReset): 20 klas. Struktura skaluje się jako M × N, a każda klasa zawiera logikę jednego typu notyfikacji sformatowanego dla jednego kanału.

Wyekstrahowany Bridge

Bridge oddziela dwie hierarchie i łączy je przez kompozycję zamiast dziedziczenia:

// 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'),
        );
    }
}

Teraz struktura to M + N: 4 klasy notyfikacji plus 3 klasy kanałów. Dodanie czwartego kanału (PushNotification) to jedna nowa klasa. Dodanie piątego typu notyfikacji (PasswordReset) to jedna nowa klasa. Nic innego się nie zmienia.

Punkt kompozycji

Bridge następuje przy konstrukcji: kompoyzujesz typ notyfikacji z kanałem:

// 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);

W praktyce tą kompozycją zajmuje się NotificationDispatcher, który sprawdza, które kanały dany użytkownik włączył:

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));
        }
    }
}

Gdzie abstrakcja zaczyna przeciekać

Bridge działa czysto wtedy, gdy dwa wymiary są naprawdę niezależne. Przestają być niezależne, gdy typ notyfikacji ma treść, która ma sens tylko w jednym kanale, szczegółowy raport HTML dla emaila, bez znaczącego odpowiednika w SMS.

// 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());
    }
}

Sprawdzenie instanceof to rozpadający się wzorzec Bridge. Notyfikacja WeeklyReport nie jest niezależna od kanału, ma inne zachowanie dla różnych kanałów, co oznacza, że abstrakcja już nie trzyma.

Uczciwa naprawa to niezmuszanie wzorca. Niektóre notyfikacje mają implementacje specyficzne dla kanału. Utwórz WeeklyReportEmailNotification i WeeklyReportSmsSummaryNotification jako oddzielne klasy. Wymuszanie M + N tam, gdzie problem jest naprawdę M × N, produkuje gorszy kod niż po prostu napisanie klas M × N w czytelny sposób.

Testowanie wzorca

Wartość Bridge dla testowania polega na tym, że każdy wymiar jest testowalny niezależnie:

// 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]');
    }
}

Dwanaście kombinacji notyfikacja–kanał, zero testów uruchamiających prawdziwy mailer ani Slack API. Każdy test jest szybki, izolowany i pokrywa dokładnie jedną jednostkę zachowania.

Kiedy sięgać po Bridge

Jest jeden wyraźny sygnał: zaraz napiszesz klasę, której nazwa to dwa pojęcia złączone razem, PaymentEmailNotification, PdfReportExporter, CsvLogFormatter. Nazwa sama mówi ci, że dwa niezależne wymiary zostały połączone w jedną klasę. To jest moment, żeby zapytać, czy można je rozdzielić i skomponować zamiast tego.

koniec artykułu