System Design Intermediate B2

The Notification System That Grew Out of Control

The problem

Business context:

SaaS platform for vacation rental management. It connects apartment owners with guests. It has ~3,000 owners and ~40,000 bookings/month.

The problem:

The system needs to send notifications at multiple points throughout a booking's lifecycle:

  • When a guest requests a booking → email to the owner + push notification to the owner.
  • When the owner confirms → email to the guest + SMS to the guest + push notification to the guest.
  • When the owner rejects → email to the guest.
  • 48h before check-in → email with instructions to the guest + SMS with access code + reminder email to the owner.
  • At check-out → email to the guest requesting a review + email to the owner with a summary.
  • If the guest doesn't pay within 24h after confirmation → reminder email, and if they still haven't paid after 48h → automatic cancellation + email to both parties.

Each owner can configure their preferences: which channels they want to receive (email, SMS, push, WhatsApp) and which notifications they want to disable. Some owners have external channel managers (Booking, Airbnb), and notifications for those bookings must also be sent to the channel manager via webhook.

Current situation (the mess):

Everything is coupled to the domain services. When a booking is confirmed, ReservationService::confirm() contains 200 lines that synchronously send emails, SMS, push notifications, and webhooks. If the SMS gateway fails, the entire booking confirmation fails and the guest sees a 500 error. This happens 2–3 times per week.

Scheduled notifications (48h before check-in, payment reminders) are implemented with cron jobs that run heavy queries every 5 minutes against the bookings table. With the current growth, these crons already take 4 minutes to run.

The team has just discovered that some guests are receiving duplicate emails. They don't know why or how frequently it happens.

Requirements:

  • Functional: All the notifications described above must work. Owners can configure their preferences. Support for new channels in the future (WhatsApp has already been requested). There must be a queryable log of every notification sent.
  • Non-functional: A failure when sending a notification must never affect the domain operation (confirm, reject, etc.). Notifications must be sent within < 5 minutes after the event. Scheduled notification jobs must scale to 200,000 bookings/month without degradation.
  • Constraints: Same team of 3 PHP/Symfony developers. Current infrastructure: Symfony Messenger with SQS transport. MySQL as the primary database. The booking domain already exists and works (except for the coupling with notifications).

Expected deliverables:

  1. Architecture of the decoupled notification system. What events does the domain emit? What does the notification module consume?
  2. Model for scheduled notifications (48h before check-in, payment reminder). How do you eliminate the heavy cron jobs?
  3. Strategy against duplicates. Why do they happen and how do you prevent them?
  4. How do you add a new channel (WhatsApp) without modifying the existing code for other channels?

Same rule: concrete decisions, explicit trade-offs, nothing generic.

My solution — Challenge 2: Notification System

General Architecture

┌─────────────────┐          Domain Events           ┌──────────────────┐
│                 │  ReservationConfirmed            │                  │
│   Reservation   │  ReservationRejected             │                  │
│   Bounded       │  ReservationCheckedOut  ─────►   │   Notification   │
│   Context       │  PaymentOverdue                  │   Module         │
│                 │  CheckInApproaching              │                  │
└─────────────────┘                                  └──────────────────┘
                                                          │
     The reservation domain does NOT know                  ├─► NotificationDispatcher
     that notifications exist.                             │     │
     It only emits events with a payload.                   │     ├─ Query owner preferences
                                                          │     │
                                                          │     ├─ Generate 1 message per channel
                                                          │     │
                                                          │     └─ Dispatch to channel queue
                                                          │
                                                          ├─► Email Queue ─► EmailChannel
                                                          ├─► SMS Queue   ─► SmsChannel
                                                          ├─► Push Queue  ─► PushChannel
                                                          └─► WA Queue    ─► WhatsAppChannel

Each event only contains reservation data, not notification data:

// The domain emits this. It knows nothing about emails or SMS.

final class ReservationConfirmed
{
    public function __construct(
        public readonly string $reservationId,
        public readonly string $guestId,
        public readonly string $ownerId,
        public readonly DateTimeImmutable $checkInDate,
        public readonly Money $totalAmount,
    ) {}
}

The dispatcher is responsible for orchestration:

final class ReservationConfirmedHandler
{
    public function __invoke(ReservationConfirmed $event): void
    {
        // Determine WHICH notifications to generate
        $ownerPrefs = $this->preferencesRepo->forOwner($event->ownerId);

        // To the guest: email + SMS + push (always)
        $this->dispatch(new SendNotification(
            recipientId: $event->guestId,
            type: 'reservation_confirmed',
            channels: ['email', 'sms', 'push'],
            payload: [...],
            idempotencyKey: "resconf-{$event->reservationId}-guest",
        ));

        // To the owner: according to their preferences
        $this->dispatch(new SendNotification(
            recipientId: $event->ownerId,
            type: 'reservation_confirmed',
            channels: $ownerPrefs->activeChannels(),
            payload: [...],
            idempotencyKey: "resconf-{$event->reservationId}-owner",
        ));
    }
}

The NotificationDispatcher splits notifications into separate queues by channel:

final class NotificationDispatcher
{
    public function __invoke(SendNotification $command): void
    {
        foreach ($command->channels as $channelName) {
            $key = "{$command->idempotencyKey}-{$channelName}";

            // Idempotency BEFORE enqueueing
            if ($this->idempotencyStore->exists($key)) {
                continue;
            }

            $this->idempotencyStore->reserve($key); // INSERT with "pending" status

            // Each channel has its own queue
            $this->bus->dispatch(
                new ChannelDelivery($channelName, $command->payload, $key)
            );
        }
    }
}

New Channel (WhatsApp)

// 1. Create the class

final class WhatsAppChannel implements NotificationChannel
{
    // ...
}

// 2. Register it in the service container (autowiring by tag)

// 3. Done. No existing class needs to be modified.

Scheduled Notifications — Hybrid Model

When a reservation is confirmed:

  │
  ├─ Calculate: check_in - 48h = notification date
  │
  └─ INSERT into scheduled_notifications table:
       reservation_id, type, execute_at, status=pending, payload

Cron every 10 minutes (lightweight):

  │
  SELECT * FROM scheduled_notifications
  WHERE execute_at <= NOW()
    AND status = 'pending'
  LIMIT 100

  │
  └─ For each result → dispatch to corresponding queue
                         → UPDATE status = 'dispatched'

The query is fast because it has an index on (status, execute_at) and only retrieves notifications that are due now; it does not scan the entire reservations table.

If the reservation is cancelled, simply mark the scheduled_notification as cancelled.

Idempotency — Complete Safe Flow

  1. Consumer reads message from the queue.

  2. Does an idempotency_key with "sent" status exist? → ACK and discard.

  3. Does one with "pending" status exist? → Continue to step 4.

    • If it doesn't exist → INSERT with "pending" status.
  4. Send through the channel (email, SMS, etc.).

  5. UPDATE status to "sent".

  6. ACK the message from the queue.

If it fails between steps 4 and 5:

→ SQS redelivers the message

→ Step 3 finds "pending"

→ Attempts to send again

→ Risk of duplicate in this specific case,
  but this is an acceptable edge case compared
  to the complexity of a two-phase commit
  with the external gateway.
#ddd #hexagonal #design