All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · High Level Design · Work in the Background

Design a Notification System

IntermediateQueues per channelRetriesPreferencesDedup⏱ 2-3 h

Sending millions of push, SMS and email notifications: queues per channel, retries without spamming, user preferences, and templates.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyIntermediate
Time2–3 hours to design and build the core; about 45 minutes in an interview
PrerequisitesAsync messaging, designing for failure, what idempotency means
ConceptsAccept fast and send later, a queue per channel and per priority, idempotency keys, retries with exponential backoff and jitter, dead-letter queues, user preferences and quiet hours, rate limits, campaign fan-out, provider failover, delivery tracking
Real worldOrder and delivery updates from shops and couriers, bank OTP SMS, ride-hailing "your driver has arrived", app push notifications, marketing email, school and hospital SMS alerts

Every product ends up sending notifications, and almost every team first does it the easy way: the order service calls the SMS gateway directly. That works until the gateway is slow, a campaign floods it, a retry sends the same OTP three times, or someone gets a sale SMS at 3 a.m. This problem is about the shared service that fixes all of that, and its core lesson applies to any background work: accept the request quickly, do the slow and unreliable part later, and make every step safe to repeat.

Problem statement

Nusrat ordered a phone from a shop in Dhaka. Over the next two days, several systems need to tell her things: the order service ("your order has shipped"), the courier ("arriving today"), the payment service ("refund done"), and the marketing team ("Eid sale, 30% off"). Some of these she must get (an OTP for login), some she has turned off (marketing email), and none should wake her up at night unless it's urgent.

What we are building: one notification service that any internal service can call with "send template X to user Y", and that delivers it by push, SMS or email according to the user's preferences, reliably, without duplicates, and without slowing the caller down.

Who uses it: internal services (order, payment, auth, chat, courier) and the marketing team's campaign tool on one side; tens of millions of end users on the other; and support staff who need to answer "did the customer get the SMS?".

What it solves: every team stops writing its own retries, templates, opt-out rules and provider integrations; users get a consistent experience; and there's one place to see what was sent.

What makes it technically interesting:

  • Unreliable, slow providers: SMS gateways time out, push services throttle, email providers reject addresses. Calls take hundreds of milliseconds and fail in different ways.
  • Duplicates: callers retry, queues redeliver, workers crash halfway. A duplicate OTP is confusing; a duplicate "payment received" is alarming.
  • Bursts: a campaign to 25 million users must not delay a login OTP.
  • Rules per user: opt-outs, quiet hours, daily limits, language, which channels they even have.
  • Cost: SMS costs money per message; push and email are nearly free. The design decides which channel to use for what.

Requirements

Functional requirements

  1. Send: an internal service sends {user, template, params, idempotency key}; the service delivers on the template's channels (push, SMS, email).
  2. Preferences: users can opt out of marketing per channel and set quiet hours; transactional messages (OTP, order and payment updates) are always allowed.
  3. Templates: messages come from versioned templates with parameters and translations (Bangla and English).
  4. Reliability: transient failures are retried; messages that can't be delivered go to a dead-letter queue for inspection and replay.
  5. No duplicates from retries: the same idempotency key never produces a second notification.
  6. Campaigns: marketing can send to a segment of millions, at a rate the providers accept.
  7. Tracking: the status of every message (queued, sent, delivered, failed) can be looked up.
  8. Out of scope: the in-app inbox UI, A/B testing of message text, and building our own SMS or push infrastructure (we use providers).

Non-functional requirements

RequirementTargetWhy it matters here
Accept latencyp99 under 50 ms for the API callCallers (checkout, login) must not wait for an SMS gateway
Delivery latencyOTP sent within ~2 s; order updates within ~1 minute; campaigns within hoursDifferent messages have very different urgency
Throughput~100 million messages a day, peaks of ~10,000/s during campaignsEstimated below
Availability99.99% for accepting; sending can lagLosing a request is worse than sending it a minute late
DurabilityAn accepted notification is never silently lostIt's either delivered or visible in the dead-letter queue
No duplicatesAt most one send per idempotency key in normal operationRepeated OTPs and payment messages erode trust
IsolationA slow or broken channel doesn't affect the others; campaigns don't delay transactional messagesFailures must stay small
Privacy and costRespect opt-outs and limits; don't use SMS where push will doLegal requirements and money

Accept latency, durability, no-duplicates and isolation drive the design. Delivery latency is relaxed except for OTPs, which is why OTPs get their own path.

Hint 1: what does the caller wait for?

The checkout service calls you and the SMS gateway takes 800 ms to answer, sometimes 30 s. Should checkout wait for that? What's the least the API must do before it can answer?

Hint 2: duplicates

The caller's request times out and it retries. Your worker crashes after the gateway accepted the SMS but before it marked the message done. Where do duplicates come from, and what stops each kind?

Hint 3: one slow channel

If push, SMS and email share one queue and one worker pool, what happens to push messages while the SMS gateway is having a bad day?

Hint 4: retries that make things worse

A gateway is overloaded and every worker retries immediately. What does that do to the gateway? How long should you wait, and when should you give up?

Capacity estimation

Assumptions (said out loud, round numbers):

  • 50 million users; about 25 million reachable for marketing (opted in, have the channel).
  • Per day: 10 million order and delivery updates, 5 million OTPs, 35 million app pushes (chat, ride status), and two marketing campaigns of 25 million each: about 100 million messages a day.
  • Channel mix: 65 million push, 25 million email, 10 million SMS (SMS is reserved for OTPs and important updates because it costs money).
  • A campaign goes out over about an hour; normal traffic peaks at 3× the average.

Traffic

FormulaResult
Messages per second (avg)100M ÷ 86,400≈ 1,200 / s
Normal peak1,200 × 3≈ 3,500 / s
Campaign25M ÷ 3,600≈ 7,000 / s
Worst case (campaign during a normal peak)3,500 + 7,000≈ 10,000 / s
SMS per second (avg)10M ÷ 86,400≈ 115 / s
API requests per second (avg)~70M requests ÷ 86,400 (one request can fan out to 2 channels)≈ 800 / s

Storage

FormulaResult
Status records per day100M × 300 B30 GB / day
Kept 90 days, 3 copies30 GB × 90 × 3≈ 8 TB
Dedup keys (24 h TTL)70M × ~60 B≈ 4 GB in Redis
Campaign backlog in the queue25M × 1 KB≈ 25 GB (Kafka holds this easily)
Preferences and contacts50M × ~200 B≈ 10 GB

