Notification System
Multi-Channel Alert Platform
Design a service that sends each notification on the channels a user chose, with templates, quiet hours, priorities, a rate limit, duplicate suppression, and retries with backoff.
Overview
A notification service takes a request such as 'tell Ana her order shipped' and delivers it on the right channels: email, push, SMS or an in-app inbox. It decides which channels from the user's preferences, renders the text from a template for each channel, and hands each message to a provider. Providers fail, users sleep, and the same event can be sent twice, so the service also retries, waits for quiet hours to end, and drops duplicates.
The problem tests three things. First, whether channels are pluggable: a new channel is a new class, and the dispatch code does not change. Second, whether you separate accepting a request from delivering it: send validates and queues, and workers deliver, so a slow provider never blocks the caller. Third, whether the delivery rules are precise: which types a user cannot opt out of, what urgent means, how many retries and how far apart.
A strong answer draws the pipeline, states the rules in a table, and writes a program whose tests show a retry succeeding, a retry giving up, a notification held for quiet hours and a duplicate ignored.
Scoping the problem
- Which channels? — Each is a class — Email, push, SMS; in-app as a follow-up
- How do users choose? — The preference model — Channels per notification kind; opt out of a kind entirely
- Can a user opt out of everything? — Some messages must arrive — Security messages always go to every channel the user has
- What is urgent? — Skips quiet hours and the rate limit — Security alerts
- What happens when a provider fails? — Retries and the final state — Retry with growing delays, then mark failed and keep it
- Can the caller send the same event twice? — Duplicates annoy users — Yes; an idempotency key makes the second send a no-op
- Is delivery synchronous? — Caller latency — No: send queues, workers deliver
The pipeline
send does all the checks that can fail fast and then queues one job per channel. process delivers whatever is due. The two never hold the lock while a provider is called.
The rules
- Channels come from the user's choice for that kind — prefs.channels[kind]; an empty set sends nothing
- Security is mandatory — MANDATORY: every channel the user has an address for
- Urgent skips quiet hours and the rate limit — Priority.URGENT checks in send
- Quiet hours hold, not drop — not_before is set to the end of quiet hours
- At most 5 non-urgent sends per user per hour — A sliding window of send times per user
- The same key is sent once — _seen[key] returns the first notification id
- A missing template field fails the request — string.Template.substitute raises KeyError
- Due jobs go out by priority, then time, then order — Job ordering: priority, not_before, seq
- Retry after 60 s, then 120 s; fail after 3 attempts — backoff * 2 ** (attempts - 1), max_attempts = 3
A delivery's status
The demo in the implementation walks through each rule. Ana has quiet hours from 22:00 to 07:00 and has opted out of marketing.
- 12:00 — order shipped, key ship-A1, sent twice — One notification: the second send returns the same id
- 12:00 — marketing — Nothing queued: opted out
- 12:00 — order with no order field — KeyError from the template
- 23:00 — order shipped — Held until 07:00 the next day
- 23:00 — security alert, urgent — Email and push sent; SMS fails, retrying
- 23:01 — worker runs — SMS fails again, retrying
- 23:03 — worker runs — SMS sent on the third attempt
- 07:00 next day — worker runs — The held order is sent
- 12:00, 12:01, 12:03 (first day) — Ben's email with a bad address — Fails 3 times, then FAILED and dead-lettered
- 12:00 (first day) — Ben's sixth non-urgent send within the hour — Refused by the rate limit; allowed again an hour later
What you will be able to do
- Scope a notification service and state its delivery rules as a table.
- Add a channel as one class without touching dispatch.
- Separate accepting a request from delivering it, and keep provider calls outside the lock.
- Implement quiet hours, priorities, a per-user rate limit, idempotency keys and retries with backoff.
- Show in tests that several workers deliver each job exactly once.
Functional requirements
- send(user, kind, priority, data, now, key) picks channels from the user's preferences, renders a template per channel and queues one job per channel
- Security notifications go to every channel the user has an address for, even if they opted out
- Non-urgent notifications during quiet hours are held until quiet hours end
- At most N non-urgent notifications per user per hour (5 in the demo)
- A repeated idempotency key returns the first notification id and queues nothing
- process(now) delivers due jobs by priority; a failed delivery is retried after 60 s and 120 s, then marked FAILED and dead-lettered
- A status per notification and channel: QUEUED, RETRYING, SENT or FAILED
Non-functional requirements
- A new channel is a new Channel subclass; NotificationService does not change
- The caller never waits on a provider: send only validates and queues
- Several workers may call process at once; each job is taken by exactly one of them
- The service lock is never held during a provider call
- Nothing is lost silently: every job ends SENT or FAILED with a dead-letter entry
- Deterministic in tests: time is passed in, and a FlakyChannel fails on demand
Interview questions
- A provider times out. Walk me through the retries.
- Four workers call process() at once. How do you make sure a message is not sent twice?
- How do you add a new channel, such as Slack?
- How do you stop a user from getting 30 notifications in a minute?
- How would you persist this and run it on many machines?
Sign up free to read the full topic
More Low-Level Design topics
Start free on Cariara · Coding problems · System design studio