System design / HLD + LLDAdvanced · 9 sections · 7 figures · 17 min read

Notification Platform

  • Kafka
  • Retries
  • Delivery guarantees
  • Designed · build in progress
The constraint

Absorb campaign bursts, survive provider outages, and never let a marketing blast delay a one-time password.

The decision

Outbox to priority-split Kafka topics, quota-aware workers behind provider adapters, delay topics and a DLQ.

50M/day
notifications
5.8k/s
peak intake
5 s
p99 target for an OTP

This design builds on production experience moving SMS, email and WhatsApp notification flows onto Kafka. The case study itself is a fresh, general design; nothing here describes a specific employer’s system.

1. Problem and requirements

Internal services (orders, payments, claims, marketing) call one platform to notify people. The platform owns templates, preferences, provider integrations, retries and delivery tracking, so no product team writes its own SMS client again.

Functional requirements

# Requirement
F1 Accept a notification for a recipient through one or more channels: SMS, email, WhatsApp, push.
F2 Send immediately or at a scheduled time.
F3 Render versioned templates with variables and locale.
F4 Respect user preferences, opt-outs, quiet hours and legal consent per channel.
F5 Prioritise: critical (OTP, security), transactional (order updates), bulk (marketing).
F6 Choose a provider per channel, fall back to another provider when one fails.
F7 Retry transient failures with backoff; park permanent failures in a dead-letter queue with a safe replay.
F8 Track status through the provider’s delivery callbacks; expose status and history per notification.
F9 Deduplicate requests and suppress notification storms to one recipient.
F10 Enforce per-tenant (calling team) quotas.

Non-functional requirements

# Requirement Target
N1 Intake durability An accepted request (202) is never lost
N2 Intake latency p99 under 100 ms
N3 Critical delivery latency (accept to provider hand-off) p99 under 5 s, even during a bulk campaign
N4 Transactional delivery latency p99 under 60 s
N5 Duplicate sends As close to zero as the providers allow, and never from our own retries of a known-successful send
N6 Intake availability 99.95%
N7 Audit retention 13 months of status history

The delivery guarantee, stated precisely

The platform guarantees at-least-once processing of every accepted request, with idempotent sending so that our own retries do not produce duplicates. It cannot guarantee exactly-once delivery to a person. If a worker crashes after a provider has accepted a message but before we record that, we cannot know whether the message went out. For providers that accept an idempotency reference, the retry is de-duplicated by the provider. For those that do not, a rare duplicate is possible and is preferred over a lost OTP. This trade-off is written into the API contract, so callers are not surprised.

Out of scope

In-app inbox, two-way conversational messaging, A/B testing of templates, and the marketing segmentation that decides who receives a campaign (the platform receives already-resolved recipients).

2. Capacity estimation

Assumptions: 50 million notifications a day; a bulk campaign can push intake to 10× the average for an hour; 3 status events per notification (accepted by provider, delivered, opened or failed); a stored notification with its rendered payload is about 1 KB; status events about 150 bytes.

Metric Working Result
Average intake 50,000,000 / 86,400 ≈ 580 requests/s
Peak intake (campaign) 580 × 10 ≈ 5,800 requests/s
Provider send rate needed at peak Bounded by provider quotas, not by us e.g. SMS 1,000/s, email 2,000/s, WhatsApp 500/s, push 5,000/s
Status callbacks 50 M × 3 / 86,400 ≈ 1,740/s average
Notification storage per day 50 M × 1 KB ≈ 50 GB/day
Status storage per day 150 M × 150 B ≈ 22 GB/day
Hot storage (30 days) (50 + 22) × 30 ≈ 2.2 TB, partitioned by day
Archive (13 months, compressed ~5×) 72 GB × 395 / 5 ≈ 5.7 TB in object storage
Kafka throughput at peak 5,800 × about 1 KB ≈ 6 MB/s in, a few times that out across consumers

What this says: intake must be faster than delivery, because providers cap how fast we can send. A campaign therefore builds a backlog by design, and the system must keep that backlog durable, drain it at exactly the provider’s rate, and keep critical traffic out of it. Kafka’s throughput is not the constraint; partitioning, priority isolation and provider quotas are.

3. Architecture and APIs

High-level architecture

FIG 01ArchitectureHigh-level architecture
Component Responsibility
Intake API Validates, authenticates the caller, applies the idempotency key, writes the notification and an outbox row in one transaction, returns 202.
Outbox relay Publishes outbox rows to notify.requested and marks them sent.
Router Loads preferences and consent, suppresses opted-out channels, renders the template, applies storm suppression and tenant quotas, picks a priority topic per channel.
Channel topics One topic per channel and priority; isolates critical from bulk traffic and one channel from another.
Channel workers Consume with bounded concurrency, take a provider quota token, call the adapter, record the result, route failures.
Provider adapters Hide each provider’s API, error codes and idempotency mechanism behind one ChannelProvider interface.
Retry topics and DLQ Delayed retries without blocking partitions; a dead-letter queue with full diagnostics and a replay tool.
Callback API Receives provider delivery reports, verifies signatures, applies them through the state machine.
Scheduler Releases scheduled notifications when they are due.
PostgreSQL Source of truth for notifications and status history, partitioned by day.
Redis Provider quota buckets (see 03 · Rate Limiter), dedup keys, storm counters.

API contracts

Method and path Purpose Success Notable errors
POST /notifications Accept one notification 202 id and status ACCEPTED 400, 409 DUPLICATE (same key, different body), 422 TEMPLATE_INVALID, 429 TENANT_QUOTA
POST /notifications:batch Accept up to 1,000 202 with per-item results 413
GET /notifications/{id} Status and history 200 404
DELETE /notifications/{id} Cancel if not yet sent 202 409 ALREADY_SENT
POST /callbacks/{provider} Provider delivery reports 204 401 bad signature
PUT /recipients/{id}/preferences Opt in or out per channel and category 200
POST /notifications
Authorization: Bearer <service token for team "orders">
Idempotency-Key: order-77812-shipped

{
  "recipient": { "userId": "u_4821" },
  "category": "ORDER_UPDATE",
  "priority": "TRANSACTIONAL",
  "channels": ["PUSH", "SMS"],
  "fallback": "SEQUENTIAL",
  "template": { "id": "order-shipped", "version": 3, "locale": "en-IN",
                "vars": { "orderId": "77812", "eta": "Thursday" } },
  "sendAt": null,
  "expiresAt": "2026-10-23T18:00:00Z"
}
202 Accepted
{ "id": "ntf_01JB8…", "status": "ACCEPTED", "idempotencyKey": "order-77812-shipped" }
  • fallback: SEQUENTIAL means: try push; if push is not delivered within its window, send SMS. ALL sends on every listed channel.
  • expiresAt stops stale messages: an OTP that is 10 minutes old is dropped, not sent.

Deployment

FIG 02ArchitectureDeployment

Workers autoscale on consumer lag, capped by the partition count and by the provider quota: adding workers beyond the quota only adds rejected calls.

4. Internal design and workflows

Class diagram

FIG 03Class modelClass diagram

Provider names are examples of the adapter pattern, not a recommendation of specific vendors.

Delivery state machine

FIG 04State machineDelivery state machine

Transitions are applied with a conditional update, so only legal moves succeed and a late event cannot drag a delivery backwards:

UPDATE delivery
SET state = :to, updated_at = :at, provider_message_id = coalesce(:pmid, provider_message_id)
WHERE id = :id AND state = ANY(:allowedFrom) AND updated_at <= :at;

The rank of each state (SENT < DELIVERED) plus the event timestamp lets an out-of-order callback (a SENT report arriving after DELIVERED) be recorded in history but ignored for the current state.

Data model and indexes

FIG 05Data modelData model and indexes
-- Idempotent intake: one notification per caller and key
CREATE UNIQUE INDEX ntf_idem ON notification (tenant, idempotency_key);
-- Provider callbacks find their delivery
CREATE UNIQUE INDEX dlv_provider_msg ON delivery (provider, provider_message_id);
-- Scheduler: due work only
CREATE INDEX ntf_due ON notification (send_at) WHERE send_at IS NOT NULL;
-- Relay: unpublished outbox rows in order
CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

notification, delivery and delivery_event are range-partitioned by day. Partitions older than 30 days are exported to object storage (Parquet) and dropped, which is far cheaper than DELETE.

Kafka topology

FIG 06ArchitectureKafka topology
  • Partition key recipientId: messages to one person stay in order (the “order placed” SMS does not arrive after “order shipped”), and storm suppression for one person happens in one partition.
  • Priority by topic, not by message field: workers poll critical first with dedicated threads; a bulk backlog of millions sits in sms.bulk and cannot delay an OTP. Kafka has no priority within a partition, so separate topics are the only reliable way.
  • Retry topics with fixed delays: a failed message moves to a delay topic and the main partition keeps flowing. Blocking retries inside the main consumer would stall every message behind the failing one.
  • Commit after recording: a worker commits the offset only after the delivery state is written. A crash before that replays the message, and the state machine plus idempotency reference prevent a second send.

