Design a notification system

Preference-aware, deduplicated, rate-limited delivery across email, push, SMS, and in-app: data model, delivery semantics, and failure modes.

Design: a notification system

A notification service turns a product event into messages for particular people. When a report finishes, Ada might need an inbox item and an email. Those are two separate pieces of work: the inbox can appear while the email is still waiting at a provider.

We'll follow that report from the product database to its recipients, then handle preference changes, lost send replies and a large announcement. The design starts with email and an in-app inbox. Push and SMS share the routing machinery, but need their own delivery rules.

Decide what the service promises

Accepting work means retaining enough information to finish or explain it after a restart. It does not mean every selected channel has delivered a message. We call the retained decision for one recipient and channel an intent .

For our report product, the useful contract is:

A committed report event can be retried without creating another intent for the same recipient and channel. Pending email work observes opt-outs before its dispatch decision. Once a send may be in flight, withdrawal requires a different recovery decision. A provider outage leaves recoverable work and visible uncertainty. It does not prevent the report itself from finishing. Support can distinguish suppression, local inbox visibility, provider acceptance and subsequent delivery evidence.

Product requirements should also specify how late a message may be. A report-ready message can remain useful after a short outage; an expired invitation cannot. Store an expiry time where needed and check it before starting a send. A queue retry budget should not outlive the message's usefulness.

The inbox is durable history, while a badge is a projection of what the user has acknowledged. Keep that distinction from wiki/newly-unread-indicator the unread-indicator design. Opening the inbox need not assert that every email was read.

Separate routing from delivery

The product commits the report change and an outbox entry together. A relay publishes the event. This is the wiki/event-bus-for-product-events transactional outbox boundary: losing a process after the report commits must leave the notification work discoverable.

A router consumes the event, selects recipients and channels, and records their intents. Channel workers perform those intents later. The wiki/task-queue-vs-event-stream event stream carries a fact; the work queue assigns an action. Rebuilding an analytics projection from old events should not silently resend old notifications.

The database owns intents, preferences, inbox rows and delivery history. A queue can carry intent IDs to wake workers quickly, but a scan of pending work must recover a lost wake-up. Otherwise the gap between saving an intent and publishing its queue message can strand it forever.

These are responsibilities, not a demand for six independently deployed services. One application and database can provide the first version. Split workers and queues when a slow provider or bulk workload needs isolation.

Uber's historical RAMEN account makes a related separation between deciding when to generate an update, constructing its payload and delivering it. Its foreground app transport is a different system from our email adapter; the useful architectural idea is keeping product decisions separate from transport behavior.

Retain the decision, then record attempts