Why channel-by-channel integration breaks down

The common path: add Twilio for SMS, then SendGrid for email six months later, then FCM for push after that - each wired directly into whichever service happens to need it. The result is three different retry policies, three different failure-logging approaches, and no single place to answer "did this user get notified at all, on any channel." A notification system designed as one architecture avoids that split from the start.

Core architecture: decouple ingestion from delivery

The business logic that decides "notify the user" should never call a provider SDK directly. Instead, it publishes an event to a message queue (Kafka or RabbitMQ), and separate delivery workers - one per channel - consume from their own topics. This decoupling is what lets you scale, retry, and monitor each channel independently.

Ingestion layer

Business services publish a NotificationRequested event - never call Twilio/SendGrid/FCM directly from application code.

Priority partitions

Per-channel topics split by urgency - notifications.sms.high, notifications.sms.low - so an OTP never queues behind a marketing blast.

Channel workers

One consumer group per channel, each wrapping a single provider SDK behind a common NotificationService interface.

The Outbox Pattern: no lost events

Publishing directly to a queue from inside a database transaction has a classic failure mode - the "dual write problem": if the database commits but the publish to Kafka fails (or vice versa), the two systems disagree about what happened. The fix is to write the event to an outbox table in the same transaction as the business change, then let a separate relay process publish it to the queue afterward.

BEGIN;
  UPDATE orders SET status = 'confirmed' WHERE id = $1;
  INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
  VALUES ($1, 'OrderConfirmed', $2, now());
COMMIT;
-- A relay worker polls the outbox table (or reads its WAL via CDC),
-- publishes each unpublished row to Kafka, then marks it published.
-- The business write and the event write either both happen or neither does.

Full implementation detail - including the CDC-based relay variant - is covered in our dedicated Outbox Pattern piece linked below.

Channel abstraction

interface ChannelProvider {
  send(payload: NotificationPayload): Promise<ProviderResult>;
}

class TwilioSmsProvider implements ChannelProvider { /* Twilio SDK call */ }
class SendGridEmailProvider implements ChannelProvider { /* SendGrid SDK call */ }
class FcmPushProvider implements ChannelProvider { /* FCM SDK call */ }

class NotificationService {
  constructor(private providers: Record<Channel, ChannelProvider>) {}

  async route(request: NotificationRequest) {
    const channel = this.resolveChannel(request.userPreferences, request.priority);
    return this.providers[channel].send(request.payload);
  }

  private resolveChannel(prefs: UserPreferences, priority: Priority): Channel {
    // SMS > Email > Push for high-priority, unless the user opted out of SMS
    if (priority === 'high' && prefs.sms) return 'sms';
    if (prefs.email) return 'email';
    return 'push';
  }
}

Why abstraction matters here specifically: providers get swapped or added (a second SMS vendor for a new region, a fallback push provider) far more often in notification systems than in most parts of an app. An interface boundary here is cheap up front and expensive to retrofit later.

Priority: SMS > Email > Push, but configurable

"SMS beats email beats push" is a reasonable default for time-critical alerts (OTP, fraud alerts, payment confirmations), but it should be a resolved preference, not a hardcoded rule - respecting user opt-outs, regional SMS cost differences, and per-event overrides (a marketing digest should never escalate to SMS regardless of "priority" flags upstream).

Key takeaways

  • Decouple the decision to notify from the act of sending - business logic publishes events, never calls provider SDKs directly.
  • Use per-channel, per-priority queue partitions so low-priority traffic can never delay a time-critical alert.
  • The outbox pattern is what prevents the dual-write problem between your database and your message queue.
  • A single ChannelProvider interface makes adding or swapping a provider a config change, not a rewrite.
  • Channel priority should be a resolved, overridable preference - not a hardcoded escalation rule.
Retrofitting this architecture onto three already-live, independently-wired notification integrations is a bigger job than building it this way from the start - but it's a very common one we've done.