"Design a shared platform that any team can call to send email, SMS, push and in-app messages, respecting user preferences." Unlike the 20 payment-alert example (one event source, one use case), this is the generic engine behind many products.
.NET 10 The .NET stack for this design
| Component | Library / API | Notes |
| API | built-in minimal APIs, AddValidation(), EF Core 10 (unique index on idempotency key) | Return 202 and enqueue |
| Queues | Azure.Messaging.ServiceBus (one queue per channel × priority; scheduled messages for quiet hours; built-in DLQ) | Workers as BackgroundService, scaled by KEDA on Container Apps / AKS |
| Provider limits | built-in System.Threading.RateLimiting TokenBucketRateLimiter | One limiter per provider account |
| Retries / failover | Microsoft.Extensions.Http.Resilience / Polly.Core | Backoff + jitter, circuit breaker per provider |
| Channels | Azure.Communication.Email, Azure.Communication.Sms, Microsoft.Azure.NotificationHubs, SignalR for in-app | One sender per channel behind an interface |
| Templates / prefs | Scriban or Fluid.Core (Liquid templates); HybridCache for prefs | Templates stored and versioned in the DB |
// One limiter per SMS provider account: 100 msg/s, short queue for bursts
var smsQuota = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 100, TokensPerPeriod = 100, ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 1_000, QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
// Separate processors for "sms-high" and "sms-low"; high gets more MaxConcurrentCalls
processor.ProcessMessageAsync += async args =>
{
var job = args.Message.Body.ToObjectFromJson<SmsJob>()!;
using RateLimitLease lease = await smsQuota.AcquireAsync(1, args.CancellationToken);
if (!lease.IsAcquired) { await args.AbandonMessageAsync(args.Message); return; } // try again later
var sent = await sms.SendAsync(job.From, job.To, job.Text, cancellationToken: args.CancellationToken);
await tracking.MarkSentAsync(job.NotificationId, "sms", sent.Value.MessageId);
await args.CompleteMessageAsync(args.Message);
};
// An exception means the message is redelivered; after MaxDeliveryCount it moves to the DLQ
Set AutoCompleteMessages = false on the processor when you complete messages yourself. A limiter inside one worker only limits that worker: with 5 replicas, give each 1/5 of the provider rate or use a shared Redis bucket (21.2).
Why did my OTP arrive 40 minutes late? Because it was stuck behind a marketing campaign. 📵
A good notification platform avoids that:
🚦 Separate queues per channel AND per priority
🪣 Rate-limit yourself to each provider's limit (they will throttle you anyway)
🔁 Retry only transient errors, with backoff, then dead-letter
🧾 Dedupe on (notification, channel) so nobody gets the SMS twice
⌛ Give OTPs a short TTL: late is the same as never
One platform, many teams, zero "sorry for the spam" emails.
#SystemDesign #Messaging #Notifications #Architecture #DotNet