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
ChannelProviderinterface 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.
Zetrixweb