Workers (Little's law): the number of calls in flight = rate × time per call. At 10,000 sends a second and ~200 ms per provider call, about 2,000 calls are in flight at any moment. With asynchronous I/O, 20 worker instances handling 100 concurrent calls each cover the peak. The real limit is usually not our CPU but the providers' rate limits: an SMS account might accept only a few hundred messages a second, so workers must throttle to it.

What the numbers say: the volume is moderate for a queue but too slow and unreliable to do inside an API call. Storage is dominated by the status log. The hard parts are not size but behaviour: duplicates, bursts, slow providers and per-user rules.

API design

Method and pathRequestSuccessErrors
POST /v1/notificationsheader Idempotency-Key: evt-2; {"user_id": "nusrat", "template": "order_shipped", "params": {"order": "BD-7790"}, "priority": "normal"}202 {"id": "evt-2", "queued": ["sms"], "skipped": ["push: no device"]}; a repeat with the same key returns 200 and the original result400 unknown template or missing param, 404 unknown user, 429 caller over quota or marketing limit
POST /v1/campaigns{"template": "eid_sale", "segment": "active-30d", "send_at": "...", "params": {...}}202 {"campaign_id": "camp-7"}400, 403 (only the marketing tool)
GET /v1/notifications/{id}none200 {"sms": {"status": "delivered", "attempts": 3, "provider": "A"}}404
PUT /v1/users/{id}/preferences{"marketing": {"email": false}, "quiet_hours": {"from": "22:00", "to": "08:00", "tz": "Asia/Dhaka"}}200400
POST /v1/devices{"user_id": "...", "token": "...", "platform": "android"}201400
POST /webhooks/{provider}provider's delivery receipt200401 bad signature
  • 202 Accepted, not 200 OK: the API promises the notification is durably queued, not that it was delivered. That's what lets it answer in milliseconds.
  • Idempotency key: chosen by the caller and tied to the business event (order-BD-7790-shipped), not random per attempt. A retried call with the same key returns the first result and sends nothing new.
  • Authentication: service-to-service (mTLS or signed tokens); each caller has a quota, and only the marketing tool may create campaigns or send marketing templates.
  • Callers send template names, not text: wording, translation and legal footers live in one place, and a compromised service can't send arbitrary text to every user.
  • Webhooks are signed by the provider and verified; they update the status from "sent" to "delivered" or "failed".

Database design

Access patterns: preferences and contacts are read on every request (read-heavy, small, per user). Templates are read constantly and change rarely. Status records are written for every message and updated a few times (write-heavy, looked up by notification or by user). Dedup keys are checked once per request and expire after a day.

-- Preferences and contacts (PostgreSQL, cached in memory with a short TTL).
CREATE TABLE user_prefs (
  user_id       BIGINT PRIMARY KEY,
  locale        TEXT NOT NULL DEFAULT 'bn',
  tz            TEXT NOT NULL DEFAULT 'Asia/Dhaka',
  quiet_from    TIME,                       -- marketing only
  quiet_to      TIME,
  optout_push   BOOLEAN NOT NULL DEFAULT false,   -- marketing opt-outs per channel
  optout_sms    BOOLEAN NOT NULL DEFAULT false,
  optout_email  BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE contacts (
  user_id   BIGINT NOT NULL,
  channel   TEXT   NOT NULL,                -- push, sms, email
  address   TEXT   NOT NULL,                -- device token, phone, email
  verified  BOOLEAN NOT NULL,
  active    BOOLEAN NOT NULL DEFAULT true,  -- false after the provider says "invalid"
  PRIMARY KEY (user_id, channel, address)
);

-- Templates: versioned, one row per language and channel.
CREATE TABLE templates (
  name     TEXT, version INT, locale TEXT, channel TEXT,
  kind     TEXT NOT NULL,                   -- transactional | marketing
  body     TEXT NOT NULL,
  PRIMARY KEY (name, version, locale, channel)
);

-- Status of every message (Cassandra / DynamoDB style: partition by user, newest first).
--   notifications_by_user: PRIMARY KEY ((user_id), created_at DESC, message_key)
--     columns: channel, template, status, attempts, provider, provider_msg_id, updated_at
--   by_provider_msg: PRIMARY KEY (provider, provider_msg_id) -> user_id, message_key  (for webhooks)

-- Dedup: Redis  SET dedup:<idempotency key> <result> NX EX 86400
  • Why PostgreSQL for preferences: small (10 GB), relational, updated by users through settings pages, and needs constraints. Reads are served from a cache, so the database sees little load.
  • Why a wide-column store for status: about 100 million inserts plus updates a day, simple key lookups, natural TTL for old records, and easy horizontal scaling. At a smaller scale, a partitioned PostgreSQL table is perfectly fine; switch when write volume or retention makes it painful.
  • Why Redis for dedup: an atomic "set if not exists, with expiry" in under a millisecond is exactly the operation needed, and keys only need to live a day. Redis isn't the source of truth for anything permanent, so losing it is an inconvenience, not data loss (see Failures).
  • Partitioning: status by user_id (so "this user's notifications" is one partition); queues partitioned by user ID inside each topic, so one user's messages stay in order.

Visual architecture

Order svcCampaignsNotify APIdedup + prefsRedisdedup keysPrefs DB+ templatespush queuesms queueemail queuepush workerssms workersemail workersAPNs / FCMSMS gatewayEmail (ESP)Delivery logstatus per msgDead lettersalert, replayevt-2SET NXevt-2:smstry 1timeoutretrytry 3sentcamp-7bad addrthe API checks the ID once, reads preferences and queues one message per channela worker retries a timeout after a pause, then records the resulta bad address will never work: straight to dead letters
Press play. The order service sends evt-2. The Notify API sets a dedup key in Redis, reads preferences, renders the template and puts one message on the SMS queue. An SMS worker calls the gateway, which times out; the worker waits and retries, the third try succeeds, and the result goes to the delivery log. A campaign message to a bad email address is rejected and goes straight to dead letters.
ComponentWhy it's hereConnections
Producers (order, auth, courier, campaigns)Decide that a user should be told something→ Notify API
Notify APIValidates, deduplicates, applies preferences and limits, renders, enqueues; answers 202 fast→ Redis, prefs, queues
RedisDedup keys (SET NX with 24 h expiry) and per-user rate-limit countersread and written by the API
Prefs DB + cacheOpt-outs, quiet hours, locale, contacts, templatesread by the API
Queues per channelDurable buffer; lets each channel go at its own speedAPI → queue → workers
Workers per channelCall the provider, record the status, retry or dead-letter→ providers, delivery log, DLQ
ProvidersAPNs/FCM for push, SMS gateways, an email serviceexternal
Delivery logStatus of each message, for support and analyticswritten by workers and webhooks
Dead lettersMessages that failed for good; alert and replay after a fixwritten by workers

We deliberately don't have: a database write per message on the API path (only the queue write is required for durability), one microservice per channel on day one (one codebase, separate queues and worker pools), or our own push or SMS infrastructure.

Request lifecycle

Accepting a request

Order svcNotify APIRedisPrefsSMS queuePOST /notifications evt-2SET evt-2 NX EX 1dOK (first time)prefs + contacts nusratsms ok, no push tokenrender templateenqueue evt-2:sms202 Acceptedretry: evt-2200 already accepted
Press play. The order service posts evt-2. The API sets the dedup key only if it doesn't exist, reads Nusrat's preferences and contacts (SMS yes, no push token), renders the template, enqueues one SMS message and answers 202. When the order service retries after a timeout, the key already exists and it gets 200 with the original result, and nothing new is queued.
  1. Order service → LB → Notify API: POST /v1/notifications with Idempotency-Key: evt-2.
  2. API checks the caller's quota, the template and the parameters (a missing parameter is a 400, never a message with a blank in it).
  3. SET dedup:evt-2 NX EX 86400 in Redis. If the key already exists, return the stored result with 200: this is a retry.
  4. Read preferences and contacts (from cache). Decide channels: the template says push + SMS; Nusrat has no device token, so push is skipped.
  5. Marketing only: check opt-outs, quiet hours (delay to the end of the window) and the daily limit.
  6. Render the template in the user's language and enqueue one message per channel (evt-2:sms), keyed by user so a user's messages stay in order.
  7. Return 202 with what was queued and skipped. Total: a few milliseconds, no provider call.

Sending a message (the worker side)

0 s1 s3 s7 sflakydowntry 1try 2try 3try 1try 2try 3try 4DLQwait 1 swait 2 swait 4 sdeliveredeach wait doubles, so a struggling gateway gets room to recoverafter 4 tries: dead-letter queue, an alert, a replay laterproduction: minutes not seconds, plus random jitter so retries don't arrive together
Press play. On the top line, the gateway is flaky: try 1 and try 2 time out, the worker waits 1 s and then 2 s, and try 3 is delivered. On the bottom line the gateway stays down: after four tries, with waits of 1, 2 and 4 s, the message goes to the dead-letter queue.
  1. An SMS worker takes evt-2:sms from the queue (it's leased, not deleted, until the worker acknowledges it).
  2. It checks the status store: if this message key is already sent, it acknowledges and stops. This catches redeliveries after a crash.
  3. It marks the message sending and calls the gateway with a timeout (e.g. 5 s), passing the message key as the provider's idempotency key where the provider supports one.
  4. Success: record sent with the provider's message ID, acknowledge the queue message.
  5. Transient failure (timeout, 5xx, throttled): schedule a retry after base × 2^(attempt-1) plus random jitter, acknowledge the original. After the maximum attempts, move it to the dead-letter queue.
  6. Permanent failure (invalid number, unknown address): don't retry. Dead-letter it and mark the contact inactive so we stop trying that address.
  7. Later, the provider's webhook reports delivered (or failed); the status is updated through by_provider_msg.

Component deep dive

Duplicates: where they come from and what stops each one

SourceExampleDefence
Caller retriesCheckout's request times out, it calls againIdempotency key + SET NX at the API
Two copies at onceA retry storm sends 50 identical requests in parallelThe check-and-accept is one atomic step (the Go test TestDuplicateRequestsQueueOnce runs 50 at once under -race)
Queue redeliveryThe broker delivers at least onceWorker checks the status store before sending
Worker crash after sendingGateway accepted the SMS, worker died before recording itProvider idempotency key where supported; otherwise this is the small window you accept

Exactly-once delivery to a phone is not something a broker setting gives you: the last step is an external call that can succeed while our acknowledgement fails. What we can do is make every step idempotent and shrink the unsafe window to "crashed in the few milliseconds between the provider's answer and our status write". That's an honest answer interviewers like.

A queue per channel, and a lane for urgent messages

one queue for everythingshared queueworkersall busy on SMSSMS gatewayslow: 5 s a callpushemailsmspush and email wait behind slow SMS callsa queue per channelpush queuesms queueemail queuepush workerssms workersemail workersAPNs / FCMSMS gatewayEmail (ESP)sentwaitingsentonly SMS waits; push and email flow
Press play. With one shared queue, every worker ends up waiting on the slow SMS gateway, and push and email messages sit behind them. With a queue and worker pool per channel, only SMS waits; push and email keep flowing.

Separate queues give each channel its own speed, its own worker count tuned to its provider's rate limit, and its own failure domain. The same idea applies to priority: OTPs and security alerts go to a high-priority topic with dedicated workers, so a 25-million-message campaign sitting in the normal queue never delays a login code. In Kafka terms: one topic per channel (and per priority), partitioned by user ID.

Retries, backoff and the dead-letter queue

Retrying immediately makes an overloaded provider worse: every worker hammers it at once. Exponential backoff spaces the attempts out (1, 2, 4, 8... in our demo seconds; minutes in production), and jitter (a random part of the wait) stops thousands of messages that failed together from retrying together. After a maximum number of attempts, or after a permanent error, the message goes to a dead-letter queue: it isn't lost, an alert fires if the DLQ grows, and after the cause is fixed someone can replay it. A circuit breaker sits on top: if a provider fails most calls for a while, stop calling it for a short time (or switch to a second provider) instead of piling up retries.

Delayed retries need somewhere to wait. Options: a "retry" topic per delay level (sms-retry-1m, sms-retry-10m) that workers read only when the delay has passed, or a scheduled-jobs table/sorted set keyed by due time. Our core uses a Due time on each message, which is the sorted-set idea in miniature.

Preferences, quiet hours and limits

Two classes of messages with different rules:

Transactional (OTP, order, payment)Marketing
Opt-outNot allowed (except push permission at the OS level)Per channel
Quiet hoursIgnored: an OTP at 2 a.m. is expectedDelayed to the end of the window, in the user's time zone
Daily limitOnly abuse limitse.g. 1 per day per user

The rules run at accept time (opt-outs, limits) and are rechecked at send time for messages that waited (a user may opt out while a campaign is queued). Preferences are cached for a short time (seconds to a minute); the recheck makes that staleness harmless.

Campaigns: fan-out without flooding

A campaign to 25 million users is not one API call with 25 million recipients. A scheduler reads the segment in pages, creates requests in batches of, say, 10,000, with idempotency keys like camp-7:<user_id> (so a restarted scheduler doesn't double-send), and feeds them at a controlled rate into the normal-priority queues. The total rate is capped below the providers' limits and below what would delay other traffic. Campaigns can be paused and resumed because progress is just "how far through the segment we are".

Provider failover

Each channel can have two providers. Workers track error rate and latency per provider; when the primary's circuit breaker opens, traffic goes to the secondary. For SMS this also helps with cost and with operator-specific delivery problems. The message key travels with the message, so status and webhooks still line up whichever provider sent it.

The core, runnable

This program is the heart of the design, small enough to read in one sitting: templates with parameters, users with contacts, opt-outs and quiet hours, an API-side Send that deduplicates and applies the rules atomically, one queue per channel, and workers that call providers, retry transient failures with exponential backoff and dead-letter the rest. A simulated clock makes the timing exact, and fake providers fail on cue.

notify/
├── go.mod
├── model.go           Channel, Kind, Template, User, Request, Message, Render, Clock
├── provider.go        Outcome, Provider, FakeProvider (scripted failures)
├── service.go         Service: Send (dedup, prefs, quiet hours, limits), Work/Step (retry, DLQ)
├── notify_test.go     duplicates under -race, backoff timing, DLQ, quiet hours, opt-outs, 4 workers
└── cmd/demo/main.go   order updates, a duplicate, a flaky gateway, a campaign at night
// model.go
package notify

import (
	"errors"
	"fmt"
	"strings"
)

// Channel is one way to reach a person. Each channel has its own queue and its own provider,
// so a slow SMS gateway never holds up push or email.
type Channel string

const (
	Push  Channel = "push"
	SMS   Channel = "sms"
	Email Channel = "email"
)

// Kind decides which rules apply. Users can't opt out of transactional messages (an order
// update, a one-time code), but marketing respects opt-outs, quiet hours and a daily limit.
type Kind int

const (
	Transactional Kind = iota
	Marketing
)

// Template is a message shape: which channels it goes to and its text with {placeholders}.
type Template struct {
	Kind     Kind
	Channels []Channel
	Text     string
}

// User is what the preference service knows about a person.
type User struct {
	Contacts  map[Channel]string // device token, phone number, email address
	OptOut    map[Channel]bool   // marketing opt-outs per channel
	QuietFrom int                // quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
	QuietTo   int
}

// Request is what another service sends us: "tell this user about this event".
// ID is the idempotency key: the same ID twice means the same notification.
type Request struct {
	ID, User, Template string
	Params             map[string]string
}

// Message is one notification on one channel, waiting in that channel's queue.
type Message struct {
	Key     string // request ID + channel, e.g. "evt-1:sms"
	Channel Channel
	To      string
	Body    string
	Kind    Kind
	Attempt int   // attempts made so far
	Due     int64 // simulated seconds; not sent before this
	seq     int   // enqueue order, breaks ties between equal Due times
}

var (
	ErrDuplicate       = errors.New("duplicate request, already accepted")
	ErrUnknownUser     = errors.New("unknown user")
	ErrUnknownTemplate = errors.New("unknown template")
	ErrRateLimited     = errors.New("marketing limit reached for today")
)

// Render fills {name} placeholders; a missing value is an error, never a blank in a message.
func Render(text string, params map[string]string) (string, error) {
	var b strings.Builder
	for {
		i := strings.IndexByte(text, '{')
		if i < 0 {
			b.WriteString(text)
			return b.String(), nil
		}
		j := strings.IndexByte(text[i:], '}')
		if j < 0 {
			return "", fmt.Errorf("unclosed placeholder in %q", text)
		}
		name := text[i+1 : i+j]
		v, ok := params[name]
		if !ok {
			return "", fmt.Errorf("missing parameter %q", name)
		}
		b.WriteString(text[:i])
		b.WriteString(v)
		text = text[i+j+1:]
	}
}

// Clock formats simulated seconds as "D1 21:59:50".
func Clock(t int64) string {
	s := t % 86400
	return fmt.Sprintf("D%d %02d:%02d:%02d", t/86400+1, s/3600, s/60%60, s%60)
}
// provider.go
package notify

import "sync"

// Outcome is what a provider (APNs/FCM, an SMS gateway, an email service) said.
type Outcome int

const (
	OK        Outcome = iota
	Transient         // timeout, 5xx, throttled: worth retrying
	Permanent         // invalid number, unknown address: retrying won't help
)

func (o Outcome) String() string {
	switch o {
	case OK:
		return "delivered"
	case Transient:
		return "failed (timeout)"
	default:
		return "rejected (bad address)"
	}
}

// Provider sends one message on one channel.
type Provider interface {
	Send(m Message) Outcome
}

// FakeProvider plays back scripted outcomes per address (attempt 1, 2, ...); after the
// script runs out, sends succeed. It also counts sends per message key.
type FakeProvider struct {
	mu     sync.Mutex
	Script map[string][]Outcome
	Sent   map[string]int
}

func NewFakeProvider(script map[string][]Outcome) *FakeProvider {
	if script == nil {
		script = map[string][]Outcome{}
	}
	return &FakeProvider{Script: script, Sent: map[string]int{}}
}

func (p *FakeProvider) Send(m Message) Outcome {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.Sent[m.Key]++
	if s := p.Script[m.To]; m.Attempt < len(s) {
		return s[m.Attempt]
	}
	return OK
}
// service.go
// Package notify is the core of a notification system: requests are deduplicated, checked
// against preferences, rendered from templates and put on one queue per channel; workers send
// them through providers, retry transient failures with exponential backoff, and move
// messages that can't be delivered to a dead-letter queue.
package notify

import (
	"strconv"
	"sync"
)

// MaxAttempts is how many times a message is tried before it goes to the dead-letter queue.
const MaxAttempts = 4

// Backoff is the wait before retry number n (1, 2, 4, ... seconds). Production adds jitter
// and uses minutes, not seconds; the demo keeps it exact so every language prints the same.
func Backoff(attempt int) int64 { return int64(1) << (attempt - 1) }

// Event is one delivery attempt, for logs and the demo.
type Event struct {
	Time    int64
	Msg     Message
	Outcome Outcome
	RetryAt int64 // set when the message was put back on the queue
	Dead    bool  // set when it went to the dead-letter queue
}

// Service accepts requests and runs the per-channel queues.
type Service struct {
	mu                 sync.Mutex
	users              map[string]User
	templates          map[string]Template
	providers          map[Channel]Provider
	seen               map[string]bool // accepted request IDs (production: a store with a TTL)
	marketing          map[string]int  // "user/day" -> marketing messages accepted
	MaxMarketingPerDay int
	queues             map[Channel][]Message
	seq                int
	Dead               []Message
	Delivered          int
}

func NewService(users map[string]User, templates map[string]Template, providers map[Channel]Provider) *Service {
	return &Service{
		users: users, templates: templates, providers: providers,
		seen: map[string]bool{}, marketing: map[string]int{}, MaxMarketingPerDay: 1,
		queues: map[Channel][]Message{},
	}
}

func inQuiet(u User, now int64) bool {
	if u.QuietFrom == u.QuietTo {
		return false
	}
	h := int(now % 86400 / 3600)
	if u.QuietFrom < u.QuietTo {
		return h >= u.QuietFrom && h < u.QuietTo
	}
	return h >= u.QuietFrom || h < u.QuietTo
}

// quietEnd is the next time the clock reads QuietTo:00.
func quietEnd(u User, now int64) int64 {
	day := now / 86400 * 86400
	end := day + int64(u.QuietTo)*3600
	if end <= now {
		end += 86400
	}
	return end
}

// Accepted says what happened to a request: channels queued, channels skipped (with the
// reason), and when sending starts (later than now during quiet hours).
type Accepted struct {
	Queued  []Channel
	Skipped []string
	Due     int64
}

// Send accepts one request. The whole check-and-accept runs under one lock, so two copies of
// a request racing each other can't both get through.
func (s *Service) Send(r Request, now int64) (Accepted, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.seen[r.ID] {
		return Accepted{}, ErrDuplicate
	}
	u, ok := s.users[r.User]
	if !ok {
		return Accepted{}, ErrUnknownUser
	}
	t, ok := s.templates[r.Template]
	if !ok {
		return Accepted{}, ErrUnknownTemplate
	}
	body, err := Render(t.Text, r.Params)
	if err != nil {
		return Accepted{}, err
	}
	var a Accepted
	dayKey := r.User + "/" + strconv.FormatInt(now/86400, 10)
	if t.Kind == Marketing && s.marketing[dayKey] >= s.MaxMarketingPerDay {
		return Accepted{}, ErrRateLimited
	}
	a.Due = now
	if t.Kind == Marketing && inQuiet(u, now) {
		a.Due = quietEnd(u, now)
	}
	for _, ch := range t.Channels {
		to, ok := u.Contacts[ch]
		switch {
		case !ok:
			a.Skipped = append(a.Skipped, string(ch)+": no address")
		case t.Kind == Marketing && u.OptOut[ch]:
			a.Skipped = append(a.Skipped, string(ch)+": opted out")
		default:
			s.seq++
			s.queues[ch] = append(s.queues[ch], Message{Key: r.ID + ":" + string(ch), Channel: ch, To: to, Body: body, Kind: t.Kind, Due: a.Due, seq: s.seq})
			a.Queued = append(a.Queued, ch)
		}
	}
	s.seen[r.ID] = true
	if t.Kind == Marketing && len(a.Queued) > 0 {
		s.marketing[dayKey]++
	}
	return a, nil
}

// take removes the earliest due message (on one channel, or on any if ch is ""), if one is due.
func (s *Service) take(ch Channel, now int64) (Message, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	bestCh, best := Channel(""), -1
	for _, c := range []Channel{Push, SMS, Email} {
		if ch != "" && c != ch {
			continue
		}
		for i, m := range s.queues[c] {
			if m.Due > now {
				continue
			}
			if best < 0 || m.Due < s.queues[bestCh][best].Due ||
				(m.Due == s.queues[bestCh][best].Due && m.seq < s.queues[bestCh][best].seq) {
				bestCh, best = c, i
			}
		}
	}
	if best < 0 {
		return Message{}, false
	}
	q := s.queues[bestCh]
	m := q[best]
	s.queues[bestCh] = append(q[:best:best], q[best+1:]...)
	return m, true
}

// deliver calls the provider outside the lock (it's network I/O), then records the result.
func (s *Service) deliver(m Message, now int64) Event {
	out := s.providers[m.Channel].Send(m)
	m.Attempt++
	ev := Event{Time: now, Msg: m, Outcome: out}
	s.mu.Lock()
	defer s.mu.Unlock()
	switch {
	case out == OK:
		s.Delivered++
	case out == Transient && m.Attempt < MaxAttempts:
		m.Due = now + Backoff(m.Attempt)
		s.queues[m.Channel] = append(s.queues[m.Channel], m)
		ev.RetryAt = m.Due
	default:
		s.Dead = append(s.Dead, m)
		ev.Dead = true
	}
	return ev
}

// Work is one worker step on one channel's queue: send the earliest due message, if any.
func (s *Service) Work(ch Channel, now int64) (Event, bool) {
	m, ok := s.take(ch, now)
	if !ok {
		return Event{}, false
	}
	return s.deliver(m, now), true
}

// Step sends the earliest due message on any channel (the demo's single worker).
func (s *Service) Step(now int64) (Event, bool) { return s.Work("", now) }

// NextDue is the earliest time any queued message becomes due.
func (s *Service) NextDue() (int64, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	var next int64
	found := false
	for _, q := range s.queues {
		for _, m := range q {
			if !found || m.Due < next {
				next, found = m.Due, true
			}
		}
	}
	return next, found
}

// Pending is how many messages are still queued.
func (s *Service) Pending() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	n := 0
	for _, q := range s.queues {
		n += len(q)
	}
	return n
}
// cmd/demo/main.go
// Command demo sends a few order updates and a marketing campaign through the notification
// core, with a flaky SMS gateway, a bad email address, quiet hours and duplicate requests.
package main

import (
	"fmt"
	"strings"

	"notify"
)

func main() {
	users := map[string]notify.User{
		"rafi": {Contacts: map[notify.Channel]string{notify.Push: "tok-rafi", notify.SMS: "+8801711000001", notify.Email: "rafi@example.com"},
			QuietFrom: 22, QuietTo: 8},
		"nusrat": {Contacts: map[notify.Channel]string{notify.SMS: "+8801811000002", notify.Email: "nusrat@example.com"},
			OptOut: map[notify.Channel]bool{notify.Email: true}},
		"tanvir": {Contacts: map[notify.Channel]string{notify.Email: "tanvir@bad-domain"}},
	}
	templates := map[string]notify.Template{
		"order_shipped": {Kind: notify.Transactional, Channels: []notify.Channel{notify.Push, notify.SMS}, Text: "Order {order} is on the way, arriving {day}"},
		"eid_sale":      {Kind: notify.Marketing, Channels: []notify.Channel{notify.Push, notify.Email}, Text: "Eid sale: {pct}% off until Friday"},
	}
	providers := map[notify.Channel]notify.Provider{
		notify.Push:  notify.NewFakeProvider(nil),
		notify.SMS:   notify.NewFakeProvider(map[string][]notify.Outcome{"+8801811000002": {notify.Transient, notify.Transient}}),
		notify.Email: notify.NewFakeProvider(map[string][]notify.Outcome{"tanvir@bad-domain": {notify.Permanent}}),
	}
	s := notify.NewService(users, templates, providers)

	send := func(now int64, r notify.Request) {
		a, err := s.Send(r, now)
		head := fmt.Sprintf("  %-16s %-13s %-6s ->", r.ID, r.Template, r.User)
		if err != nil {
			fmt.Printf("%s error: %v\n", head, err)
			return
		}
		parts := []string{}
		for _, ch := range a.Queued {
			parts = append(parts, string(ch))
		}
		line := "queued " + strings.Join(parts, ", ")
		if a.Due > now {
			line += " for " + notify.Clock(a.Due) + " (quiet hours)"
		}
		if len(a.Queued) == 0 {
			line = "nothing to send"
		}
		if len(a.Skipped) > 0 {
			line += " (skipped " + strings.Join(a.Skipped, "; ") + ")"
		}
		fmt.Printf("%s %s\n", head, line)
	}
	run := func(until int64) {
		for {
			next, ok := s.NextDue()
			if !ok || next > until {
				return
			}
			ev, _ := s.Step(next)
			line := ev.Outcome.String()
			switch {
			case ev.RetryAt > 0:
				line += ", retry at " + notify.Clock(ev.RetryAt)
			case ev.Dead:
				line += ", moved to dead letters"
			default:
				line += ": " + ev.Msg.Body
			}
			fmt.Printf("  %s %-5s %-19s try %d: %s\n", notify.Clock(ev.Time), ev.Msg.Channel, ev.Msg.Key, ev.Msg.Attempt, line)
		}
	}

	t1 := int64(21*3600 + 59*60 + 50)
	fmt.Println("D1 21:59:50, the order service sends three requests:")
	send(t1, notify.Request{ID: "evt-1", User: "rafi", Template: "order_shipped", Params: map[string]string{"order": "BD-7781", "day": "Sunday"}})
	send(t1, notify.Request{ID: "evt-1", User: "rafi", Template: "order_shipped", Params: map[string]string{"order": "BD-7781", "day": "Sunday"}})
	send(t1, notify.Request{ID: "evt-2", User: "nusrat", Template: "order_shipped", Params: map[string]string{"order": "BD-7790", "day": "Monday"}})
	fmt.Println("workers:")
	run(t1 + 10)

	t2 := int64(22*3600 + 5)
	fmt.Println("D1 22:00:05, the marketing team starts a campaign:")
	send(t2, notify.Request{ID: "camp-7:rafi", User: "rafi", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
	send(t2, notify.Request{ID: "camp-7:nusrat", User: "nusrat", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
	send(t2, notify.Request{ID: "camp-7:tanvir", User: "tanvir", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
	send(t2, notify.Request{ID: "camp-8:rafi", User: "rafi", Template: "eid_sale", Params: map[string]string{"pct": "40"}})
	send(t2, notify.Request{ID: "camp-9:tanvir", User: "tanvir", Template: "eid_sale", Params: map[string]string{}})
	fmt.Println("workers:")
	run(2 * 86400)
	fmt.Printf("delivered %d, dead letters %d, still queued %d\n", s.Delivered, len(s.Dead), s.Pending())
}

Running the demo:

$ go run ./cmd/demo
D1 21:59:50, the order service sends three requests:
  evt-1            order_shipped rafi   -> queued push, sms
  evt-1            order_shipped rafi   -> error: duplicate request, already accepted
  evt-2            order_shipped nusrat -> queued sms (skipped push: no address)
workers:
  D1 21:59:50 push  evt-1:push          try 1: delivered: Order BD-7781 is on the way, arriving Sunday
  D1 21:59:50 sms   evt-1:sms           try 1: delivered: Order BD-7781 is on the way, arriving Sunday
  D1 21:59:50 sms   evt-2:sms           try 1: failed (timeout), retry at D1 21:59:51
  D1 21:59:51 sms   evt-2:sms           try 2: failed (timeout), retry at D1 21:59:53
  D1 21:59:53 sms   evt-2:sms           try 3: delivered: Order BD-7790 is on the way, arriving Monday
D1 22:00:05, the marketing team starts a campaign:
  camp-7:rafi      eid_sale      rafi   -> queued push, email for D2 08:00:00 (quiet hours)
  camp-7:nusrat    eid_sale      nusrat -> nothing to send (skipped push: no address; email: opted out)
  camp-7:tanvir    eid_sale      tanvir -> queued email (skipped push: no address)
  camp-8:rafi      eid_sale      rafi   -> error: marketing limit reached for today
  camp-9:tanvir    eid_sale      tanvir -> error: missing parameter "pct"
workers:
  D1 22:00:05 email camp-7:tanvir:email try 1: rejected (bad address), moved to dead letters
  D2 08:00:00 push  camp-7:rafi:push    try 1: delivered: Eid sale: 30% off until Friday
  D2 08:00:00 email camp-7:rafi:email   try 1: delivered: Eid sale: 30% off until Friday
delivered 5, dead letters 1, still queued 0

Reading the output:

  • The duplicate: the second evt-1 is rejected as already accepted. Rafi gets one push and one SMS, not two of each.
  • Skipped channels: Nusrat has no device token, so evt-2 goes by SMS only, and the response says why push was skipped. The caller learns exactly what happened.
  • The flaky gateway: Nusrat's SMS times out at 21:59:50, is retried 1 s later, times out again, waits 2 s, and is delivered on try 3 at 21:59:53. Rafi's messages, queued at the same moment, went out first and weren't held up.
  • Quiet hours: at 22:00:05 Rafi is in quiet hours (22 to 8), so the campaign is queued for D2 08:00:00 and delivered then. The order updates an hour earlier weren't delayed, because quiet hours apply to marketing only.
  • Opt-out: Nusrat opted out of marketing email and has no push token, so the campaign has nothing to send to her. That's a successful request with zero messages, not an error.
  • Permanent failure: Tanvir's email address is bad. The provider rejects it on try 1 and it goes straight to dead letters; retrying would only waste calls.
  • Limits and validation: a second campaign to Rafi the same day hits the daily marketing limit; a request missing pct is refused instead of sending "Eid sale: % off".
  • Totals: 5 delivered, 1 dead letter, nothing left queued.

The tests check what matters most: 50 identical requests at once produce exactly one queued message (-race), retries happen at 0, 1, 3 and 7 s and then go to the DLQ, permanent failures are not retried, quiet hours delay only marketing and wrap around midnight, opt-outs don't block transactional messages, a failed request doesn't burn its idempotency key, and four workers draining one queue send each message exactly once. Releasing the lock between the duplicate check and the accept makes the duplicate test fail (4 copies got through in one run), so the test really guards that step.

$ go test -race -v ./...
=== RUN   TestDuplicateRequestsQueueOnce
--- PASS: TestDuplicateRequestsQueueOnce (0.00s)
=== RUN   TestRetriesBackOffThenDeadLetter
--- PASS: TestRetriesBackOffThenDeadLetter (0.00s)
=== RUN   TestPermanentFailureIsNotRetried
--- PASS: TestPermanentFailureIsNotRetried (0.00s)
=== RUN   TestQuietHoursDelayMarketingOnly
--- PASS: TestQuietHoursDelayMarketingOnly (0.00s)
=== RUN   TestOptOutAppliesToMarketingOnly
--- PASS: TestOptOutAppliesToMarketingOnly (0.00s)
=== RUN   TestMarketingLimitAndValidation
--- PASS: TestMarketingLimitAndValidation (0.00s)
=== RUN   TestConcurrentWorkersSendEachMessageOnce
--- PASS: TestConcurrentWorkersSendEachMessageOnce (0.02s)
PASS
ok  	notify	1.034s
?   	notify/cmd/demo	[no test files]

The same core in five more languages

Single-threaded versions with the same classes and the same output. The worker loop is sequential here; the Go version is the one that runs workers concurrently. What differs:

  • Python: messages are frozen dataclasses, and a retry creates a changed copy with replace(), so a message on the queue is never edited in place.
  • TypeScript: Outcome is a const object instead of an enum, because Node runs .ts files by stripping types and enums aren't just types. SendRequest and Attempt avoid clashing with the DOM's global Request and Event.
  • Rust: Channel, Kind and Outcome are enums matched exhaustively, so adding a channel forces every match to handle it. render uses let ... else for early returns.
  • Java: records for immutable messages; send and take are synchronized and the provider call happens outside the lock, as in Go.
  • C++: a std::mutex with std::lock_guard around the same critical sections; std::optional for "no message due".
"""A notification system's core: requests are deduplicated, checked against preferences,
rendered from templates and put on one queue per channel; workers send them through
providers, retry transient failures with exponential backoff, and move messages that can't be
delivered to a dead-letter queue."""
from dataclasses import dataclass, field, replace
from enum import Enum

PUSH, SMS, EMAIL = "push", "sms", "email"
CHANNELS = [PUSH, SMS, EMAIL]
TRANSACTIONAL, MARKETING = 0, 1
MAX_ATTEMPTS = 4


class Outcome(Enum):
    OK = "delivered"
    TRANSIENT = "failed (timeout)"
    PERMANENT = "rejected (bad address)"


@dataclass
class Template:
    kind: int
    channels: list[str]
    text: str


@dataclass
class User:
    contacts: dict[str, str]
    opt_out: set[str] = field(default_factory=set)  # marketing opt-outs per channel
    quiet_from: int = 0  # quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
    quiet_to: int = 0


@dataclass
class Request:
    id: str  # the idempotency key
    user: str
    template: str
    params: dict[str, str]


@dataclass(frozen=True)
class Message:
    key: str  # request ID + channel, e.g. "evt-1:sms"
    channel: str
    to: str
    body: str
    kind: int
    attempt: int  # attempts made so far
    due: int  # simulated seconds; not sent before this
    seq: int  # enqueue order, breaks ties


@dataclass
class Accepted:
    queued: list[str] = field(default_factory=list)
    skipped: list[str] = field(default_factory=list)
    due: int = 0


@dataclass
class Event:
    time: int
    msg: Message
    outcome: Outcome
    retry_at: int = 0
    dead: bool = False


class Rejected(Exception):
    pass


def render(text: str, params: dict[str, str]) -> str:
    """Fills {name} placeholders; a missing value is an error, never a blank in a message."""
    out = []
    while True:
        i = text.find("{")
        if i < 0:
            return "".join(out) + text
        j = text.find("}", i)
        if j < 0:
            raise Rejected(f"unclosed placeholder in {text!r}")
        name = text[i + 1 : j]
        if name not in params:
            raise Rejected(f'missing parameter "{name}"')
        out += [text[:i], params[name]]
        text = text[j + 1 :]


def clock(t: int) -> str:
    s = t % 86400
    return f"D{t // 86400 + 1} {s // 3600:02d}:{s // 60 % 60:02d}:{s % 60:02d}"


def backoff(attempt: int) -> int:
    """Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter)."""
    return 1 << (attempt - 1)


class FakeProvider:
    """Plays back scripted outcomes per address; after the script runs out, sends succeed."""

    def __init__(self, script: dict[str, list[Outcome]] | None = None):
        self.script = script or {}
        self.sent: dict[str, int] = {}

    def send(self, m: Message) -> Outcome:
        self.sent[m.key] = self.sent.get(m.key, 0) + 1
        s = self.script.get(m.to, [])
        return s[m.attempt] if m.attempt < len(s) else Outcome.OK


def in_quiet(u: User, now: int) -> bool:
    if u.quiet_from == u.quiet_to:
        return False
    h = now % 86400 // 3600
    if u.quiet_from < u.quiet_to:
        return u.quiet_from <= h < u.quiet_to
    return h >= u.quiet_from or h < u.quiet_to


def quiet_end(u: User, now: int) -> int:
    end = now // 86400 * 86400 + u.quiet_to * 3600
    return end + 86400 if end <= now else end


class Service:
    def __init__(self, users: dict[str, User], templates: dict[str, Template], providers: dict[str, FakeProvider]):
        self.users, self.templates, self.providers = users, templates, providers
        self.seen: set[str] = set()  # accepted request IDs (production: a store with a TTL)
        self.marketing: dict[str, int] = {}  # "user/day" -> marketing messages accepted
        self.max_marketing_per_day = 1
        self.queues: dict[str, list[Message]] = {c: [] for c in CHANNELS}
        self.seq = 0
        self.dead: list[Message] = []
        self.delivered = 0

    def send(self, r: Request, now: int) -> Accepted:
        if r.id in self.seen:
            raise Rejected("duplicate request, already accepted")
        u = self.users.get(r.user)
        if u is None:
            raise Rejected("unknown user")
        t = self.templates.get(r.template)
        if t is None:
            raise Rejected("unknown template")
        body = render(t.text, r.params)
        day_key = f"{r.user}/{now // 86400}"
        if t.kind == MARKETING and self.marketing.get(day_key, 0) >= self.max_marketing_per_day:
            raise Rejected("marketing limit reached for today")
        a = Accepted(due=now)
        if t.kind == MARKETING and in_quiet(u, now):
            a.due = quiet_end(u, now)
        for ch in t.channels:
            if ch not in u.contacts:
                a.skipped.append(f"{ch}: no address")
            elif t.kind == MARKETING and ch in u.opt_out:
                a.skipped.append(f"{ch}: opted out")
            else:
                self.seq += 1
                self.queues[ch].append(Message(f"{r.id}:{ch}", ch, u.contacts[ch], body, t.kind, 0, a.due, self.seq))
                a.queued.append(ch)
        self.seen.add(r.id)
        if t.kind == MARKETING and a.queued:
            self.marketing[day_key] = self.marketing.get(day_key, 0) + 1
        return a

    def _take(self, now: int) -> Message | None:
        due = [m for c in CHANNELS for m in self.queues[c] if m.due <= now]
        if not due:
            return None
        m = min(due, key=lambda m: (m.due, m.seq))
        self.queues[m.channel].remove(m)
        return m

    def step(self, now: int) -> Event | None:
        m = self._take(now)
        if m is None:
            return None
        out = self.providers[m.channel].send(m)
        m = replace(m, attempt=m.attempt + 1)
        ev = Event(now, m, out)
        if out == Outcome.OK:
            self.delivered += 1
        elif out == Outcome.TRANSIENT and m.attempt < MAX_ATTEMPTS:
            m = replace(m, due=now + backoff(m.attempt))
            self.queues[m.channel].append(m)
            ev.retry_at = m.due
        else:
            self.dead.append(m)
            ev.dead = True
        return ev

    def next_due(self) -> int | None:
        dues = [m.due for q in self.queues.values() for m in q]
        return min(dues) if dues else None

    def pending(self) -> int:
        return sum(len(q) for q in self.queues.values())


def main() -> None:
    users = {
        "rafi": User({PUSH: "tok-rafi", SMS: "+8801711000001", EMAIL: "rafi@example.com"}, quiet_from=22, quiet_to=8),
        "nusrat": User({SMS: "+8801811000002", EMAIL: "nusrat@example.com"}, opt_out={EMAIL}),
        "tanvir": User({EMAIL: "tanvir@bad-domain"}),
    }
    templates = {
        "order_shipped": Template(TRANSACTIONAL, [PUSH, SMS], "Order {order} is on the way, arriving {day}"),
        "eid_sale": Template(MARKETING, [PUSH, EMAIL], "Eid sale: {pct}% off until Friday"),
    }
    providers = {
        PUSH: FakeProvider(),
        SMS: FakeProvider({"+8801811000002": [Outcome.TRANSIENT, Outcome.TRANSIENT]}),
        EMAIL: FakeProvider({"tanvir@bad-domain": [Outcome.PERMANENT]}),
    }
    s = Service(users, templates, providers)

    def send(now: int, r: Request) -> None:
        head = f"  {r.id:<16} {r.template:<13} {r.user:<6} ->"
        try:
            a = s.send(r, now)
        except Rejected as e:
            print(f"{head} error: {e}")
            return
        line = "queued " + ", ".join(a.queued)
        if a.due > now:
            line += f" for {clock(a.due)} (quiet hours)"
        if not a.queued:
            line = "nothing to send"
        if a.skipped:
            line += " (skipped " + "; ".join(a.skipped) + ")"
        print(f"{head} {line}")

    def run(until: int) -> None:
        while (nxt := s.next_due()) is not None and nxt <= until:
            ev = s.step(nxt)
            assert ev is not None
            line = ev.outcome.value
            if ev.retry_at:
                line += f", retry at {clock(ev.retry_at)}"
            elif ev.dead:
                line += ", moved to dead letters"
            else:
                line += f": {ev.msg.body}"
            print(f"  {clock(ev.time)} {ev.msg.channel:<5} {ev.msg.key:<19} try {ev.msg.attempt}: {line}")

    t1 = 21 * 3600 + 59 * 60 + 50
    print("D1 21:59:50, the order service sends three requests:")
    send(t1, Request("evt-1", "rafi", "order_shipped", {"order": "BD-7781", "day": "Sunday"}))
    send(t1, Request("evt-1", "rafi", "order_shipped", {"order": "BD-7781", "day": "Sunday"}))
    send(t1, Request("evt-2", "nusrat", "order_shipped", {"order": "BD-7790", "day": "Monday"}))
    print("workers:")
    run(t1 + 10)

    t2 = 22 * 3600 + 5
    print("D1 22:00:05, the marketing team starts a campaign:")
    send(t2, Request("camp-7:rafi", "rafi", "eid_sale", {"pct": "30"}))
    send(t2, Request("camp-7:nusrat", "nusrat", "eid_sale", {"pct": "30"}))
    send(t2, Request("camp-7:tanvir", "tanvir", "eid_sale", {"pct": "30"}))
    send(t2, Request("camp-8:rafi", "rafi", "eid_sale", {"pct": "40"}))
    send(t2, Request("camp-9:tanvir", "tanvir", "eid_sale", {}))
    print("workers:")
    run(2 * 86400)
    print(f"delivered {s.delivered}, dead letters {len(s.dead)}, still queued {s.pending()}")


if __name__ == "__main__":
    main()

Scaling strategy

These stages are illustrative; real thresholds depend on hardware and traffic shape.

StageTrafficWhat breaks firstWhat changes
1. 100 usersA few messages an hourNothingSend from the app inside a background job (not the request), using the provider's SDK. A table of sent messages.
2. 10K usersA few per minuteCallers waiting on slow providers; lost messages when a job crashesA small notification module with a database-backed job queue, retries with backoff, templates in one place, an opt-out flag.
3. 100K usersSeveral per secondOne slow channel blocks the others; duplicate sends after retriesSeparate queues and workers per channel, idempotency keys with a dedup store, a dead-letter queue, a status table.
4. 1M usersHundreds per second, campaign burstsCampaigns delay OTPs; provider rate limits; preference reads on every messagePriority lanes, a campaign scheduler with throttling, a preference cache, quiet hours, a broker like Kafka or a managed queue.
5. 10M+ usersThousands per secondOne provider's outages; status writes; multiple regions and languagesTwo providers per channel with circuit breakers, a wide-column status store, per-region workers near providers, delivery webhooks, localisation at scale.

At every step the thing that breaks is behavioural (waiting, duplicates, blocking), not raw size. A startup with 10,000 users does not need Kafka; it needs a job queue, retries and an idempotency key.

Failure scenarios

FailureWhat happensDetectionRecoveryData safetyUser impact
Provider slow or downCalls time out; retries pile upError rate and latency per providerBackoff, circuit breaker, switch to the secondary providerMessages wait in the queue, nothing lostDelays on that channel only
Worker crash mid-sendThe leased message is redeliveredQueue redelivery, worker healthAnother worker takes it; status check skips messages already sentAt-least-once; the tiny post-send window can duplicateRarely, one duplicate
Queue broker failureAPI can't enqueueEnqueue errorsBroker replicas take over; the API returns 503 so callers retry with the same keyCallers retry; dedup prevents doublesNotifications delayed seconds to minutes
Redis (dedup) downAPI can't check duplicatesRedis errorsFall back to a unique insert on the idempotency key in the status store (slower), or accept a short risk of duplicates for non-critical templatesDedup weakened briefly, no message lossPossibly a duplicate
Preference DB downCan't read opt-outsErrors, cache missesServe from cache; for marketing, if preferences can't be read, don't send (fail closed)Opt-outs respectedMarketing delayed; transactional continues
Duplicate requestsCaller retriesDedup hits metricSame key returns the first resultNo double sendsNone
Campaign flood25M messages queued at onceQueue depth and age per priorityThrottled scheduler; separate priority lanes; pause buttonQueued, not lostCampaign slower; OTPs unaffected
Bad template deployEvery message renders wrongRender errors, spike in user reportsTemplates are versioned: roll back to the previous versionMessages not yet sent use the fixed versionA few wrong messages at most
Dead letters growingMany messages fail permanentlyDLQ size alertInvestigate (expired tokens, bad numbers, provider change), fix, replayDLQ keeps themDelayed until replay

The guiding rules: never lose an accepted request (it's in the queue or the DLQ), fail closed for marketing (if in doubt, don't send), and fail open for OTPs (try another provider rather than give up).

Reliability and observability

  • SLIs: accept success rate and latency; time from accept to provider "sent" per priority (OTP, transactional, marketing); delivery success rate per channel and provider; DLQ size.
  • SLOs: 99.99% of accepts succeed; 99% of OTPs are sent within 5 s; 99% of transactional messages within 1 minute; DLQ under 0.1% of daily volume.
  • SLA: for internal callers, publish the SLOs; for external customers (if sold as a service), a looser monthly availability number.
  • Metrics: queue depth and age of the oldest message per topic (age catches stuck consumers better than depth), retries per provider, circuit-breaker state, dedup hit rate, skipped-by-preference counts, cost per day per channel.
  • Alerts: page on OTP latency SLO burn and on a provider failing over; ticket on DLQ growth and on rising permanent-failure rates (often expired device tokens).
  • Logs: one structured line per attempt with message key, channel, provider, outcome and latency; never log OTP codes or full message bodies.
  • Tracing: a trace from the producer's request through the API, queue and worker to the provider call; the message key is the correlation ID.
  • Health checks: workers report healthy only if they can reach the broker; a provider being down is a metric, not a reason to restart workers.
  • Disaster recovery: broker replicated across zones; status store multi-zone; the DLQ and campaign progress let you replay after an outage.

Security

  • Only templates, never free text: callers can't send arbitrary content, which limits damage from a compromised service and stops phishing through our own sender ID.
  • Authentication and authorisation: mTLS or signed service tokens; per-caller allowed templates (the auth service can send OTPs, the marketing tool can send marketing, nobody else can).
  • Rate limiting and abuse: per-caller quotas; per-recipient limits on OTPs (e.g. 5 per hour) to stop SMS bombing and toll fraud, where attackers trigger SMS to premium numbers.
  • Secrets: provider API keys in a secret manager, rotated; webhook signatures verified.
  • Privacy: OTPs and message bodies are not logged; phone numbers and emails encrypted at rest; status records expire; honour data deletion requests by removing contacts and history.
  • Consent: marketing only to users who opted in, with an unsubscribe link in every marketing email and a STOP keyword for SMS where required by local rules.
  • Input validation: parameters are escaped when rendered into email HTML, and length-limited for SMS.

Trade-off analysis

DecisionChosenAlternativeWhy chosenWhen to switch
Accept modelAsync: enqueue and return 202Synchronous send inside the API callCallers never wait on providers; failures are retried safelyTiny systems where one background job is simpler than a queue
QueuesOne per channel, plus a high-priority laneOne shared queueIsolation: a slow SMS gateway or a campaign can't block OTPsVery low volume where one queue is easier to run
DuplicatesIdempotency key + SET NX + status checkHope the broker's "exactly once" covers itThe last hop is an external call no broker controlsNever skip it for OTPs and payments; maybe for low-value pushes
RetriesExponential backoff with jitter, max attempts, DLQImmediate retries, or retry foreverGives providers room to recover and makes failures visibleNever retry forever; tune counts per channel
Status storeWide-column store partitioned by userPostgreSQL table100M+ writes a day with TTL and simple lookupsUnder a few million a day, PostgreSQL with partitions is simpler
BrokerKafka (or a managed queue)Database-backed job tableHigh throughput, replay, partitions by userA job table is fine until hundreds of messages a second
ProvidersTwo per channel with a circuit breakerOne providerOutages and regional delivery problems are commonLow volume or no budget for a second integration

Every alternative on the right is simpler to run; each choice on the left is paid for by a specific requirement (isolation, no duplicates, durability). Interviewers want to hear which requirement justifies which piece.

Final architecture

Servicesorder, auth, chatSchedulercampaign batchesNotify APIN instancesRedisdedup, limitsPrefs DB+ cacheotp (high)pushsmsemailWorker poolsone per topicAPNs / FCMSMS gateway ASMS gateway BEmail ESPStatus storeper messageDLQ + replayafter max triesWebhooksdelivered, openedOTP 4821otp10k batchemail x10kdeliveredone-time codes get their own high-priority topic and never wait behind a campaigna circuit breaker moves SMS to gateway B when A keeps failingcampaigns enter in batches, throttled to what providers acceptprovider webhooks close the loop: sent, delivered, opened
Press play. An OTP goes through the Notify API into the high-priority topic and its own workers, so it never waits behind a campaign. When SMS gateway A keeps failing, a circuit breaker moves SMS to gateway B. Campaigns enter in 10,000-message batches from the scheduler, throttled to what providers accept. Workers write each result to the status store, and provider webhooks update it to delivered or opened.

This design is reasonable because every part answers a requirement: fast accepts and durability come from enqueue-then-return; no duplicates comes from idempotency keys at the API and status checks at the workers; isolation comes from queues per channel and a priority lane; provider unreliability is handled by backoff, a DLQ and a second provider; user rules are enforced at accept time and rechecked at send time; and the status store plus webhooks answer "did they get it?". Nothing here is needed at 1,000 users, and the scaling table shows the order in which to add it.

Interview discussion

1. Why not just call the SMS provider from the order service?

Answer

The provider is slow and fails often; the order service would wait on it, need its own retries, and could send duplicates on retry. Every team would rebuild templates, opt-outs and limits. A notification service accepts the request durably in milliseconds and handles all of that in one place. For a tiny product, a background job with retries in the same app is a fine first step.

2. How do you make sure a user doesn't get the same OTP twice?

Answer

The caller sends an idempotency key tied to the event; the API does an atomic SET NX and returns the first result for repeats. Workers check the status store before sending, so queue redeliveries are skipped. Where the provider accepts an idempotency key, pass the message key. The remaining window is a crash between the provider accepting and our status write; you can make it tiny but not zero.

3. A campaign to 25 million users starts at the same time as a spike in logins. What happens to OTPs?

Answer

Nothing bad, if OTPs have their own high-priority topic and workers, and the campaign scheduler feeds the normal queue at a throttled rate. With one shared queue, OTPs would sit behind millions of marketing messages, which is exactly why the lanes exist.

4. How do you handle quiet hours for users in different time zones?

Answer

Store each user's time zone with their preferences and compute quiet hours in local time. For marketing in the window, set the message's due time to the end of the window and let a delayed queue or scheduler release it. Transactional messages ignore quiet hours. Recheck preferences when the delayed message is finally sent.

5. The SMS gateway is down for 20 minutes. Walk me through it.

Answer

Calls time out; the circuit breaker opens after the error rate crosses a threshold and stops hammering the gateway. If a second provider exists, SMS fails over to it. If not, messages wait in the queue with backoff; OTPs might fall back to push or email if the product allows. Alerts fire on latency and error rate. When the gateway recovers, the breaker half-opens, then closes, and the backlog drains at the provider's rate limit.

6. How do you know whether a message was actually delivered?

Answer

"Sent" means the provider accepted it. "Delivered" comes later from provider webhooks (SMS delivery reports, email events, some push receipts), mapped back by the provider message ID. Not every channel reports delivery reliably, so dashboards should show both and not pretend "sent" is "delivered".

7. What would you change to support an in-app inbox?

Answer

Add "in-app" as a channel whose worker writes to an inbox store partitioned by user (newest first), with read/unread flags, and pushes a real-time update over the app's existing WebSocket if the user is online. The status store's per-user partitioning already looks like an inbox, but keep them separate: the inbox is product data with its own retention.

Common mistakes: sending inside the API request; one queue for everything; retrying immediately and forever; relying on "exactly once" from the broker; letting callers send free text; ignoring opt-outs and quiet hours; no dead-letter queue, so failures vanish; treating "sent" as "delivered".

What interviewers look for: a clear async boundary with 202, an idempotency story that covers every source of duplicates, isolation by channel and priority, sensible retry and DLQ behaviour, preferences enforced in the right place, numbers for campaign bursts and provider limits, and honesty about what exactly-once can and can't mean.

Designed it yourself and understood the solution? Mark it to track your progress.