Dispatch sequence

FIG 07SequenceDispatch sequence

Retry policy

RetryDecision next(int attempt, SendResult r) {
    if (r.permanent()) return RetryDecision.fail(r.reason());              // bad number, unsubscribed
    if (attempt >= 4)  return RetryDecision.deadLetter("retries exhausted");
    Duration base = List.of(Duration.ofMinutes(1), Duration.ofMinutes(10), Duration.ofHours(1)).get(attempt - 1);
    long jitterMs = ThreadLocalRandom.current().nextLong(base.toMillis() / 5);   // up to +20%
    return RetryDecision.after(base.plusMillis(jitterMs), r.retryAfter());    // honour provider Retry-After if larger
}

Critical notifications use a shorter schedule (5 s, 15 s, 30 s) and switch provider on the first failure, because an OTP is worthless after a few minutes.

Storm suppression and deduplication

Two different problems with two different keys:

Problem Key Mechanism
A caller sends the same request twice (tenant, Idempotency-Key) Unique index at intake; a repeat returns the original 202
A bug sends the same content to one person 50 times with different keys (recipient, template, hash(vars)) Redis SET NX with a 10-minute TTL in the router; repeats become SUPPRESSED (DUPLICATE_CONTENT)
A legitimate flood to one person (20 order updates in a minute) (recipient, channel) Token bucket, e.g. 5 SMS per 10 minutes for non-critical; excess is collapsed into a digest or dropped per category

Transaction boundaries

Step Atomic unit
Intake Insert notification + deliveries + outbox row: one database transaction
Publish Relay reads outbox, produces to Kafka, marks published_at; a crash in between republishes (consumers de-duplicate by notification ID)
Send Claim (QUEUED → SENDING) is one statement; the provider call is outside any transaction; the result is a second statement
Callback Insert event + conditional state update: one transaction

5. Failure modes and consistency

Scenario What would go wrong Design response
Intake writes the DB, then Kafka publish fails Accepted but never sent Transactional outbox: the row is committed with the notification; the relay publishes it whenever Kafka is back.
Kafka redelivers a message (rebalance, crash before commit) Duplicate SMS Conditional claim QUEUED → SENDING; a message already SENT is skipped; provider idempotency reference covers the crash-after-send window where supported.
Worker crashes after the provider accepted but before recording Unknown outcome On redelivery, a delivery stuck in SENDING past a timeout is resolved by querying the provider by reference when the API allows; otherwise it is resent and the rare duplicate is accepted (documented trade-off).
SMS provider A outage Messages pile up or fail Circuit breaker per provider (open on 50% errors over 30 s); ProviderSelector routes to provider B; half-open probes restore A. Critical traffic fails over first.
Provider returns 429 (quota) Retry storm makes it worse Quota gate in Redis matches the provider’s contracted rate; on 429, the worker pauses the partition and honours Retry-After instead of retrying instantly.
Campaign floods intake OTPs delayed Separate critical topics and dedicated worker threads; tenant quotas cap bulk intake; bulk topics simply build lag.
Poison message (template renders an exception every time) Partition stuck Deserialisation and render errors go straight to the DLQ with the stack trace, payload and offset; the partition moves on.
Out-of-order callbacks SENT overwrites DELIVERED State rank plus event timestamp; illegal backward moves are ignored for state but kept in history.
Forged callbacks Fake “delivered” statuses Provider signature verification (HMAC or provider-specific), IP allow-list, and replay protection by event ID.
Message outlives its usefulness A 2-hour-old OTP arrives expiresAt checked at claim time; expired deliveries become EXPIRED, never sent.
PostgreSQL primary fails Intake down Standby promotion; intake returns 503 with Retry-After meanwhile, and callers retry with the same idempotency key. Workers pause rather than send without recording.
Callback storm after a provider recovers Database write spike Callback API writes events in batches through its own small queue; state updates are idempotent so replays are safe.

Consistency summary

  • Strong (single database transaction): intake, idempotency, each state transition.
  • Eventual: the Kafka pipeline from intake to send; status from provider callbacks.
  • Never assumed: that Kafka delivers once, that a provider call that timed out failed, or that callbacks arrive in order.

Security and abuse

  • Callers authenticate with service credentials; each caller (tenant) has quotas and an allow-list of templates and categories.
  • Templates are reviewed and versioned; callers pass variables, never free-form message bodies, which stops the platform being used for arbitrary spam or phishing.
  • Phone numbers and emails are personal data: encrypted at rest, masked in logs and traces, deleted or anonymised after retention.
  • Opt-out (STOP replies, unsubscribe links) is processed as a preference update with the highest priority.

