A notification service looks like a thin wrapper around a few provider SDKs until it has to serve a real product: dozens of event types across email, SMS and push, each with its own template, its own urgency, its own per-user opt-out, and a provider on the other end that has its own rate limits and its own outages. Interviewers reach for this scenario at the architect level because it combines several distinct hard problems — prioritized queueing so a password-reset email never queues behind a marketing blast, idempotent delivery so a retried event doesn't spam a user twice, and graceful provider failover so one vendor's bad day doesn't become yours — inside a service that most engineers underestimate as "just call an API." A strong answer treats notification delivery as a distributed systems problem with a user-facing consequence, not a CRUD wrapper. This page works through the design the way an architect-level loop runs: requirements, architecture, then the deep dives on preferences, queueing, templating, rate limiting, failover and delivery tracking.
Q1 What are the functional and non-functional requirements for a multi-tenant notification service spanning email, SMS and push?#
Short answer: Functionally, the service needs to accept a notification request from many internal producers, render it through a template for the right channel and locale, respect the recipient's preferences, and deliver it through one or more provider integrations; non-functionally, the dominant requirements are that different notification types have genuinely different latency and durability needs, and that the system must never double-send a notification even though the infrastructure underneath it is inherently at-least-once.
Draw a clear line between notification classes early, because it drives almost every later decision: transactional notifications (password reset, order confirmation, fraud alert) need low latency and near-certain delivery, while bulk or marketing notifications (a weekly digest, a promotional blast) tolerate minutes of delay and can be throttled aggressively without hurting the product. State the delivery guarantee explicitly too — "at-least-once with idempotent consumers," the same effectively-once framing used in payment systems, because building for literal exactly-once here is the same trap it is anywhere else in distributed systems; see CAP, Consistency Models and Idempotency Interview Questions for why. Multi-tenancy adds one more axis: if this service serves many internal product teams, each tenant needs its own rate-limit budget and its own provider credentials, so a runaway sender from one team can't exhaust the shared SMS budget for everyone else.
What interviewers look for: an explicit transactional-versus-bulk split established before any architecture is drawn, since it's the requirement that justifies prioritized queueing later.
Common mistakes: treating all notifications as equally urgent, which leads to a design with no priority mechanism and no good answer for "how do you keep marketing volume from delaying a password reset."
Q2 Design the high-level architecture — what are the major components from "something happened" to "the user got a notification"?#
Short answer: An ingestion API that accepts a notification request and validates it against a known template, a preference check that can short-circuit delivery entirely, a template-rendering step that produces channel-specific content, per-channel queues that feed dedicated worker pools, provider adapters behind a common interface, and a delivery-status pipeline that ingests provider callbacks and writes back the final state.
Producers (other services) call the ingestion API with an event type, a recipient identifier and a payload of template variables — never pre-rendered text, so the rendering, localization and preference logic all live in one place instead of being duplicated across every producer. The request is validated and immediately enqueued rather than processed synchronously, because rendering and provider calls have unpredictable latency that shouldn't block the caller. Workers pull from channel- and priority-specific queues, check preferences one more time (preferences can change between enqueue and send for a delayed digest), render the template, and call the appropriate provider adapter — a thin abstraction (IEmailProvider, ISmsProvider, IPushProvider) that lets you add or fail over between vendors without touching the rest of the pipeline. Provider delivery-status webhooks land on a separate endpoint and update a per-notification status record, closing the loop for the delivery-tracking question later on this page.
What interviewers look for: producers sending structured events rather than pre-rendered content, and a provider-adapter abstraction that decouples the pipeline from any single vendor's SDK.
Follow-up questions:
- Where would you put fraud or abuse checks — inline in ingestion, or in the worker pipeline?
- How would you let a product team add a new notification type without a deployment of this service?
Q3 How would you model and enforce user notification preferences — channel opt-outs, quiet hours, frequency caps?#
Short answer: Store preferences per user, per notification category (not a single blanket toggle), including opted-in channels, a quiet-hours window in the user's own time zone, and a frequency cap per category; check preferences at enqueue time to avoid wasted work, and check again immediately before send for anything that can sit in a queue long enough for the user's preferences to have changed.
Category-level granularity matters because a user who opts out of marketing email still expects a fraud alert or a password reset to reach them — collapsing preferences into one global switch either annoys users into disabling everything or, worse, gets legally regulated categories suppressed by a well-meaning opt-out. Quiet hours need to be evaluated in the recipient's local time zone, not the sender's, and the system needs a policy for what happens to a notification that arrives during quiet hours: hold it until the window opens (fine for a digest, wrong for a security alert, which should always bypass quiet hours) or drop the delay entirely for categories marked urgent.
public sealed record NotificationPreference(
string UserId,
string Category,
IReadOnlySet<string> EnabledChannels,
TimeOnly? QuietHoursStart,
TimeOnly? QuietHoursEnd,
string TimeZoneId,
int? MaxPerDay,
bool BypassQuietHours);What interviewers look for: per-category rather than global preferences, and an explicit answer for which categories are allowed to override quiet hours and frequency caps, since treating every category identically is a common design gap.
Common mistakes: evaluating quiet hours in UTC or server local time instead of the recipient's own time zone.
Q4 Design the queueing and prioritization strategy — how do you keep a password-reset email from queuing behind a marketing blast?#
Short answer: Use separate queues per priority tier (and typically per channel), with workers configured to drain higher-priority queues first or with a much larger share of worker capacity, so a transactional queue never has to wait behind a bulk queue's backlog even when both are backed by the same broker.
A single shared queue with a priority field on each message is the tempting shortcut, but most broker implementations don't reorder messages already sitting in a queue, so a large bulk batch enqueued moments before an urgent message can still delay it. Physically separate queues avoid that: a message broker such as Azure Service Bus, modeled with topics and per-priority subscriptions, or simply distinct queue names per (channel, priority) pair, lets you dedicate worker concurrency explicitly — for example, workers that always check the transactional queue first and only pull from the bulk queue when the transactional queue is empty, or a fixed ratio of worker slots reserved for transactional traffic so bulk volume can never starve it entirely. Apply backpressure at the bulk tier specifically: if the bulk queue's depth exceeds a threshold, throttle the producers or the workers feeding it rather than letting it grow unbounded and consume shared infrastructure capacity that transactional traffic also depends on, such as the provider's own rate limit.
What interviewers look for: recognizing that a priority field on a shared queue is usually insufficient, and reaching for physically or logically separate queues with dedicated worker capacity instead.
Common mistakes: relying on message priority alone without reserving worker capacity, so a large bulk backlog still starves transactional throughput indirectly by consuming all available workers.
Q5 How would you implement templating and localization so product teams can add notifications without redeploying this service?#
Short answer: Store templates externally — in a database or a versioned configuration store, keyed by notification type, channel and locale — rather than compiling them into the service, and render them through a small, sandboxed placeholder-substitution engine that only fills in named variables, so adding a notification type is a data change, not a code change.
Each template needs a subject/body pair per channel (SMS strips to plain text with a length budget; push needs a title and a short body; email supports richer formatting) and a locale-specific variant, with a defined fallback locale so a user whose preferred language has no translation yet still gets something readable instead of a blank message. Keep the rendering surface intentionally limited to variable substitution and simple conditionals rather than a full scripting language, both for safety (a template shouldn't be able to execute arbitrary code) and for product teams' ability to self-serve without engineering review of every change.
public interface INotificationTemplate
{
string Render(IReadOnlyDictionary<string, string> variables, string locale);
}
public sealed class CompositeTemplate(string pattern) : INotificationTemplate
{
public string Render(IReadOnlyDictionary<string, string> variables, string locale) =>
variables.Aggregate(pattern, (text, kvp) => text.Replace($"{{{kvp.Key}}}", kvp.Value));
}What interviewers look for: templates as external, versioned data rather than code, and a defined fallback-locale strategy, since "what happens when there's no translation yet" is a question most candidates haven't considered.
Q6 Design rate limiting for outbound notifications — both protecting your own system and respecting provider or carrier limits.#
Short answer: Apply a token-bucket limiter per provider (and per sending identity, such as a phone number or sender email, if the provider enforces limits at that level) to stay under contracted rate limits, plus a separate per-user frequency cap to prevent a single user from being flooded regardless of how many distinct producers are trying to notify them.
Provider-side limiting protects a shared, expensive resource — many SMS and push providers throttle or temporarily ban a sending identity that bursts past its contracted rate, and if that identity is shared across your whole platform, one misbehaving producer can take down delivery for everyone. System.Threading.RateLimiting's PartitionedRateLimiter, the same primitive behind ASP.NET Core's rate-limiting middleware, works well here even outside an HTTP pipeline: partition by provider and sending identity, and configure each partition as a token bucket sized to that provider's contracted rate.
var limiter = PartitionedRateLimiter.Create<SendRequest, string>(request =>
RateLimitPartition.GetTokenBucketLimiter(request.ProviderId, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 1000,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));The per-user frequency cap is a separate, product-level concern from provider throttling — it exists to protect the recipient's experience, not your infrastructure, and belongs alongside the preference checks from an earlier question rather than in the same limiter as provider protection, since the two have entirely different failure responses: a provider limit hit should queue and retry, while a user frequency cap hit should usually drop or defer to a digest.
What interviewers look for: the distinction between protecting the provider relationship and protecting the recipient's experience as two separate limiters with different behavior on rejection.
Q7 How do you handle retries and provider failover when an SMS or push provider is degraded or down?#
Short answer: Wrap every provider call in a resilience pipeline with retry-with-backoff and a circuit breaker, and configure a secondary provider so that when the circuit for the primary opens, traffic automatically routes to the fallback instead of queuing indefinitely behind a provider that isn't recovering.
Polly's v8 pipeline API (Microsoft.Extensions.Http.Resilience's AddStandardResilienceHandler for HTTP-based provider SDKs, or a plain ResiliencePipelineBuilder for non-HTTP SDKs) gives you retry, circuit breaking and timeout composed in one place rather than hand-rolled loops scattered across provider adapters, which matters because every adapter needs the same policy applied consistently. A circuit breaker specifically prevents the failure mode where a degraded provider's elevated latency backs up your worker pool — with the breaker open, calls fail fast instead of hanging, which frees workers to route the message elsewhere instead of holding a slot waiting on a provider that's unlikely to respond in time.
var pipeline = new ResiliencePipelineBuilder<SendResult>()
.AddRetry(new RetryStrategyOptions<SendResult> { MaxRetryAttempts = 3, UseJitter = true })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<SendResult> { FailureRatio = 0.5 })
.AddFallback(new FallbackStrategyOptions<SendResult>
{
FallbackAction = _ => Outcome.FromResultAsValueTask(SendViaSecondaryProvider())
})
.Build();Design the fallback deliberately rather than as an afterthought: a secondary SMS provider might have different pricing, deliverability characteristics or a different set of supported countries, so failover should be scoped (per region, per message type) rather than a blind "try provider B for everything," and every failover event should be logged with enough context to drive a post-incident review of the primary provider's reliability.
What interviewers look for: circuit breaking specifically as the mechanism that prevents a degraded provider from backing up the whole pipeline, plus a scoped, deliberate fallback strategy rather than an unconditional one.
Common mistakes: retrying indefinitely against a provider that's clearly down instead of tripping a circuit breaker and failing over, which wastes worker capacity and delays every message behind it.
Q8 Design delivery tracking — how do you know whether an email was delivered, opened, bounced, or an SMS failed?#
Short answer: Providers report delivery outcomes asynchronously through webhooks (delivered, bounced, opened, clicked for email; delivered, failed, undelivered for SMS), so maintain a per-notification status record with a small state machine, update it idempotently as callbacks arrive, and correlate every callback back to the original send via an ID you generated and passed to the provider at send time.
The correlation ID is what makes this tractable: generate it yourself before calling the provider, include it as the provider's client reference or metadata field, and every subsequent webhook the provider sends back references it, letting you update the right record without depending on the provider's own identifiers as your primary key. Process webhooks idempotently — a provider can and will redeliver the same status callback — by treating a repeated "delivered" event for an already-delivered notification as a no-op rather than an error. Emit status transitions as metrics and traces (via OpenTelemetry) so delivery health per provider, per channel and per notification type is queryable in aggregate, not just discoverable one record at a time; that aggregate view is what actually triggers the failover decision from the previous question in production, rather than a human watching individual failures.
What interviewers look for: a self-generated correlation ID passed to the provider up front, rather than trying to match callbacks after the fact using the provider's own IDs, and idempotent webhook processing consistent with the rest of the system's at-least-once assumption.
Q9 How would you prevent duplicate notifications when the same business event is published twice upstream?#
Short answer: Deduplicate as close to the source as possible using an idempotency key derived from the business event (for example, orderId + notificationType), stored with a unique constraint so a second attempt to enqueue the same logical notification is rejected or merged rather than silently sent twice, combined with an outbox pattern at the producing service so the event is only published once per actual state change.
This mirrors the idempotency discipline used throughout distributed systems: an at-least-once event bus will redeliver, so the notification service's ingestion API needs to treat "have I already accepted this exact idempotency key" as its first check, before rendering or enqueueing anything. Store the key with a short, deliberate retention window (long enough to cover realistic redelivery timeframes, not forever) so the dedup table doesn't grow unbounded, and reject a duplicate at ingestion rather than at send time, since catching it earlier avoids wasted rendering and provider-call work entirely.
What interviewers look for: the idempotency key derived from business semantics (not a random ID the producer might regenerate on retry) and dedup enforced at ingestion rather than deep in the pipeline.
Common mistakes: deduplicating only at the provider-call step, which still wastes rendering and queueing work and doesn't protect against duplicate records in delivery-tracking data.
Q10 How would you scale this service for a flash-sale-style burst of millions of notifications in a short window?#
Short answer: The queue absorbs the burst so the ingestion API stays fast regardless of downstream capacity, worker pools autoscale on queue depth, and the real bottleneck at that scale is almost always the provider's own rate limit, not your infrastructure — so the design has to accept that a burst this large will take time to fully deliver, and prioritization has to guarantee that transactional traffic threaded through the same period isn't delayed by it.
Producers publish into the bulk queue as fast as they generate events, and because the queue is durable, ingestion latency stays flat even as the backlog grows into the millions — the burst becomes a worker-pool and provider-throughput problem, not an API-availability problem. Scale workers horizontally based on queue depth (a standard autoscaling signal), but recognize that past a certain worker count, adding more workers stops helping because the provider-side token bucket from the rate-limiting question is now the binding constraint; at that point the lever is negotiating a higher contracted rate with the provider or spreading the send across multiple provider accounts, not adding compute. Batching APIs, where the provider supports them, reduce per-message overhead and are worth using specifically for bulk sends. Throughout the burst, the priority queues from the earlier question are what keep a concurrent password-reset or fraud alert flowing at normal latency instead of being buried in a multi-hour bulk backlog.
What interviewers look for: identifying the provider's rate limit, not your own compute, as the eventual bottleneck, and connecting that back to why prioritized queues matter most exactly when the system is under the heaviest load.
Quick-Fire Round#
| Question | Answer |
|---|---|
| What delivery guarantee should a notification pipeline assume by default? | At-least-once with idempotent consumers ("effectively-once"). |
| Why use per-category preferences instead of one global opt-out? | Legally or operationally required categories (fraud alerts) must still reach the user. |
| Whose time zone should quiet hours be evaluated in? | The recipient's, not the server's or sender's. |
| Why does a priority field on a shared queue often fail to protect urgent messages? | Most brokers don't reorder messages already enqueued. |
| What .NET primitive powers both HTTP and non-HTTP provider rate limiting? | PartitionedRateLimiter from System.Threading.RateLimiting. |
| What does a circuit breaker prevent during a provider outage? | Worker capacity backing up behind a slow, failing provider. |
| How should a self-generated correlation ID be used with a provider? | Passed at send time so delivery-status webhooks can be matched back to it. |
| Where should notification deduplication happen? | As early as possible — at ingestion, using a business-derived idempotency key. |
| At extreme scale, what usually becomes the real bottleneck? | The provider's rate limit, not your own compute. |
| What should always be able to bypass quiet hours and frequency caps? | Urgent, safety- or fraud-related categories, explicitly marked as such. |
How to Prepare#
- Be ready to name the transactional-versus-bulk split as the first design decision, before any component diagram.
- Practice explaining why a shared queue with a priority field is usually insufficient, and what physically separate queues buy you instead.
- Know the difference between provider-side rate limiting and user-facing frequency capping as two distinct concerns with different failure behavior.
- Rehearse a circuit-breaker-plus-failover answer with a concrete, scoped fallback strategy, not an unconditional "try the other provider."
- Have a clear answer for idempotency key derivation from business semantics, not from a producer-generated ID that can change on retry.
- Practice identifying the provider rate limit as the eventual bottleneck under extreme scale, since it's the detail most candidates miss.