Practice · High Level Design · Work in the Background
Design a Notification System
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
| Difficulty | Intermediate |
| Time | 2–3 hours to design and build the core; about 45 minutes in an interview |
| Prerequisites | Async messaging, designing for failure, what idempotency means |
| Concepts | Accept 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 world | Order 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
- Send: an internal service sends
{user, template, params, idempotency key}; the service delivers on the template's channels (push, SMS, email). - Preferences: users can opt out of marketing per channel and set quiet hours; transactional messages (OTP, order and payment updates) are always allowed.
- Templates: messages come from versioned templates with parameters and translations (Bangla and English).
- Reliability: transient failures are retried; messages that can't be delivered go to a dead-letter queue for inspection and replay.
- No duplicates from retries: the same idempotency key never produces a second notification.
- Campaigns: marketing can send to a segment of millions, at a rate the providers accept.
- Tracking: the status of every message (queued, sent, delivered, failed) can be looked up.
- 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
| Requirement | Target | Why it matters here |
|---|---|---|
| Accept latency | p99 under 50 ms for the API call | Callers (checkout, login) must not wait for an SMS gateway |
| Delivery latency | OTP sent within ~2 s; order updates within ~1 minute; campaigns within hours | Different messages have very different urgency |
| Throughput | ~100 million messages a day, peaks of ~10,000/s during campaigns | Estimated below |
| Availability | 99.99% for accepting; sending can lag | Losing a request is worse than sending it a minute late |
| Durability | An accepted notification is never silently lost | It's either delivered or visible in the dead-letter queue |
| No duplicates | At most one send per idempotency key in normal operation | Repeated OTPs and payment messages erode trust |
| Isolation | A slow or broken channel doesn't affect the others; campaigns don't delay transactional messages | Failures must stay small |
| Privacy and cost | Respect opt-outs and limits; don't use SMS where push will do | Legal 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
| Formula | Result | |
|---|---|---|
| Messages per second (avg) | 100M ÷ 86,400 | ≈ 1,200 / s |
| Normal peak | 1,200 × 3 | ≈ 3,500 / s |
| Campaign | 25M ÷ 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
| Formula | Result | |
|---|---|---|
| Status records per day | 100M × 300 B | 30 GB / day |
| Kept 90 days, 3 copies | 30 GB × 90 × 3 | ≈ 8 TB |
| Dedup keys (24 h TTL) | 70M × ~60 B | ≈ 4 GB in Redis |
| Campaign backlog in the queue | 25M × 1 KB | ≈ 25 GB (Kafka holds this easily) |
| Preferences and contacts | 50M × ~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 path | Request | Success | Errors |
|---|---|---|---|
POST /v1/notifications | header 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 result | 400 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} | none | 200 {"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"}} | 200 | 400 |
POST /v1/devices | {"user_id": "...", "token": "...", "platform": "android"} | 201 | 400 |
POST /webhooks/{provider} | provider's delivery receipt | 200 | 401 bad signature |
202 Accepted, not200 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
| Component | Why it's here | Connections |
|---|---|---|
| Producers (order, auth, courier, campaigns) | Decide that a user should be told something | → Notify API |
| Notify API | Validates, deduplicates, applies preferences and limits, renders, enqueues; answers 202 fast | → Redis, prefs, queues |
| Redis | Dedup keys (SET NX with 24 h expiry) and per-user rate-limit counters | read and written by the API |
| Prefs DB + cache | Opt-outs, quiet hours, locale, contacts, templates | read by the API |
| Queues per channel | Durable buffer; lets each channel go at its own speed | API → queue → workers |
| Workers per channel | Call the provider, record the status, retry or dead-letter | → providers, delivery log, DLQ |
| Providers | APNs/FCM for push, SMS gateways, an email service | external |
| Delivery log | Status of each message, for support and analytics | written by workers and webhooks |
| Dead letters | Messages that failed for good; alert and replay after a fix | written 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 service → LB → Notify API:
POST /v1/notificationswithIdempotency-Key: evt-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). SET dedup:evt-2 NX EX 86400in Redis. If the key already exists, return the stored result with200: this is a retry.- Read preferences and contacts (from cache). Decide channels: the template says push + SMS; Nusrat has no device token, so push is skipped.
- Marketing only: check opt-outs, quiet hours (delay to the end of the window) and the daily limit.
- 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. - Return
202with what was queued and skipped. Total: a few milliseconds, no provider call.
Sending a message (the worker side)
- An SMS worker takes
evt-2:smsfrom the queue (it's leased, not deleted, until the worker acknowledges it). - It checks the status store: if this message key is already
sent, it acknowledges and stops. This catches redeliveries after a crash. - It marks the message
sendingand 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. - Success: record
sentwith the provider's message ID, acknowledge the queue message. - 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. - Permanent failure (invalid number, unknown address): don't retry. Dead-letter it and mark the contact inactive so we stop trying that address.
- Later, the provider's webhook reports
delivered(orfailed); the status is updated throughby_provider_msg.
Component deep dive
Duplicates: where they come from and what stops each one
| Source | Example | Defence |
|---|---|---|
| Caller retries | Checkout's request times out, it calls again | Idempotency key + SET NX at the API |
| Two copies at once | A retry storm sends 50 identical requests in parallel | The check-and-accept is one atomic step (the Go test TestDuplicateRequestsQueueOnce runs 50 at once under -race) |
| Queue redelivery | The broker delivers at least once | Worker checks the status store before sending |
| Worker crash after sending | Gateway accepted the SMS, worker died before recording it | Provider 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
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-out | Not allowed (except push permission at the OS level) | Per channel |
| Quiet hours | Ignored: an OTP at 2 a.m. is expected | Delayed to the end of the window, in the user's time zone |
| Daily limit | Only abuse limits | e.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-1is rejected as already accepted. Rafi gets one push and one SMS, not two of each. - Skipped channels: Nusrat has no device token, so
evt-2goes 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
pctis 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:
Outcomeis a const object instead of anenum, because Node runs.tsfiles by stripping types and enums aren't just types.SendRequestandAttemptavoid clashing with the DOM's globalRequestandEvent. - Rust:
Channel,KindandOutcomeare enums matched exhaustively, so adding a channel forces everymatchto handle it.renderuseslet ... elsefor early returns. - Java: records for immutable messages;
sendandtakearesynchronizedand the provider call happens outside the lock, as in Go. - C++: a
std::mutexwithstd::lock_guardaround the same critical sections;std::optionalfor "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()// 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.
type Channel = "push" | "sms" | "email";
const CHANNELS: Channel[] = ["push", "sms", "email"];
type Kind = "transactional" | "marketing";
const MAX_ATTEMPTS = 4;
// A const object instead of an enum: Node runs this file by stripping types, and enums aren't types.
const Outcome = { OK: "delivered", Transient: "failed (timeout)", Permanent: "rejected (bad address)" } as const;
type Outcome = (typeof Outcome)[keyof typeof Outcome];
type Template = { kind: Kind; channels: Channel[]; text: string };
type User = {
contacts: Partial<Record<Channel, string>>;
optOut?: Set<Channel>; // marketing opt-outs per channel
quietFrom?: number; // quiet hours for marketing, e.g. 22 to 8
quietTo?: number;
};
// SendRequest and Attempt, not Request and Event: those names are DOM globals.
type SendRequest = { id: string; user: string; template: string; params: Record<string, string> };
type Message = {
readonly key: string; // request ID + channel, e.g. "evt-1:sms"
readonly channel: Channel;
readonly to: string;
readonly body: string;
readonly kind: Kind;
readonly attempt: number; // attempts made so far
readonly due: number; // simulated seconds; not sent before this
readonly seq: number; // enqueue order, breaks ties
};
type Accepted = { queued: Channel[]; skipped: string[]; due: number };
type Attempt = { time: number; msg: Message; outcome: Outcome; retryAt: number; dead: boolean };
class Rejected extends Error {}
/** Fills {name} placeholders; a missing value is an error, never a blank in a message. */
function render(text: string, params: Record<string, string>): string {
let out = "";
for (;;) {
const i = text.indexOf("{");
if (i < 0) return out + text;
const j = text.indexOf("}", i);
if (j < 0) throw new Rejected(`unclosed placeholder in ${JSON.stringify(text)}`);
const name = text.slice(i + 1, j);
if (!(name in params)) throw new Rejected(`missing parameter "${name}"`);
out += text.slice(0, i) + params[name];
text = text.slice(j + 1);
}
}
const pad2 = (n: number) => String(n).padStart(2, "0");
function clock(t: number): string {
const s = t % 86400;
return `D${Math.floor(t / 86400) + 1} ${pad2(Math.floor(s / 3600))}:${pad2(Math.floor(s / 60) % 60)}:${pad2(s % 60)}`;
}
/** Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter). */
const backoff = (attempt: number) => 2 ** (attempt - 1);
/** Plays back scripted outcomes per address; after the script runs out, sends succeed. */
class FakeProvider {
sent = new Map<string, number>();
readonly script: Map<string, Outcome[]>;
constructor(script: Record<string, Outcome[]> = {}) {
this.script = new Map(Object.entries(script));
}
send(m: Message): Outcome {
this.sent.set(m.key, (this.sent.get(m.key) ?? 0) + 1);
const s = this.script.get(m.to) ?? [];
return m.attempt < s.length ? s[m.attempt]! : Outcome.OK;
}
}
function inQuiet(u: User, now: number): boolean {
const from = u.quietFrom ?? 0;
const to = u.quietTo ?? 0;
if (from === to) return false;
const h = Math.floor((now % 86400) / 3600);
return from < to ? h >= from && h < to : h >= from || h < to;
}
function quietEnd(u: User, now: number): number {
const end = Math.floor(now / 86400) * 86400 + (u.quietTo ?? 0) * 3600;
return end <= now ? end + 86400 : end;
}
class Service {
seen = new Set<string>(); // accepted request IDs (production: a store with a TTL)
marketing = new Map<string, number>(); // "user/day" -> marketing messages accepted
maxMarketingPerDay = 1;
queues = new Map<Channel, Message[]>(CHANNELS.map((c) => [c, []]));
seq = 0;
dead: Message[] = [];
delivered = 0;
readonly users: Map<string, User>;
readonly templates: Map<string, Template>;
readonly providers: Map<Channel, FakeProvider>;
constructor(users: Record<string, User>, templates: Record<string, Template>, providers: Record<Channel, FakeProvider>) {
this.users = new Map(Object.entries(users));
this.templates = new Map(Object.entries(templates));
this.providers = new Map(Object.entries(providers) as [Channel, FakeProvider][]);
}
send(r: SendRequest, now: number): Accepted {
if (this.seen.has(r.id)) throw new Rejected("duplicate request, already accepted");
const u = this.users.get(r.user);
if (!u) throw new Rejected("unknown user");
const t = this.templates.get(r.template);
if (!t) throw new Rejected("unknown template");
const body = render(t.text, r.params);
const dayKey = `${r.user}/${Math.floor(now / 86400)}`;
if (t.kind === "marketing" && (this.marketing.get(dayKey) ?? 0) >= this.maxMarketingPerDay)
throw new Rejected("marketing limit reached for today");
const a: Accepted = { queued: [], skipped: [], due: now };
if (t.kind === "marketing" && inQuiet(u, now)) a.due = quietEnd(u, now);
for (const ch of t.channels) {
const to = u.contacts[ch];
if (to === undefined) a.skipped.push(`${ch}: no address`);
else if (t.kind === "marketing" && u.optOut?.has(ch)) a.skipped.push(`${ch}: opted out`);
else {
this.queues.get(ch)!.push({ key: `${r.id}:${ch}`, channel: ch, to, body, kind: t.kind, attempt: 0, due: a.due, seq: ++this.seq });
a.queued.push(ch);
}
}
this.seen.add(r.id);
if (t.kind === "marketing" && a.queued.length > 0) this.marketing.set(dayKey, (this.marketing.get(dayKey) ?? 0) + 1);
return a;
}
private take(now: number): Message | undefined {
let best: Message | undefined;
for (const c of CHANNELS)
for (const m of this.queues.get(c)!)
if (m.due <= now && (!best || m.due < best.due || (m.due === best.due && m.seq < best.seq))) best = m;
if (best) {
const q = this.queues.get(best.channel)!;
q.splice(q.indexOf(best), 1);
}
return best;
}
step(now: number): Attempt | undefined {
const taken = this.take(now);
if (!taken) return undefined;
const outcome = this.providers.get(taken.channel)!.send(taken);
const m: Message = { ...taken, attempt: taken.attempt + 1 };
const ev: Attempt = { time: now, msg: m, outcome, retryAt: 0, dead: false };
if (outcome === Outcome.OK) this.delivered++;
else if (outcome === Outcome.Transient && m.attempt < MAX_ATTEMPTS) {
ev.retryAt = now + backoff(m.attempt);
this.queues.get(m.channel)!.push({ ...m, due: ev.retryAt });
} else {
this.dead.push(m);
ev.dead = true;
}
return ev;
}
nextDue(): number | undefined {
let next: number | undefined;
for (const q of this.queues.values()) for (const m of q) if (next === undefined || m.due < next) next = m.due;
return next;
}
pending(): number {
let n = 0;
for (const q of this.queues.values()) n += q.length;
return n;
}
}
const s = new Service(
{
rafi: { contacts: { push: "tok-rafi", sms: "+8801711000001", email: "rafi@example.com" }, quietFrom: 22, quietTo: 8 },
nusrat: { contacts: { sms: "+8801811000002", email: "nusrat@example.com" }, optOut: new Set<Channel>(["email"]) },
tanvir: { contacts: { email: "tanvir@bad-domain" } },
},
{
order_shipped: { kind: "transactional", channels: ["push", "sms"], text: "Order {order} is on the way, arriving {day}" },
eid_sale: { kind: "marketing", channels: ["push", "email"], text: "Eid sale: {pct}% off until Friday" },
},
{
push: new FakeProvider(),
sms: new FakeProvider({ "+8801811000002": [Outcome.Transient, Outcome.Transient] }),
email: new FakeProvider({ "tanvir@bad-domain": [Outcome.Permanent] }),
},
);
function send(now: number, r: SendRequest): void {
const head = ` ${r.id.padEnd(16)} ${r.template.padEnd(13)} ${r.user.padEnd(6)} ->`;
let a: Accepted;
try {
a = s.send(r, now);
} catch (e) {
if (!(e instanceof Rejected)) throw e;
console.log(`${head} error: ${e.message}`);
return;
}
let line = "queued " + a.queued.join(", ");
if (a.due > now) line += ` for ${clock(a.due)} (quiet hours)`;
if (a.queued.length === 0) line = "nothing to send";
if (a.skipped.length > 0) line += ` (skipped ${a.skipped.join("; ")})`;
console.log(`${head} ${line}`);
}
function run(until: number): void {
for (let next = s.nextDue(); next !== undefined && next <= until; next = s.nextDue()) {
const ev = s.step(next)!;
let line: string = ev.outcome;
if (ev.retryAt) line += `, retry at ${clock(ev.retryAt)}`;
else if (ev.dead) line += ", moved to dead letters";
else line += `: ${ev.msg.body}`;
console.log(` ${clock(ev.time)} ${ev.msg.channel.padEnd(5)} ${ev.msg.key.padEnd(19)} try ${ev.msg.attempt}: ${line}`);
}
}
const t1 = 21 * 3600 + 59 * 60 + 50;
console.log("D1 21:59:50, the order service sends three requests:");
send(t1, { id: "evt-1", user: "rafi", template: "order_shipped", params: { order: "BD-7781", day: "Sunday" } });
send(t1, { id: "evt-1", user: "rafi", template: "order_shipped", params: { order: "BD-7781", day: "Sunday" } });
send(t1, { id: "evt-2", user: "nusrat", template: "order_shipped", params: { order: "BD-7790", day: "Monday" } });
console.log("workers:");
run(t1 + 10);
const t2 = 22 * 3600 + 5;
console.log("D1 22:00:05, the marketing team starts a campaign:");
send(t2, { id: "camp-7:rafi", user: "rafi", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-7:nusrat", user: "nusrat", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-7:tanvir", user: "tanvir", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-8:rafi", user: "rafi", template: "eid_sale", params: { pct: "40" } });
send(t2, { id: "camp-9:tanvir", user: "tanvir", template: "eid_sale", params: {} });
console.log("workers:");
run(2 * 86400);
console.log(`delivered ${s.delivered}, dead letters ${s.dead.length}, still queued ${s.pending()}`);// 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.
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Channel {
Push,
Sms,
Email,
}
const CHANNELS: [Channel; 3] = [Channel::Push, Channel::Sms, Channel::Email];
impl Channel {
fn name(self) -> &'static str {
match self {
Channel::Push => "push",
Channel::Sms => "sms",
Channel::Email => "email",
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Transactional,
Marketing,
}
#[derive(Clone, Copy, PartialEq)]
enum Outcome {
Ok,
Transient, // timeout, 5xx, throttled: worth retrying
Permanent, // invalid number, unknown address: retrying won't help
}
impl Outcome {
fn text(self) -> &'static str {
match self {
Outcome::Ok => "delivered",
Outcome::Transient => "failed (timeout)",
Outcome::Permanent => "rejected (bad address)",
}
}
}
const MAX_ATTEMPTS: u32 = 4;
struct Template {
kind: Kind,
channels: Vec<Channel>,
text: &'static str,
}
#[derive(Default)]
struct User {
contacts: HashMap<Channel, &'static str>,
opt_out: HashSet<Channel>, // marketing opt-outs per channel
quiet_from: i64, // quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
quiet_to: i64,
}
struct Request {
id: &'static str, // the idempotency key
user: &'static str,
template: &'static str,
params: Vec<(&'static str, &'static str)>,
}
#[derive(Clone)]
struct Message {
key: String, // request ID + channel, e.g. "evt-1:sms"
channel: Channel,
to: String,
body: String,
attempt: u32, // attempts made so far
due: i64, // simulated seconds; not sent before this
seq: u64, // enqueue order, breaks ties
}
#[derive(Default)]
struct Accepted {
queued: Vec<Channel>,
skipped: Vec<String>,
due: i64,
}
struct Event {
time: i64,
msg: Message,
outcome: Outcome,
retry_at: Option<i64>,
dead: bool,
}
/// Fills {name} placeholders; a missing value is an error, never a blank in a message.
fn render(text: &str, params: &[(&str, &str)]) -> Result<String, String> {
let (mut out, mut rest) = (String::new(), text);
loop {
let Some(i) = rest.find('{') else {
out.push_str(rest);
return Ok(out);
};
let Some(j) = rest[i..].find('}') else {
return Err(format!("unclosed placeholder in {:?}", text));
};
let name = &rest[i + 1..i + j];
let Some((_, v)) = params.iter().find(|(k, _)| *k == name) else {
return Err(format!("missing parameter \"{}\"", name));
};
out.push_str(&rest[..i]);
out.push_str(v);
rest = &rest[i + j + 1..];
}
}
fn clock(t: i64) -> String {
let s = t % 86400;
format!("D{} {:02}:{:02}:{:02}", t / 86400 + 1, s / 3600, s / 60 % 60, s % 60)
}
/// Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter).
fn backoff(attempt: u32) -> i64 {
1 << (attempt - 1)
}
/// Plays back scripted outcomes per address; after the script runs out, sends succeed.
#[derive(Default)]
struct FakeProvider {
script: HashMap<&'static str, Vec<Outcome>>,
sent: HashMap<String, u32>,
}
impl FakeProvider {
fn send(&mut self, m: &Message) -> Outcome {
*self.sent.entry(m.key.clone()).or_insert(0) += 1;
match self.script.get(m.to.as_str()) {
Some(s) if (m.attempt as usize) < s.len() => s[m.attempt as usize],
_ => Outcome::Ok,
}
}
}
fn in_quiet(u: &User, now: i64) -> bool {
if u.quiet_from == u.quiet_to {
return false;
}
let h = now % 86400 / 3600;
if u.quiet_from < u.quiet_to {
h >= u.quiet_from && h < u.quiet_to
} else {
h >= u.quiet_from || h < u.quiet_to
}
}
fn quiet_end(u: &User, now: i64) -> i64 {
let end = now / 86400 * 86400 + u.quiet_to * 3600;
if end <= now {
end + 86400
} else {
end
}
}
struct Service {
users: HashMap<&'static str, User>,
templates: HashMap<&'static str, Template>,
providers: HashMap<Channel, FakeProvider>,
seen: HashSet<String>, // accepted request IDs (production: a store with a TTL)
marketing: HashMap<String, u32>, // "user/day" -> marketing messages accepted
max_marketing_per_day: u32,
queues: HashMap<Channel, Vec<Message>>,
seq: u64,
dead: Vec<Message>,
delivered: u32,
}
impl Service {
fn send(&mut self, r: &Request, now: i64) -> Result<Accepted, String> {
if self.seen.contains(r.id) {
return Err("duplicate request, already accepted".into());
}
let u = self.users.get(r.user).ok_or("unknown user")?;
let t = self.templates.get(r.template).ok_or("unknown template")?;
let body = render(t.text, &r.params)?;
let day_key = format!("{}/{}", r.user, now / 86400);
if t.kind == Kind::Marketing && self.marketing.get(&day_key).copied().unwrap_or(0) >= self.max_marketing_per_day {
return Err("marketing limit reached for today".into());
}
let mut a = Accepted { due: now, ..Default::default() };
if t.kind == Kind::Marketing && in_quiet(u, now) {
a.due = quiet_end(u, now);
}
for &ch in &t.channels {
match u.contacts.get(&ch) {
None => a.skipped.push(format!("{}: no address", ch.name())),
Some(_) if t.kind == Kind::Marketing && u.opt_out.contains(&ch) => a.skipped.push(format!("{}: opted out", ch.name())),
Some(to) => {
self.seq += 1;
let m = Message { key: format!("{}:{}", r.id, ch.name()), channel: ch, to: to.to_string(), body: body.clone(), attempt: 0, due: a.due, seq: self.seq };
self.queues.entry(ch).or_default().push(m);
a.queued.push(ch);
}
}
}
self.seen.insert(r.id.to_string());
if t.kind == Kind::Marketing && !a.queued.is_empty() {
*self.marketing.entry(day_key).or_insert(0) += 1;
}
Ok(a)
}
fn take(&mut self, now: i64) -> Option<Message> {
let mut best: Option<(Channel, usize, i64, u64)> = None;
for ch in CHANNELS {
for (i, m) in self.queues.get(&ch).into_iter().flatten().enumerate() {
if m.due <= now && best.map_or(true, |(_, _, d, s)| (m.due, m.seq) < (d, s)) {
best = Some((ch, i, m.due, m.seq));
}
}
}
let (ch, i, _, _) = best?;
Some(self.queues.get_mut(&ch).unwrap().remove(i))
}
fn step(&mut self, now: i64) -> Option<Event> {
let mut m = self.take(now)?;
let outcome = self.providers.get_mut(&m.channel).unwrap().send(&m);
m.attempt += 1;
let mut ev = Event { time: now, msg: m.clone(), outcome, retry_at: None, dead: false };
if outcome == Outcome::Ok {
self.delivered += 1;
} else if outcome == Outcome::Transient && m.attempt < MAX_ATTEMPTS {
m.due = now + backoff(m.attempt);
ev.retry_at = Some(m.due);
self.queues.entry(m.channel).or_default().push(m);
} else {
self.dead.push(m);
ev.dead = true;
}
Some(ev)
}
fn next_due(&self) -> Option<i64> {
self.queues.values().flatten().map(|m| m.due).min()
}
fn pending(&self) -> usize {
self.queues.values().map(Vec::len).sum()
}
}
fn main() {
use Channel::*;
let mut users = HashMap::new();
users.insert("rafi", User { contacts: HashMap::from([(Push, "tok-rafi"), (Sms, "+8801711000001"), (Email, "rafi@example.com")]), quiet_from: 22, quiet_to: 8, ..Default::default() });
users.insert("nusrat", User { contacts: HashMap::from([(Sms, "+8801811000002"), (Email, "nusrat@example.com")]), opt_out: HashSet::from([Email]), ..Default::default() });
users.insert("tanvir", User { contacts: HashMap::from([(Email, "tanvir@bad-domain")]), ..Default::default() });
let mut templates = HashMap::new();
templates.insert("order_shipped", Template { kind: Kind::Transactional, channels: vec![Push, Sms], text: "Order {order} is on the way, arriving {day}" });
templates.insert("eid_sale", Template { kind: Kind::Marketing, channels: vec![Push, Email], text: "Eid sale: {pct}% off until Friday" });
let providers = HashMap::from([
(Push, FakeProvider::default()),
(Sms, FakeProvider { script: HashMap::from([("+8801811000002", vec![Outcome::Transient, Outcome::Transient])]), ..Default::default() }),
(Email, FakeProvider { script: HashMap::from([("tanvir@bad-domain", vec![Outcome::Permanent])]), ..Default::default() }),
]);
let mut s = Service { users, templates, providers, seen: HashSet::new(), marketing: HashMap::new(), max_marketing_per_day: 1, queues: HashMap::new(), seq: 0, dead: Vec::new(), delivered: 0 };
fn send(s: &mut Service, now: i64, r: Request) {
let head = format!(" {:<16} {:<13} {:<6} ->", r.id, r.template, r.user);
match s.send(&r, now) {
Err(e) => println!("{} error: {}", head, e),
Ok(a) => {
let names: Vec<&str> = a.queued.iter().map(|c| c.name()).collect();
let mut line = format!("queued {}", names.join(", "));
if a.due > now {
line += &format!(" for {} (quiet hours)", clock(a.due));
}
if a.queued.is_empty() {
line = "nothing to send".to_string();
}
if !a.skipped.is_empty() {
line += &format!(" (skipped {})", a.skipped.join("; "));
}
println!("{} {}", head, line);
}
}
}
fn run(s: &mut Service, until: i64) {
while let Some(next) = s.next_due().filter(|&n| n <= until) {
let ev = s.step(next).unwrap();
let mut line = ev.outcome.text().to_string();
if let Some(at) = ev.retry_at {
line += &format!(", retry at {}", clock(at));
} else if ev.dead {
line += ", moved to dead letters";
} else {
line += &format!(": {}", ev.msg.body);
}
println!(" {} {:<5} {:<19} try {}: {}", clock(ev.time), ev.msg.channel.name(), ev.msg.key, ev.msg.attempt, line);
}
}
let t1 = 21 * 3600 + 59 * 60 + 50;
println!("D1 21:59:50, the order service sends three requests:");
send(&mut s, t1, Request { id: "evt-1", user: "rafi", template: "order_shipped", params: vec![("order", "BD-7781"), ("day", "Sunday")] });
send(&mut s, t1, Request { id: "evt-1", user: "rafi", template: "order_shipped", params: vec![("order", "BD-7781"), ("day", "Sunday")] });
send(&mut s, t1, Request { id: "evt-2", user: "nusrat", template: "order_shipped", params: vec![("order", "BD-7790"), ("day", "Monday")] });
println!("workers:");
run(&mut s, t1 + 10);
let t2 = 22 * 3600 + 5;
println!("D1 22:00:05, the marketing team starts a campaign:");
send(&mut s, t2, Request { id: "camp-7:rafi", user: "rafi", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-7:nusrat", user: "nusrat", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-7:tanvir", user: "tanvir", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-8:rafi", user: "rafi", template: "eid_sale", params: vec![("pct", "40")] });
send(&mut s, t2, Request { id: "camp-9:tanvir", user: "tanvir", template: "eid_sale", params: vec![] });
println!("workers:");
run(&mut s, 2 * 86400);
println!("delivered {}, dead letters {}, still queued {}", s.delivered, s.dead.len(), s.pending());
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
// 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.
public class Notify {
enum Channel { PUSH, SMS, EMAIL; String label() { return name().toLowerCase(); } }
enum Kind { TRANSACTIONAL, MARKETING }
enum Outcome {
OK("delivered"), TRANSIENT("failed (timeout)"), PERMANENT("rejected (bad address)");
final String text;
Outcome(String text) { this.text = text; }
}
static final int MAX_ATTEMPTS = 4;
record Template(Kind kind, List<Channel> channels, String text) {}
/** Contacts per channel, marketing opt-outs, and quiet hours for marketing (0, 0 = none). */
record User(Map<Channel, String> contacts, Set<Channel> optOut, int quietFrom, int quietTo) {}
/** id is the idempotency key: the same ID twice means the same notification. */
record Request(String id, String user, String template, Map<String, String> params) {}
/** One notification on one channel. attempt = tries so far, due = not before, seq = tie-break. */
record Message(String key, Channel channel, String to, String body, Kind kind, int attempt, long due, long seq) {}
record Accepted(List<Channel> queued, List<String> skipped, long due) {}
record Event(long time, Message msg, Outcome outcome, long retryAt, boolean dead) {}
static final class Rejected extends Exception {
private static final long serialVersionUID = 1L;
Rejected(String msg) { super(msg); }
}
/** Fills {name} placeholders; a missing value is an error, never a blank in a message. */
static String render(String text, Map<String, String> params) throws Rejected {
StringBuilder out = new StringBuilder();
while (true) {
int i = text.indexOf('{');
if (i < 0) return out.append(text).toString();
int j = text.indexOf('}', i);
if (j < 0) throw new Rejected("unclosed placeholder in \"" + text + "\"");
String name = text.substring(i + 1, j);
if (!params.containsKey(name)) throw new Rejected("missing parameter \"" + name + "\"");
out.append(text, 0, i).append(params.get(name));
text = text.substring(j + 1);
}
}
static String clock(long t) {
long s = t % 86400;
return String.format("D%d %02d:%02d:%02d", t / 86400 + 1, s / 3600, s / 60 % 60, s % 60);
}
/** Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter). */
static long backoff(int attempt) { return 1L << (attempt - 1); }
/** Plays back scripted outcomes per address; after the script runs out, sends succeed. */
static final class FakeProvider {
final Map<String, List<Outcome>> script;
final Map<String, Integer> sent = new HashMap<>();
FakeProvider(Map<String, List<Outcome>> script) { this.script = script; }
Outcome send(Message m) {
sent.merge(m.key(), 1, Integer::sum);
List<Outcome> s = script.getOrDefault(m.to(), List.of());
return m.attempt() < s.size() ? s.get(m.attempt()) : Outcome.OK;
}
}
static boolean inQuiet(User u, long now) {
if (u.quietFrom() == u.quietTo()) return false;
long h = now % 86400 / 3600;
return u.quietFrom() < u.quietTo() ? h >= u.quietFrom() && h < u.quietTo() : h >= u.quietFrom() || h < u.quietTo();
}
static long quietEnd(User u, long now) {
long end = now / 86400 * 86400 + u.quietTo() * 3600L;
return end <= now ? end + 86400 : end;
}
static final class Service {
final Map<String, User> users;
final Map<String, Template> templates;
final Map<Channel, FakeProvider> providers;
final Set<String> seen = new HashSet<>(); // accepted request IDs (production: a store with a TTL)
final Map<String, Integer> marketing = new HashMap<>(); // "user/day" -> marketing messages accepted
int maxMarketingPerDay = 1;
final Map<Channel, List<Message>> queues = new LinkedHashMap<>();
long seq;
final List<Message> dead = new ArrayList<>();
int delivered;
Service(Map<String, User> users, Map<String, Template> templates, Map<Channel, FakeProvider> providers) {
this.users = users;
this.templates = templates;
this.providers = providers;
for (Channel c : Channel.values()) queues.put(c, new ArrayList<>());
}
/** Synchronized: the check-and-accept is one step, so duplicates racing each other can't both pass. */
synchronized Accepted send(Request r, long now) throws Rejected {
if (seen.contains(r.id())) throw new Rejected("duplicate request, already accepted");
User u = users.get(r.user());
if (u == null) throw new Rejected("unknown user");
Template t = templates.get(r.template());
if (t == null) throw new Rejected("unknown template");
String body = render(t.text(), r.params());
String dayKey = r.user() + "/" + now / 86400;
if (t.kind() == Kind.MARKETING && marketing.getOrDefault(dayKey, 0) >= maxMarketingPerDay)
throw new Rejected("marketing limit reached for today");
long due = t.kind() == Kind.MARKETING && inQuiet(u, now) ? quietEnd(u, now) : now;
List<Channel> queued = new ArrayList<>();
List<String> skipped = new ArrayList<>();
for (Channel ch : t.channels()) {
String to = u.contacts().get(ch);
if (to == null) skipped.add(ch.label() + ": no address");
else if (t.kind() == Kind.MARKETING && u.optOut().contains(ch)) skipped.add(ch.label() + ": opted out");
else {
queues.get(ch).add(new Message(r.id() + ":" + ch.label(), ch, to, body, t.kind(), 0, due, ++seq));
queued.add(ch);
}
}
seen.add(r.id());
if (t.kind() == Kind.MARKETING && !queued.isEmpty()) marketing.merge(dayKey, 1, Integer::sum);
return new Accepted(queued, skipped, due);
}
synchronized Message take(long now) {
Message best = null;
for (Channel c : Channel.values())
for (Message m : queues.get(c))
if (m.due() <= now && (best == null || m.due() < best.due() || (m.due() == best.due() && m.seq() < best.seq())))
best = m;
if (best != null) queues.get(best.channel()).remove(best);
return best;
}
Event step(long now) {
Message taken = take(now);
if (taken == null) return null;
Outcome out = providers.get(taken.channel()).send(taken); // network I/O, outside the lock
Message m = new Message(taken.key(), taken.channel(), taken.to(), taken.body(), taken.kind(), taken.attempt() + 1, taken.due(), taken.seq());
synchronized (this) {
if (out == Outcome.OK) {
delivered++;
return new Event(now, m, out, 0, false);
}
if (out == Outcome.TRANSIENT && m.attempt() < MAX_ATTEMPTS) {
long at = now + backoff(m.attempt());
queues.get(m.channel()).add(new Message(m.key(), m.channel(), m.to(), m.body(), m.kind(), m.attempt(), at, m.seq()));
return new Event(now, m, out, at, false);
}
dead.add(m);
return new Event(now, m, out, 0, true);
}
}
synchronized Long nextDue() {
Long next = null;
for (List<Message> q : queues.values())
for (Message m : q) if (next == null || m.due() < next) next = m.due();
return next;
}
synchronized int pending() {
return queues.values().stream().mapToInt(List::size).sum();
}
}
static Service s;
static void send(long now, Request r) {
String head = String.format(" %-16s %-13s %-6s ->", r.id(), r.template(), r.user());
Accepted a;
try {
a = s.send(r, now);
} catch (Rejected e) {
System.out.println(head + " error: " + e.getMessage());
return;
}
String line = "queued " + String.join(", ", a.queued().stream().map(Channel::label).toList());
if (a.due() > now) line += " for " + clock(a.due()) + " (quiet hours)";
if (a.queued().isEmpty()) line = "nothing to send";
if (!a.skipped().isEmpty()) line += " (skipped " + String.join("; ", a.skipped()) + ")";
System.out.println(head + " " + line);
}
static void run(long until) {
for (Long next = s.nextDue(); next != null && next <= until; next = s.nextDue()) {
Event ev = s.step(next);
String line = ev.outcome().text;
if (ev.retryAt() > 0) line += ", retry at " + clock(ev.retryAt());
else if (ev.dead()) line += ", moved to dead letters";
else line += ": " + ev.msg().body();
System.out.printf(" %s %-5s %-19s try %d: %s%n", clock(ev.time()), ev.msg().channel().label(), ev.msg().key(), ev.msg().attempt(), line);
}
}
public static void main(String[] args) {
Map<String, User> users = Map.of(
"rafi", new User(Map.of(Channel.PUSH, "tok-rafi", Channel.SMS, "+8801711000001", Channel.EMAIL, "rafi@example.com"), Set.of(), 22, 8),
"nusrat", new User(Map.of(Channel.SMS, "+8801811000002", Channel.EMAIL, "nusrat@example.com"), Set.of(Channel.EMAIL), 0, 0),
"tanvir", new User(Map.of(Channel.EMAIL, "tanvir@bad-domain"), Set.of(), 0, 0));
Map<String, Template> templates = Map.of(
"order_shipped", new Template(Kind.TRANSACTIONAL, List.of(Channel.PUSH, Channel.SMS), "Order {order} is on the way, arriving {day}"),
"eid_sale", new Template(Kind.MARKETING, List.of(Channel.PUSH, Channel.EMAIL), "Eid sale: {pct}% off until Friday"));
Map<Channel, FakeProvider> providers = Map.of(
Channel.PUSH, new FakeProvider(Map.of()),
Channel.SMS, new FakeProvider(Map.of("+8801811000002", List.of(Outcome.TRANSIENT, Outcome.TRANSIENT))),
Channel.EMAIL, new FakeProvider(Map.of("tanvir@bad-domain", List.of(Outcome.PERMANENT))));
s = new Service(users, templates, providers);
long t1 = 21 * 3600 + 59 * 60 + 50;
System.out.println("D1 21:59:50, the order service sends three requests:");
send(t1, new Request("evt-1", "rafi", "order_shipped", Map.of("order", "BD-7781", "day", "Sunday")));
send(t1, new Request("evt-1", "rafi", "order_shipped", Map.of("order", "BD-7781", "day", "Sunday")));
send(t1, new Request("evt-2", "nusrat", "order_shipped", Map.of("order", "BD-7790", "day", "Monday")));
System.out.println("workers:");
run(t1 + 10);
long t2 = 22 * 3600 + 5;
System.out.println("D1 22:00:05, the marketing team starts a campaign:");
send(t2, new Request("camp-7:rafi", "rafi", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-7:nusrat", "nusrat", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-7:tanvir", "tanvir", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-8:rafi", "rafi", "eid_sale", Map.of("pct", "40")));
send(t2, new Request("camp-9:tanvir", "tanvir", "eid_sale", Map.of()));
System.out.println("workers:");
run(2 * 86400);
System.out.printf("delivered %d, dead letters %d, still queued %d%n", s.delivered, s.dead.size(), s.pending());
}
}// 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.
#include <array>
#include <cstdint>
#include <cstdio>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
enum class Channel { Push, Sms, Email };
constexpr std::array<Channel, 3> kChannels{Channel::Push, Channel::Sms, Channel::Email};
const char* name(Channel c) { return c == Channel::Push ? "push" : c == Channel::Sms ? "sms" : "email"; }
enum class Kind { Transactional, Marketing };
enum class Outcome { Ok, Transient, Permanent };
const char* text(Outcome o) {
return o == Outcome::Ok ? "delivered" : o == Outcome::Transient ? "failed (timeout)" : "rejected (bad address)";
}
constexpr int kMaxAttempts = 4;
struct Template {
Kind kind;
std::vector<Channel> channels;
std::string text;
};
struct User {
std::map<Channel, std::string> contacts;
std::set<Channel> opt_out; // marketing opt-outs per channel
int quiet_from = 0, quiet_to = 0; // quiet hours for marketing, e.g. 22 to 8
};
struct Request {
std::string id; // the idempotency key
std::string user, tmpl;
std::map<std::string, std::string> params;
};
struct Message {
std::string key; // request ID + channel, e.g. "evt-1:sms"
Channel channel;
std::string to, body;
Kind kind;
int attempt; // attempts made so far
int64_t due; // simulated seconds; not sent before this
int64_t seq; // enqueue order, breaks ties
};
struct Accepted {
std::vector<Channel> queued;
std::vector<std::string> skipped;
int64_t due = 0;
};
struct Event {
int64_t time;
Message msg;
Outcome outcome;
int64_t retry_at = 0;
bool dead = false;
};
struct Rejected : std::runtime_error {
using std::runtime_error::runtime_error;
};
// Fills {name} placeholders; a missing value is an error, never a blank in a message.
std::string render(std::string text, const std::map<std::string, std::string>& params) {
std::string out;
for (;;) {
auto i = text.find('{');
if (i == std::string::npos) return out + text;
auto j = text.find('}', i);
if (j == std::string::npos) throw Rejected("unclosed placeholder in \"" + text + "\"");
std::string key = text.substr(i + 1, j - i - 1);
auto it = params.find(key);
if (it == params.end()) throw Rejected("missing parameter \"" + key + "\"");
out += text.substr(0, i) + it->second;
text = text.substr(j + 1);
}
}
std::string clock(int64_t t) {
int64_t s = t % 86400;
char buf[32];
std::snprintf(buf, sizeof buf, "D%lld %02lld:%02lld:%02lld", static_cast<long long>(t / 86400 + 1),
static_cast<long long>(s / 3600), static_cast<long long>(s / 60 % 60), static_cast<long long>(s % 60));
return buf;
}
// Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter).
int64_t backoff(int attempt) { return int64_t{1} << (attempt - 1); }
// Plays back scripted outcomes per address; after the script runs out, sends succeed.
struct FakeProvider {
std::map<std::string, std::vector<Outcome>> script;
std::map<std::string, int> sent;
Outcome send(const Message& m) {
sent[m.key]++;
auto it = script.find(m.to);
if (it != script.end() && m.attempt < static_cast<int>(it->second.size())) return it->second[m.attempt];
return Outcome::Ok;
}
};
bool in_quiet(const User& u, int64_t now) {
if (u.quiet_from == u.quiet_to) return false;
int64_t h = now % 86400 / 3600;
if (u.quiet_from < u.quiet_to) return h >= u.quiet_from && h < u.quiet_to;
return h >= u.quiet_from || h < u.quiet_to;
}
int64_t quiet_end(const User& u, int64_t now) {
int64_t end = now / 86400 * 86400 + int64_t{u.quiet_to} * 3600;
return end <= now ? end + 86400 : end;
}
class Service {
public:
Service(std::map<std::string, User> users, std::map<std::string, Template> templates,
std::map<Channel, FakeProvider> providers)
: users_(std::move(users)), templates_(std::move(templates)), providers_(std::move(providers)) {}
// One lock around the check-and-accept, so duplicates racing each other can't both pass.
Accepted send(const Request& r, int64_t now) {
std::lock_guard lock(mu_);
if (seen_.count(r.id)) throw Rejected("duplicate request, already accepted");
auto u = users_.find(r.user);
if (u == users_.end()) throw Rejected("unknown user");
auto t = templates_.find(r.tmpl);
if (t == templates_.end()) throw Rejected("unknown template");
std::string body = render(t->second.text, r.params);
std::string day_key = r.user + "/" + std::to_string(now / 86400);
bool mkt = t->second.kind == Kind::Marketing;
if (mkt && marketing_[day_key] >= max_marketing_per_day) throw Rejected("marketing limit reached for today");
Accepted a;
a.due = mkt && in_quiet(u->second, now) ? quiet_end(u->second, now) : now;
for (Channel ch : t->second.channels) {
auto to = u->second.contacts.find(ch);
if (to == u->second.contacts.end()) {
a.skipped.push_back(std::string(name(ch)) + ": no address");
} else if (mkt && u->second.opt_out.count(ch)) {
a.skipped.push_back(std::string(name(ch)) + ": opted out");
} else {
queues_[ch].push_back({r.id + ":" + name(ch), ch, to->second, body, t->second.kind, 0, a.due, ++seq_});
a.queued.push_back(ch);
}
}
seen_.insert(r.id);
if (mkt && !a.queued.empty()) marketing_[day_key]++;
return a;
}
std::optional<Event> step(int64_t now) {
auto taken = take(now);
if (!taken) return std::nullopt;
Message m = *taken;
Outcome out = providers_.at(m.channel).send(m); // network I/O, outside the lock
m.attempt++;
Event ev{now, m, out};
std::lock_guard lock(mu_);
if (out == Outcome::Ok) {
delivered++;
} else if (out == Outcome::Transient && m.attempt < kMaxAttempts) {
m.due = now + backoff(m.attempt);
ev.retry_at = m.due;
queues_[m.channel].push_back(m);
} else {
dead.push_back(m);
ev.dead = true;
}
return ev;
}
std::optional<int64_t> next_due() {
std::lock_guard lock(mu_);
std::optional<int64_t> next;
for (auto& [ch, q] : queues_)
for (auto& m : q)
if (!next || m.due < *next) next = m.due;
return next;
}
size_t pending() {
std::lock_guard lock(mu_);
size_t n = 0;
for (auto& [ch, q] : queues_) n += q.size();
return n;
}
int max_marketing_per_day = 1;
int delivered = 0;
std::vector<Message> dead;
private:
std::optional<Message> take(int64_t now) {
std::lock_guard lock(mu_);
std::vector<Message>* bq = nullptr;
size_t bi = 0;
for (Channel c : kChannels) {
auto& q = queues_[c];
for (size_t i = 0; i < q.size(); i++) {
const Message& m = q[i];
if (m.due > now) continue;
if (!bq || m.due < (*bq)[bi].due || (m.due == (*bq)[bi].due && m.seq < (*bq)[bi].seq)) bq = &q, bi = i;
}
}
if (!bq) return std::nullopt;
Message m = (*bq)[bi];
bq->erase(bq->begin() + static_cast<std::ptrdiff_t>(bi));
return m;
}
std::mutex mu_;
std::map<std::string, User> users_;
std::map<std::string, Template> templates_;
std::map<Channel, FakeProvider> providers_;
std::set<std::string> seen_; // accepted request IDs (production: a store with a TTL)
std::map<std::string, int> marketing_; // "user/day" -> marketing messages accepted
std::map<Channel, std::vector<Message>> queues_;
int64_t seq_ = 0;
};
std::string join(const std::vector<std::string>& v, const char* sep) {
std::string out;
for (const auto& x : v) out += (out.empty() ? "" : sep) + x;
return out;
}
void send(Service& s, int64_t now, const Request& r) {
char head[96];
std::snprintf(head, sizeof head, " %-16s %-13s %-6s ->", r.id.c_str(), r.tmpl.c_str(), r.user.c_str());
try {
Accepted a = s.send(r, now);
std::vector<std::string> names;
for (Channel c : a.queued) names.push_back(name(c));
std::string line = "queued " + join(names, ", ");
if (a.due > now) line += " for " + clock(a.due) + " (quiet hours)";
if (a.queued.empty()) line = "nothing to send";
if (!a.skipped.empty()) line += " (skipped " + join(a.skipped, "; ") + ")";
std::printf("%s %s\n", head, line.c_str());
} catch (const Rejected& e) {
std::printf("%s error: %s\n", head, e.what());
}
}
void run(Service& s, int64_t until) {
for (auto next = s.next_due(); next && *next <= until; next = s.next_due()) {
Event ev = *s.step(*next);
std::string line = text(ev.outcome);
if (ev.retry_at) line += ", retry at " + clock(ev.retry_at);
else if (ev.dead) line += ", moved to dead letters";
else line += ": " + ev.msg.body;
std::printf(" %s %-5s %-19s try %d: %s\n", clock(ev.time).c_str(), name(ev.msg.channel), ev.msg.key.c_str(),
ev.msg.attempt, line.c_str());
}
}
int main() {
using enum Channel;
std::map<std::string, User> users{
{"rafi", {{{Push, "tok-rafi"}, {Sms, "+8801711000001"}, {Email, "rafi@example.com"}}, {}, 22, 8}},
{"nusrat", {{{Sms, "+8801811000002"}, {Email, "nusrat@example.com"}}, {Email}, 0, 0}},
{"tanvir", {{{Email, "tanvir@bad-domain"}}, {}, 0, 0}},
};
std::map<std::string, Template> templates{
{"order_shipped", {Kind::Transactional, {Push, Sms}, "Order {order} is on the way, arriving {day}"}},
{"eid_sale", {Kind::Marketing, {Push, Email}, "Eid sale: {pct}% off until Friday"}},
};
std::map<Channel, FakeProvider> providers{
{Push, {}},
{Sms, {{{"+8801811000002", {Outcome::Transient, Outcome::Transient}}}, {}}},
{Email, {{{"tanvir@bad-domain", {Outcome::Permanent}}}, {}}},
};
Service s(users, templates, providers);
const int64_t t1 = 21 * 3600 + 59 * 60 + 50;
std::printf("D1 21:59:50, the order service sends three requests:\n");
send(s, t1, {"evt-1", "rafi", "order_shipped", {{"order", "BD-7781"}, {"day", "Sunday"}}});
send(s, t1, {"evt-1", "rafi", "order_shipped", {{"order", "BD-7781"}, {"day", "Sunday"}}});
send(s, t1, {"evt-2", "nusrat", "order_shipped", {{"order", "BD-7790"}, {"day", "Monday"}}});
std::printf("workers:\n");
run(s, t1 + 10);
const int64_t t2 = 22 * 3600 + 5;
std::printf("D1 22:00:05, the marketing team starts a campaign:\n");
send(s, t2, {"camp-7:rafi", "rafi", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-7:nusrat", "nusrat", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-7:tanvir", "tanvir", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-8:rafi", "rafi", "eid_sale", {{"pct", "40"}}});
send(s, t2, {"camp-9:tanvir", "tanvir", "eid_sale", {}});
std::printf("workers:\n");
run(s, 2 * 86400);
std::printf("delivered %d, dead letters %zu, still queued %zu\n", s.delivered, s.dead.size(), s.pending());
}Scaling strategy
These stages are illustrative; real thresholds depend on hardware and traffic shape.
| Stage | Traffic | What breaks first | What changes |
|---|---|---|---|
| 1. 100 users | A few messages an hour | Nothing | Send from the app inside a background job (not the request), using the provider's SDK. A table of sent messages. |
| 2. 10K users | A few per minute | Callers waiting on slow providers; lost messages when a job crashes | A small notification module with a database-backed job queue, retries with backoff, templates in one place, an opt-out flag. |
| 3. 100K users | Several per second | One slow channel blocks the others; duplicate sends after retries | Separate queues and workers per channel, idempotency keys with a dedup store, a dead-letter queue, a status table. |
| 4. 1M users | Hundreds per second, campaign bursts | Campaigns delay OTPs; provider rate limits; preference reads on every message | Priority lanes, a campaign scheduler with throttling, a preference cache, quiet hours, a broker like Kafka or a managed queue. |
| 5. 10M+ users | Thousands per second | One provider's outages; status writes; multiple regions and languages | Two 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
| Failure | What happens | Detection | Recovery | Data safety | User impact |
|---|---|---|---|---|---|
| Provider slow or down | Calls time out; retries pile up | Error rate and latency per provider | Backoff, circuit breaker, switch to the secondary provider | Messages wait in the queue, nothing lost | Delays on that channel only |
| Worker crash mid-send | The leased message is redelivered | Queue redelivery, worker health | Another worker takes it; status check skips messages already sent | At-least-once; the tiny post-send window can duplicate | Rarely, one duplicate |
| Queue broker failure | API can't enqueue | Enqueue errors | Broker replicas take over; the API returns 503 so callers retry with the same key | Callers retry; dedup prevents doubles | Notifications delayed seconds to minutes |
| Redis (dedup) down | API can't check duplicates | Redis errors | Fall back to a unique insert on the idempotency key in the status store (slower), or accept a short risk of duplicates for non-critical templates | Dedup weakened briefly, no message loss | Possibly a duplicate |
| Preference DB down | Can't read opt-outs | Errors, cache misses | Serve from cache; for marketing, if preferences can't be read, don't send (fail closed) | Opt-outs respected | Marketing delayed; transactional continues |
| Duplicate requests | Caller retries | Dedup hits metric | Same key returns the first result | No double sends | None |
| Campaign flood | 25M messages queued at once | Queue depth and age per priority | Throttled scheduler; separate priority lanes; pause button | Queued, not lost | Campaign slower; OTPs unaffected |
| Bad template deploy | Every message renders wrong | Render errors, spike in user reports | Templates are versioned: roll back to the previous version | Messages not yet sent use the fixed version | A few wrong messages at most |
| Dead letters growing | Many messages fail permanently | DLQ size alert | Investigate (expired tokens, bad numbers, provider change), fix, replay | DLQ keeps them | Delayed 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
| Decision | Chosen | Alternative | Why chosen | When to switch |
|---|---|---|---|---|
| Accept model | Async: enqueue and return 202 | Synchronous send inside the API call | Callers never wait on providers; failures are retried safely | Tiny systems where one background job is simpler than a queue |
| Queues | One per channel, plus a high-priority lane | One shared queue | Isolation: a slow SMS gateway or a campaign can't block OTPs | Very low volume where one queue is easier to run |
| Duplicates | Idempotency key + SET NX + status check | Hope the broker's "exactly once" covers it | The last hop is an external call no broker controls | Never skip it for OTPs and payments; maybe for low-value pushes |
| Retries | Exponential backoff with jitter, max attempts, DLQ | Immediate retries, or retry forever | Gives providers room to recover and makes failures visible | Never retry forever; tune counts per channel |
| Status store | Wide-column store partitioned by user | PostgreSQL table | 100M+ writes a day with TTL and simple lookups | Under a few million a day, PostgreSQL with partitions is simpler |
| Broker | Kafka (or a managed queue) | Database-backed job table | High throughput, replay, partitions by user | A job table is fine until hundreds of messages a second |
| Providers | Two per channel with a circuit breaker | One provider | Outages and regional delivery problems are common | Low 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
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.