6. Scaling evolution

Stage Trigger Design Why
1. Single application Launch, under 50 req/s One Spring Boot app: intake, a database-backed job queue (SELECT … FOR UPDATE SKIP LOCKED), one worker pool, one provider per channel A durable queue in PostgreSQL is enough and easy to operate; Kafka is not yet justified
2. Growing traffic Queue table contention, or several consumers need the stream Outbox to Kafka; per-channel topics and workers; bounded concurrency; lag dashboards Independent consumers, replay, and per-channel scaling
3. Provider instability Real outages and quota rejections Second provider per channel, circuit breakers, quota gate, delayed retry topics, DLQ console One provider’s bad day must not stop delivery
4. Priority conflicts Campaigns delaying transactional messages Priority-split topics, dedicated critical workers, tenant quotas Isolation by topic is the only real priority in Kafka
5. Large multi-tenant Many teams, billions per month Per-tenant quotas and dashboards, partitioned status tables with archive, cost-aware routing (cheapest healthy provider per country) Fairness, cost and retention dominate
6. Multi-region Regional data rules or latency Regional intake and workers sending through regional providers; status replicated to a global view Data residency for phone numbers and messages

7. Operations and validation

Metrics and SLOs

Signal SLO or alert
Intake success rate 99.95%; page on 5-minute burn
Critical accept-to-sent latency p99 under 5 s; page on breach
Consumer lag and oldest unprocessed age per topic Critical: alert at 10 s; standard: 2 min; bulk: dashboard only
Provider error rate and circuit state Alert when a breaker opens
Delivery rate per channel (delivered ÷ sent) Alert on a 20% drop versus the 7-day baseline (silent provider failure)
DLQ inflow Alert above 0.1% of sends
Outbox oldest unpublished row Alert at 30 s
Duplicate-suppression hits Dashboard; spikes reveal caller bugs

Traces carry the notification ID from intake through router, worker, adapter and callback, so one ID shows the whole life of a message.

Test plan (to build)

Test Proves
Kill a worker between provider accept and DB write (fault injection) Behaviour of the crash window, with and without provider idempotency
Replay the same Kafka message 10 times One send
Fake provider returns 503 for 2 minutes Retries, breaker opens, failover to provider B, recovery
Fake provider returns 429 with Retry-After Quota respected, no retry storm
Callbacks delivered in reverse order Final state is the latest legal state
1 million bulk plus a trickle of OTPs OTP p99 stays under 5 s
Poison template Lands in DLQ; partition keeps moving

Load test (to measure)

Using fake providers with configurable latency, error rate and quotas: accepted requests per second, delivery throughput per channel, accept-to-sent p95 and p99 per priority, consumer lag over time, and behaviour during a simulated 5-minute provider outage. These will be charted and labelled as measured only after the runs exist.

8. Decision records

DR-1: Kafka, introduced at stage 2, not on day one

  • Decision: start with a PostgreSQL-backed job queue; move to Kafka with an outbox when independent consumers, replay or per-channel scaling are needed.
  • Rejected for day one: Kafka from the start (operational weight with no benefit below a few hundred messages a second). Rejected for later: RabbitMQ (good for task queues, but replay and multiple independent consumers of the same stream are first-class in Kafka).

DR-2: Priority by topic

  • Decision: separate topics per channel and priority; workers drain critical topics first with dedicated capacity.
  • Rejected: a priority field on one topic (Kafka partitions are FIFO, so a bulk backlog still blocks critical messages behind it).

DR-3: Non-blocking retries through delay topics

  • Decision: failed messages move to retry.1m, retry.10m, retry.1h, then the DLQ.
  • Rejected: retrying inside the consumer (one bad provider stalls the whole partition); Thread.sleep in a consumer (triggers rebalances).

9. Limitations, and implemented versus proposed

Known limitations

  • Exactly-once delivery to a person is impossible; a rare duplicate is possible with providers that lack idempotency support.
  • Per-recipient ordering holds within one channel, not across channels (push and SMS can arrive in either order).
  • Delivery and open tracking depend on providers; email opens in particular are unreliable signals.

Status

Item Status
Requirements, delivery guarantee, capacity, HLD, APIs, deployment Designed here
Class, ER, state, sequence and Kafka topology diagrams; retry and suppression logic Designed here
Failure scenarios, consistency, security, scaling, SLOs Designed here
Decision records, trade-offs, limitations Designed here
Java implementation (intake, router, one channel worker, two fake providers) To build
Fault-injection, ordering and replay tests To build
Load test with provider outage To measure