βIβll anchor the design on invariants first, then walk through requirements, API semantics, data model and consistency, HLD, critical flows, scaling, failure handling, tradeoffs, and observability.β
That already signals senior/principal thinking.
These are the rules that should remain true even under retries, provider failures, backlog, and node crashes.
notification_idcampaign_id (if promo)execution_idrequest_idprovider_request_idThese sound generic, but the value is in the mechanisms they force:
That is what makes this high-signal in interview.
You can state them like this:
You donβt need exact numbers unless interviewer asks, but it helps to say:
βCritical and promo have different SLOs, so I separate both data flow and worker budgets.β
Thatβs a strong Principal framing.
A notification system often has an ingestion API plus internal scheduler/worker interfaces.
Example:
POST /notifications/critical
Request:
Example:
POST /notifications/promotional
Request:
campaign_idstart_atavailable_untilExample:
POST /campaigns/{campaign_id}/cancel
This matters because promo queues may already contain work.
The scheduler expands campaigns into controlled batches of recipient work.
Workers call providers and classify results:
Every execution should carry:
execution_idnotification_idavailable_untilchannelThis lets every downstream stage make correct decisions.
Like many distributed systems:
available_untilThis is where the system becomes real.
We need to model:
NotificationsRepresents the logical notification intent.
Fields:
notification_idtenant_idtype (critical | promotional)campaign_id (nullable)channeltemplate_idcreated_atavailable_untilpriority_classFor critical direct sends:
For promo:
CampaignsRepresents the promo campaign definition.
Fields:
campaign_idtenant_idstart_atavailable_untilscheduled, running, paused, cancelled, completed, expired)Because campaign lifecycle is not the same as per-recipient delivery execution.
ExecutionsThis is the most important operational table.
It tracks the per-recipient work item to be sent or attempted.
Fields:
execution_idnotification_idcampaign_id (nullable)tenant_idrecipient_idchanneldue_atavailable_untilstatus (pending, leased, enqueued, sending, sent, failed, expired, suppressed)lease_ownerlease_untilversionattempt_countproviderprovider_request_idretention_ttlExecutions is separateBecause:
OutboxUsed by the scheduler to guarantee βclaimed work becomes enqueuedβ reliably.
Fields:
shard_idoutbox_idexecution_idpending, sent)It prevents the classic gap:
The outbox closes that gap.
DeliveryAttempts (optional)If you want detailed per-attempt history.
Fields:
execution_idattempt_noIf interviewer wants simpler MVP, keep attempts folded into Executions.
At this scale, partitioning matters a lot.
Use:
Example logical key:
PK = shard_id:bucket_5mSK = due_at#execution_idThis is exactly the kind of pattern that scales scheduler scans well:
Because a large campaign can create a hot partition and kill throughput.
Because it spreads writes uniformly.
This is one of the biggest pitfalls.
Immediately expand a promo campaign targeting 10M users into 10M execution rows at once.
Store in campaign:
Then scheduler:
This is one of the strongest design upgrades.
The scheduler should meter expansion, not just meter delivery.
Thatβs very Principal.
Every promo execution carries:
available_untilavailable_until in payloadIf you use DynamoDB TTL, AWS states deletes are best-effort and expired items are typically deleted βwithin a few days,β so TTL should be treated as cleanup, not correctness logic.
That is a strong, factual point to call out.
Be precise here.
If worker sends to provider, crashes before persisting success:
Now we draw the system.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Producers / Triggering Systems β
β chat service, recommendation engine, cron jobs, product backends β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββ
β Notification Ingress API β
β----------------------------β
β Validate request β
β Classify critical/promo β
β Idempotency check β
β Persist intent β
ββββββββββββββ¬ββββββββββββββββ
β
βββββββββββββββββββββ΄βββββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ
β Critical Path Router β β Promo Campaign Router β
β----------------------------β β----------------------------β
β Enqueue directly β β Persist campaign β
β Strict low-latency path β β Segment snapshot ref β
ββββββββββββββ¬ββββββββββββββββ ββββββββββββββ¬ββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ
β Critical Queue / Log β β Promo Scheduler Fleet β
β (e.g. Kafka / fast queue) β β----------------------------β
β----------------------------β β Own shard ranges β
β Partitioned by recipient β β Scan due executions β
β Consumer groups β β Controlled fanout β
ββββββββββββββ¬ββββββββββββββββ β Claim + Outbox β
β β Expiry check β
β ββββββββββββββ¬ββββββββββββββββ
β β
β βΌ
β ββββββββββββββββββββββββββββββ
β β Promo Queue(s) β
β β (e.g. SQS / queue pool) β
β ββββββββββββββ¬ββββββββββββββββ
β β
βββββββββββββββββββββββββ¬ββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββ
β Channel Worker Pools β
β----------------------------------β
β Push workers β
β Email workers β
β SMS workers β
β Expiry check before send β
β Dedup / idempotency β
β Retry classification β
βββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββ
β Provider Throttle / Rate Control β
β----------------------------------β
β Per-provider token buckets β
β Per-tenant / per-campaign caps β
β Slow-start + backoff β
βββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββ
β External Providers β
β APNs / FCM / SES / SMTP / SMS aggregators β
ββββββββββββββββββββββββββββββββββββββββββββββ
State / coordination:
Executions store
Campaigns store
Outbox store
Idempotency / dedup cache
Metrics / tracing / audit
This is the entry point.
Responsibilities
Because ingestion may be fast, while actual delivery can be deferred, retried, throttled, or cancelled.
Critical notifications need low latency.
Responsibilities
Because critical traffic cannot wait behind segment expansion and promo backlog.
This is one of the most important architectural separations.
For promo, the system should not immediately explode into all recipient sends.
Responsibilities
Because big campaigns can overwhelm storage and queues instantly.
This is the brain of the promotional path.
Responsibilities
To reduce claim races and simplify distributed coordination.
This is one of the strongest ideas from the Hello Interview discussion.
A fast queueing system (often Kafka-like or another high-throughput log/queue) for critical work.
Kafkaβs consumer group model is specifically designed so a pool of consumers can divide work across processes/machines for scalability and fault tolerance.
You do not need Kafka specifically, but this is a reasonable design if interviewer asks for examples.
Promo can use a different queueing shape:
If using SQS-like semantics, visibility timeout matters because messages can be re-delivered if not deleted before the timeout expires. AWS documents that SQS messages become visible again when visibility timeout expires, so workers must be idempotent.
Separate worker pools for:
Because:
This makes scaling and throttling much cleaner.
A crucial component many candidates omit.
Responsibilities
Even if internal queues are healthy, providers are still bottlenecks.
Example: user sends a 1:1 chat message, recipient should get push notification quickly.
Producer calls ingress API with critical notification request.
Ingress:
Notification is enqueued directly onto critical queue/log, partitioned by a stable key such as:
Worker:
Worker marks:
sentIt avoids:
That path isolation is the core idea.
Example: βrecommend this content to all users in segment X before 6 PM.β
Producer creates campaign with:
campaign_idstart_atavailable_untilSystem stores:
It does not create all recipient sends at once.
Scheduler owning relevant shards:
Scheduler pages through segment members in small batches
(e.g., 5kβ20k users/sec per campaign depending capacity).
For each batch:
This is the biggest design point:
Expansion itself is rate-limited.
Outbox publisher sends execution payloads to promo queue(s).
Worker:
available_untilexpired and dropCampaign may end as:
This is realistic and strong to say in interview.
This is a key interview probe.
A promotional notification must not be sent after available_until.
Before enqueue:
now > available_until, do not enqueueCarry available_until in the message.
Before provider call:
expiredTTL can eventually clean old rows, but is not relied on for correctness.
If using SQS-like queues, visibility timeout and redelivery mean a message can reappear later; that makes the worker-side expiry check mandatory. AWS documents that visibility timeout expiration makes a message visible again for another consumer.
That is a great concrete point.
Examples:
Action:
available_untilExamples:
Action:
Retries must be:
This is where you show Principal judgment.
The design should explicitly solve each.
This is foundational.
Critical:
Promo:
You can independently scale:
This prevents priority inversion.
At 1M notifications/sec, the execution store can become the bottleneck if you treat it as the source of truth for every micro-step.
Use queues as the work to do, and store only meaningful state transitions:
That can significantly reduce write amplification.
Use:
This aligns with the shard-and-bucket approach discussed in the Hello Interview thread.
This is the hardest part.
βCampaign starts β generate all recipient executions immediately.β
It turns one giant spike into a managed flow.
βI treat segment expansion as a first-class resource-consuming operation with its own backpressure.β
That sounds very strong.
Assign:
shard_id = hash(execution_id or campaign page token) % KEach scheduler instance owns a disjoint shard range.
If a scheduler dies:
This is one of the best ideas to include.
Providers are external bottlenecks.
Use hierarchical token buckets:
Because a Gmail domain throttle problem is different from an SMS aggregator cap.
Ramp up gradually for big campaigns:
This is realistic and advanced.
Use separate queues or queue classes for:
To avoid:
Scheduler claims work, then crashes before enqueueing.
Use claim + outbox pattern:
This is a very strong design point.
Message reappears due to consumer crash or visibility timeout expiry.
SQS-like systems explicitly rely on visibility timeout to hide a message during processing, and if the worker does not delete it before the timeout, it can be delivered again.
execution_idQueue delay grows; many promo messages will become stale before send.
available_until, stop further expansionThis is very practical and often overlooked.
Provider rejects or slows requests, retries can explode.
Retries should not continue at full speed into a failing provider.
Cannot write status transitions or scheduler state.
Itβs usually better to temporarily stop non-critical campaign expansion than to create unknown delivery state.
This is where you sound Principal instead of just βcorrect.β
Because they have fundamentally different SLOs and failure tolerance:
A single shared pipeline will create priority inversion.
Because it causes:
Controlled expansion is more stable and more cost-efficient.
Because it reduces:
It gives deterministic ownership and scales horizontally.
Because βclaim then enqueueβ is not atomic across DB + queue.
Outbox gives reliable handoff and closes the βclaimed but never enqueuedβ gap.
Because queues alone do not solve provider limits.
Without provider-aware throttling:
Because TTL cleanup is delayed/best-effort in many datastores.
For example, DynamoDB TTL deletes are best-effort and typically occur within days, so correctness must be enforced at read/dispatch/worker time, not by background deletion alone.
Thatβs a strong factual tradeoff.
A notification system needs strong observability because βit didnβt sendβ can be hard to debug.
Every event should include:
request_idnotification_idcampaign_idexecution_idtenant_idchannelproviderprovider_request_idshard_idThat makes end-to-end tracing possible.
Important alerts include:
A trace should show:
That is how you debug βwhy didnβt user X get notification Y?β
I would design a dual-path notification system:
Thatβs a strong, production-grade story.
βIβd split the system into two isolated paths: a low-latency critical path that goes straight from ingest into a fast queue and channel workers, and a promotional path that stores campaign intent, then uses a shard-owned scheduler to expand recipients in controlled batches. The promo scheduler uses claim-plus-outbox to enqueue safely, and both the scheduler and workers enforce available_until so expired promotions are never sent. Iβd isolate channel workers, apply provider-aware token buckets and circuit breakers, and use explicit execution state plus dedup keys to get at-least-once processing internally but exactly-once logical delivery effects.βDefine the APIs expected from the system. This is your chance to analyze and define the read and write paths so that you can come up with the high-level design...
Describe the overall system architecture. Identify the main components needed to solve the problem end-to-end. Use the diagramming tool to create a block diagram.
Deep dive into 2-3 key components. Explain how they work, how they scale, discuss tradeoffs, capacity, and any relevant algorithms or data structures.