Engineering Atlas › Low-Level Design

Code a multi-channel notification module

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: user preferences and quiet hours, rate limits, campaign fan-out and analytics. The system design lesson owns those; here they would be one more filter before the engine.

Entities and relationships

Relationships: the engine aggregates deliveries and holds a registry of channels and templates. A delivery references its request and template by id. Channels own nothing but their provider client.

Class diagram

classDiagram
    class NotificationRequest {
      +id: string
      +recipient: RecipientId
      +templateId: string
      +vars: Map~string, string~
      +channels: ChannelName[]
    }
    class Template {
      +id: string
      +bodies: Map~ChannelName, string~
      +render(channel, vars) string
    }
    class Delivery {
      +requestId: string
      +text: string
      +channelIndex: int
      +attempts: int
      +nextAt: int
      +state: DeliveryState
    }
    class Channel {
      <<interface>>
      +send(recipient, text) Result
    }
    class RetryPolicy {
      <<interface>>
      +nextAt(attempts, now) int
      +maxAttempts() int
    }
    class DeliveryEngine {
      -deliveries: Map~string, Delivery~
      +submit(request) bool
      +tick() Attempt[]
      +status(id) Status
    }
    class Clock {
      <<interface>>
      +now() int
    }
    DeliveryEngine "1" o-- "many" Delivery
    DeliveryEngine --> Channel
    DeliveryEngine --> Template
    DeliveryEngine --> RetryPolicy
    DeliveryEngine --> Clock
    Delivery --> NotificationRequest
    EmailChannel ..|> Channel
    SmsChannel ..|> Channel
    PushChannel ..|> Channel
    ExponentialBackoff ..|> RetryPolicy

Key flows

A delivery alert asks for push first and SMS as the fallback. Push times out twice, then the retry budget is spent, and SMS succeeds on the next tick.

sequenceDiagram
    participant O as OrderService
    participant E as DeliveryEngine
    participant T as Template
    participant P as PushChannel
    participant S as SmsChannel
    O->>E: submit(id n1, shipped, [push, sms])
    E->>T: render(vars)
    T-->>E: "Your order 812 has shipped"
    E->>E: Delivery n1 PENDING on push, due now
    E->>P: send (attempt 1)
    P-->>E: RETRY (timeout)
    E->>E: next attempt now + 10 s
    E->>P: send (attempt 2)
    P-->>E: RETRY
    E->>E: budget spent, fall back to sms, due now
    E->>S: send (attempt 1)
    S-->>E: OK
    E->>E: n1 SENT via sms

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

submit either rejects a bad request synchronously (unknown template, missing variable, unknown channel, empty channel list) or durably records exactly one delivery for that id; a repeat submit returns "duplicate" and changes nothing. Each tick attempts every delivery whose next-attempt time is at or before now, at most once per tick. After an attempt, a delivery is SENT on the channel that returned OK, still PENDING with a later time or the next channel, or FAILED when the last channel gives up. Delivery is at-least-once per channel attempt; the engine never sends on a later channel after an earlier one succeeded.

Layer 2The mechanism

In memory, deliveries live in a map by id plus a list or min-heap ordered by (nextAt, sequence) so a tick takes due items in a stable order. Backoff for attempt k on a channel is base * 2^(k-1), so with a 10-second base the waits are 10, 20, 40 seconds. In production the same state is a table with next_attempt_at indexed; workers run a claim query that skips locked rows, call the provider outside the transaction, then record the outcome with a conditional update on the lease they hold. Provider protocols matter for classification: APNs is HTTP/2 and answers each request with a status and a reason (a 410 means the token is no longer valid, which is permanent); SMTP uses 4xx reply codes for "try again" and 5xx for permanent rejection; SMS gateways return their own codes that the adapter must map.

Layer 3The machine

A provider call is a network round trip plus TLS, typically 50 to 500 ms, while the engine's bookkeeping is microseconds. A worker that sends synchronously therefore spends over 99% of its time waiting on sockets, which is why delivery workers are I/O-concurrent (an event loop, goroutines, or a thread pool far larger than the core count) and reuse connections: a new TLS 1.3 handshake costs at least one extra round trip, and APNs over HTTP/2 can multiplex many requests on one connection. Payload limits shape templates: Apple documents a 4 KB limit for a regular push payload, and an SMS segment holds 160 characters in the GSM 7-bit alphabet but only 70 once a single emoji forces UCS-2 encoding, so one emoji can double the cost of a message. Durable delivery state costs a database write per attempt, around a millisecond for a commit on SSD.

Why it's built this way

  1. Providers fail independently and often transiently, so each attempt's outcome is classified and retried per channel rather than failing the whole notification.
  2. Retrying a struggling provider immediately makes it worse, so retries back off exponentially (and, in production, with jitter).
  3. Some failures are permanent and some users are reachable elsewhere, so a fallback chain moves on instead of retrying forever.
  4. Producers and workers both retry, so ids make submits idempotent and leases make claims exclusive.
  5. Templates, channels and policies change on different schedules and are owned by different people, so each sits behind its own interface.

Extensibility

Leadership lens

Check yourself

  1. Why render the template at submit time instead of at send time?

    Show answer

    Missing variables fail the producer's call where someone can fix them, and every retry and fallback sends the same text even if the template is edited meanwhile.

  2. A push provider returns "token unregistered". Should the engine retry it?

    Show answer

    No. That is a permanent failure: retrying wastes the budget and delays the user. Move to the next channel and remove the stale token from the recipient's addresses.

  3. Why does an idempotent submit not guarantee the user receives at most one message?

    Show answer

    The ambiguity is downstream: a provider can accept a message and then time out before replying, and the retry sends a second copy. Only provider-side keys or device-side collapse can remove that duplicate.

  4. Why give each channel its own concurrency limit?

    Show answer

    So a slow provider exhausts only its own workers. With a shared pool, a stalled SMS gateway would hold every worker and delay push and email too.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →