惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Simon Willison's Weblog
Simon Willison's Weblog
Google DeepMind News
Google DeepMind News
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
P
Proofpoint News Feed
Recent Announcements
Recent Announcements
MongoDB | Blog
MongoDB | Blog
U
Unit 42
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
G
Google Developers Blog
I
InfoQ
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
Jina AI
Jina AI
量子位
宝玉的分享
宝玉的分享
The Cloudflare Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 聂微东
Last Week in AI
Last Week in AI
WordPress大学
WordPress大学
美团技术团队
The Hacker News
The Hacker News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Tailwind CSS Blog
博客园 - 司徒正美
博客园 - 叶小钗
Hugging Face - Blog
Hugging Face - Blog
P
Palo Alto Networks Blog
博客园_首页
阮一峰的网络日志
阮一峰的网络日志
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
The GitHub Blog
The GitHub Blog
Y
Y Combinator Blog
Vercel News
Vercel News
Martin Fowler
Martin Fowler
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Forbes - Security
Forbes - Security
Attack and Defense Labs
Attack and Defense Labs
Google DeepMind News
Google DeepMind News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Microsoft Azure Blog
Microsoft Azure Blog
P
Privacy International News Feed
G
GRAHAM CLULEY
The Last Watchdog
The Last Watchdog
C
Cyber Attacks, Cyber Crime and Cyber Security
AI
AI
V2EX - 技术
V2EX - 技术

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a Multi-Channel Notification Service in .NET 8
Naimul Karim · 2026-05-09 · via DEV Community

TL;DR — This post walks through building a complete notification service in .NET 8 that delivers messages across Email, SMS, and Push channels using a message queue, Scriban template engine, per-channel rate limiting, and parallel fan-out dispatch. Full source code on GitHub at the end.


Why Build a Notification Service?

Every production application eventually needs to notify its users — a welcome email, a password reset SMS, a push alert when their order ships. The naive approach is to call SendGrid inline in your controller. That works until:

  • Your SMTP provider is slow and your API response time balloons
  • You hit a Twilio rate limit and lose messages silently
  • You need to add a new channel and it requires touching 12 files
  • A downstream outage takes your whole API down with it

A proper notification service decouples delivery from your application logic. Requests are queued instantly, workers deliver asynchronously, and channels are isolated — one failing provider never blocks another.


Architecture Overview

Producers (API / Scheduled Jobs / Webhooks)
                    │
                    ▼
         ┌─────────────────┐
         │  Message Queue  │   RabbitMQ (topic exchange)
         └────────┬────────┘
                  │
    ┌─────────────▼──────────────────┐
    │          Dispatcher            │
    │  ┌──────────────────────────┐  │
    │  │    Template Service      │  │   Scriban engine + cache
    │  └──────────────────────────┘  │
    │  ┌──────────────────────────┐  │
    │  │      Rate Limiter        │  │   Sliding window per recipient
    │  └──────────────────────────┘  │
    └──────┬──────────┬──────────┬───┘
           │          │          │
    ┌──────▼──┐  ┌────▼───┐  ┌──▼──────┐
    │  Email  │  │  SMS   │  │  Push   │   Channel Workers
    │ Worker  │  │ Worker │  │ Worker  │   (IHostedService)
    └──────┬──┘  └────┬───┘  └──┬──────┘
           │          │          │
    ┌──────▼──┐  ┌────▼───┐  ┌──▼──────┐
    │  SMTP   │  │Twilio  │  │Firebase │   External Providers
    │ MailKit │  │  API   │  │  FCM    │
    └─────────┘  └────────┘  └─────────┘
                    │
         ┌──────────▼──────────┐
         │    Observability    │   OpenTelemetry + Serilog + Prometheus
         └─────────────────────┘

Enter fullscreen mode Exit fullscreen mode

Data flow in plain English

  1. Your API receives POST /api/notifications and immediately publishes to RabbitMQ — the HTTP response is instant
  2. RabbitMqConsumer (a BackgroundService) picks up the message
  3. The Dispatcher resolves the recipient, checks the rate limiter, renders the template, and fans out to all requested channels in parallel
  4. Each channel worker calls its external provider (MailKit, Twilio, FCM)
  5. Failures nack the message — RabbitMQ retries up to 3 times, then routes to a dead-letter queue

Project Structure

NotificationService/
├── src/
│   ├── NotificationService.Api/            # ASP.NET Core Web API
│   ├── NotificationService.Core/           # Domain models & interfaces
│   ├── NotificationService.Templates/      # Scriban template engine
│   ├── NotificationService.Dispatcher/     # Rate limiter + orchestration
│   ├── NotificationService.Channels/       # Email / SMS / Push workers
│   └── NotificationService.Infrastructure/ # RabbitMQ, persistence, DI
├── tests/
│   ├── NotificationService.UnitTests/
│   └── NotificationService.IntegrationTests/
├── docker-compose.yml
└── NotificationService.sln

Enter fullscreen mode Exit fullscreen mode

The key design decision: every layer depends only on Core interfaces. Swapping RabbitMQ for Azure Service Bus, or Twilio for Vonage, touches exactly one file.


Part 1 — Core Domain Models

Everything starts with the models. Keeping them in a separate project enforces that no business logic leaks into infrastructure.

// Core/Models/NotificationModels.cs

public record NotificationRequest
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public required string RecipientId { get; init; }
    public required string[] Channels { get; init; }      // "email" | "sms" | "push"
    public required string TemplateName { get; init; }
    public Dictionary<string, object> Data { get; init; } = new();
    public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}

public record RenderedNotification
{
    public required string Recipient { get; init; }
    public required string Subject { get; init; }
    public required string Body { get; init; }
    public string? HtmlBody { get; init; }
    public Dictionary<string, string> Metadata { get; init; } = new();
}

public record DeliveryResult
{
    public bool IsSuccess { get; init; }
    public DeliveryStatus Status { get; init; }
    public string? MessageId { get; init; }
    public string? ErrorMessage { get; init; }

    public static DeliveryResult Success(string? messageId = null) => new()
        { IsSuccess = true, Status = DeliveryStatus.Delivered, MessageId = messageId };

    public static DeliveryResult Failure(string error) => new()
        { IsSuccess = false, Status = DeliveryStatus.Failed, ErrorMessage = error };
}

Enter fullscreen mode Exit fullscreen mode

The core interfaces define the contracts every implementation must honour:

// Core/Interfaces/INotificationInterfaces.cs

public interface INotificationChannel
{
    string ChannelName { get; }
    Task<DeliveryResult> SendAsync(RenderedNotification notification, CancellationToken ct = default);
}

public interface ITemplateService
{
    Task<RenderedNotification> RenderAsync(
        string templateName,
        Dictionary<string, object> data,
        string channel,
        string recipient);
}

public interface IDispatcher
{
    Task DispatchAsync(NotificationRequest request, CancellationToken ct = default);
}

public interface IRateLimiter
{
    bool TryAcquire(string recipientId, string channel);
    Task<bool> TryAcquireAsync(string recipientId, string channel, CancellationToken ct = default);
}

Enter fullscreen mode Exit fullscreen mode


Part 2 — Message Queue with RabbitMQ

Publisher

The publisher converts a NotificationRequest to bytes and publishes to a topic exchange. Priority is encoded as the AMQP message priority so high-priority messages jump the queue.

// Infrastructure/Queue/RabbitMqPublisher.cs

public class RabbitMqPublisher : INotificationPublisher, IDisposable
{
    public Task PublishAsync(NotificationRequest request, CancellationToken ct = default)
    {
        EnsureConnected();

        var body  = JsonSerializer.SerializeToUtf8Bytes(request);
        var props = _channel!.CreateBasicProperties();
        props.Persistent = true;
        props.Priority   = (byte)request.Priority;
        props.MessageId  = request.Id.ToString();

        var routingKey = $"notification.{request.Priority.ToString().ToLower()}";

        _channel.BasicPublish(
            exchange:        _opts.ExchangeName,
            routingKey:      routingKey,
            basicProperties: props,
            body:            body);

        return Task.CompletedTask;
    }
}

Enter fullscreen mode Exit fullscreen mode

Consumer (BackgroundService)

The consumer runs as a hosted service. Failed messages are nacked — RabbitMQ routes them to the dead-letter queue after max retries.

// Infrastructure/Queue/RabbitMqConsumer.cs

public class RabbitMqConsumer : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Connect();

        var consumer = new AsyncEventingBasicConsumer(_channel!);

        consumer.Received += async (_, ea) =>
        {
            try
            {
                var request = JsonSerializer.Deserialize<NotificationRequest>(ea.Body.Span)!;

                using var scope  = _scopeFactory.CreateScope();
                var dispatcher   = scope.ServiceProvider.GetRequiredService<IDispatcher>();

                await dispatcher.DispatchAsync(request, stoppingToken);
                _channel!.BasicAck(ea.DeliveryTag, multiple: false);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing notification");
                _channel!.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
            }
        };

        _channel!.BasicConsume(_opts.QueueName, autoAck: false, consumer);
        return Task.CompletedTask;
    }
}

Enter fullscreen mode Exit fullscreen mode

Queue topology

Exchange: notifications (topic)
    │
    ├── notification.critical ──► Queue: notifications.main
    ├── notification.high     ──► Queue: notifications.main
    ├── notification.normal   ──► Queue: notifications.main
    └── notification.low      ──► Queue: notifications.main
                                           │
                                     (nack, no requeue)
                                           │
                                           ▼
                                  Queue: notifications.dlq

Enter fullscreen mode Exit fullscreen mode


Part 3 — Template Service (Scriban)

Scriban is a fast, sandboxed templating engine for .NET. Templates are stored per (name, channel, part) — so welcome:email:body and welcome:sms:body can have completely different content. Compiled templates are cached in a ConcurrentDictionary — parsing happens once per unique key, never again.

// Templates/Services/ScribanTemplateService.cs

public class ScribanTemplateService(
    ITemplateRepository repository,
    ILogger<ScribanTemplateService> logger) : ITemplateService
{
    private static readonly ConcurrentDictionary<string, Template> _cache = new();

    public async Task<RenderedNotification> RenderAsync(
        string templateName, Dictionary<string, object> data,
        string channel, string recipient)
    {
        var subject = await RenderPartAsync(templateName, channel, "subject", data);
        var body    = await RenderPartAsync(templateName, channel, "body",    data);

        return new RenderedNotification
        {
            Recipient = recipient,
            Subject   = subject,
            Body      = body
        };
    }

    private async Task<string> RenderPartAsync(
        string name, string channel, string part,
        Dictionary<string, object> data)
    {
        var key = $"{name}:{channel}:{part}";

        var compiled = _cache.GetOrAdd(key, _ =>
        {
            var source = repository.GetTemplateAsync(name, channel, part)
                .GetAwaiter().GetResult()
                ?? throw new InvalidOperationException($"Template not found: {key}");
            return Template.Parse(source);
        });

        var ctx   = new TemplateContext { StrictVariables = false };
        var model = new ScriptObject();
        foreach (var (k, v) in data) model.Add(k, v);
        ctx.PushGlobal(model);

        return await compiled.RenderAsync(ctx);
    }
}

Enter fullscreen mode Exit fullscreen mode

Seeded templates (ready out of the box)

Template Channel What it sends
welcome email Full welcome with verification link
welcome sms Short text with verification link
welcome push Tap-to-verify push notification
password-reset email / sms / push Reset link + expiry notice
order-confirmation all Order ID, total, delivery date, tracking link

Part 4 — Rate Limiter (Sliding Window)

The rate limiter sits inside the Dispatcher — not at the API layer — so limits apply regardless of how notifications enter the system.

// Dispatcher/RateLimiting/SlidingWindowRateLimiter.cs

public class SlidingWindowRateLimiter(
    IMemoryCache cache,
    IOptions<RateLimitOptions> options) : IRateLimiter
{
    public bool TryAcquire(string recipientId, string channel)
    {
        var key    = $"rl:{channel}:{recipientId}";
        var window = cache.GetOrCreate(key, entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_opts.WindowSeconds);
            return new WindowCounter(_opts.MaxPerWindow);
        })!;

        return window.TryConsume();
    }

    private sealed class WindowCounter(int max)
    {
        private int _count;
        public bool TryConsume() => Interlocked.Increment(ref _count) <= max;
    }
}

Enter fullscreen mode Exit fullscreen mode

Each (recipientId, channel) pair has its own independent window. Configure via appsettings.json:

"RateLimit": {
  "MaxPerWindow": 10,
  "WindowSeconds": 3600
}

Enter fullscreen mode Exit fullscreen mode

For distributed deployments: replace IMemoryCache with a Redis-backed implementation using INCR + EXPIRE per key.


Part 5 — Dispatcher (Orchestration)

The Dispatcher wires together template rendering, rate limiting, and channel delivery — all in parallel.

// Dispatcher/Services/NotificationDispatcher.cs

public class NotificationDispatcher(
    ITemplateService templateService,
    IEnumerable<INotificationChannel> channels,
    IRateLimiter rateLimiter,
    IRecipientResolver recipientResolver,
    ILogger<NotificationDispatcher> logger) : IDispatcher
{
    public async Task DispatchAsync(NotificationRequest request, CancellationToken ct = default)
    {
        var recipient = await recipientResolver.ResolveAsync(request.RecipientId, ct);
        if (recipient is null)
        {
            logger.LogWarning("Recipient {Id} not found", request.RecipientId);
            return;
        }

        var targets = channels
            .Where(c => request.Channels.Contains(c.ChannelName, StringComparer.OrdinalIgnoreCase))
            .ToList();

        // Fan-out to all channels IN PARALLEL
        var tasks = targets.Select(ch => SendToChannelAsync(ch, request, recipient, ct));
        await Task.WhenAll(tasks);
    }

    private async Task SendToChannelAsync(
        INotificationChannel channel, NotificationRequest request,
        RecipientInfo recipient, CancellationToken ct)
    {
        if (!await rateLimiter.TryAcquireAsync(request.RecipientId, channel.ChannelName, ct))
        {
            logger.LogWarning("Rate limited: {Recipient} on {Channel}",
                request.RecipientId, channel.ChannelName);
            return;
        }

        var address = channel.ChannelName.ToLower() switch
        {
            "email" => recipient.Email,
            "sms"   => recipient.PhoneNumber,
            "push"  => recipient.DevicePushToken,
            _       => null
        };

        if (address is null) return;

        var rendered = await templateService.RenderAsync(
            request.TemplateName, request.Data, channel.ChannelName, address);

        var result = await channel.SendAsync(rendered, ct);

        if (result.IsSuccess)
            logger.LogInformation("✓ {Channel} → {Recipient}", channel.ChannelName, request.RecipientId);
        else
            logger.LogError("✗ {Channel} failed → {Error}", channel.ChannelName, result.ErrorMessage);
    }
}

Enter fullscreen mode Exit fullscreen mode

Task.WhenAll means an ["email", "sms", "push"] request sends all three concurrently. One channel throwing never blocks the others.


Part 6 — Channel Workers

Email — MailKit

public class EmailChannel(IOptions<SmtpOptions> opts, ILogger<EmailChannel> logger) : INotificationChannel
{
    public string ChannelName => "email";

    public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
    {
        try
        {
            var message = new MimeMessage();
            message.From.Add(new MailboxAddress(opts.Value.FromName, opts.Value.FromAddress));
            message.To.Add(MailboxAddress.Parse(n.Recipient));
            message.Subject = n.Subject;

            var builder = new BodyBuilder();
            if (n.HtmlBody is not null) { builder.HtmlBody = n.HtmlBody; builder.TextBody = n.Body; }
            else builder.TextBody = n.Body;
            message.Body = builder.ToMessageBody();

            using var client = new SmtpClient();
            await client.ConnectAsync(opts.Value.Host, opts.Value.Port,
                SecureSocketOptions.StartTlsWhenAvailable, ct);
            await client.AuthenticateAsync(opts.Value.User, opts.Value.Password, ct);
            await client.SendAsync(message, ct);
            await client.DisconnectAsync(quit: true, ct);

            return DeliveryResult.Success(message.MessageId);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Email failed to {Recipient}", n.Recipient);
            return DeliveryResult.Failure(ex.Message);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

SMS — Twilio

public class SmsChannel(IOptions<TwilioOptions> opts, ILogger<SmsChannel> logger) : INotificationChannel
{
    public string ChannelName => "sms";

    public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
    {
        EnsureInitialized();

        try
        {
            var message = await MessageResource.CreateAsync(
                to:   new PhoneNumber(n.Recipient),
                from: new PhoneNumber(opts.Value.FromNumber),
                body: n.Body);

            return message.ErrorCode is null
                ? DeliveryResult.Success(message.Sid)
                : DeliveryResult.Failure($"[{message.ErrorCode}] {message.ErrorMessage}");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "SMS failed to {Recipient}", n.Recipient);
            return DeliveryResult.Failure(ex.Message);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Push — Firebase FCM

public class PushChannel(IOptions<FirebaseOptions> opts, ILogger<PushChannel> logger) : INotificationChannel
{
    public string ChannelName => "push";

    public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
    {
        await EnsureInitializedAsync();

        try
        {
            var message = new Message
            {
                Token        = n.Recipient,
                Notification = new FirebaseAdmin.Messaging.Notification
                    { Title = n.Subject, Body = n.Body },
                Android = new AndroidConfig { Priority = Priority.High },
                Apns    = new ApnsConfig { Aps = new Aps { Sound = "default" } },
                Data    = n.Metadata
            };

            var messageId = await FirebaseMessaging.GetMessaging(_app!).SendAsync(message, ct);
            return DeliveryResult.Success(messageId);
        }
        catch (FirebaseMessagingException ex)
            when (ex.MessagingErrorCode == MessagingErrorCode.Unregistered)
        {
            return DeliveryResult.Failure("FCM token is no longer valid");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Push failed to {Recipient}", n.Recipient);
            return DeliveryResult.Failure(ex.Message);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode


Part 7 — API Layer

The controller is intentionally thin — validate, publish, return 202 Accepted. No business logic.

[ApiController]
[Route("api/[controller]")]
public class NotificationsController(
    INotificationPublisher publisher,
    ILogger<NotificationsController> logger) : ControllerBase
{
    [HttpPost]
    [ProducesResponseType(typeof(NotificationResponse), StatusCodes.Status202Accepted)]
    public async Task<IActionResult> Send(
        [FromBody] SendNotificationRequest request, CancellationToken ct)
    {
        if (request.Channels.Length == 0)
            return BadRequest(new { error = "At least one channel is required" });

        var notification = new NotificationRequest
        {
            RecipientId  = request.RecipientId,
            Channels     = request.Channels,
            TemplateName = request.TemplateName,
            Data         = request.Data,
            Priority     = request.Priority
        };

        await publisher.PublishAsync(notification, ct);
        return Accepted(new NotificationResponse(notification.Id, "Queued", DateTimeOffset.UtcNow));
    }
}

Enter fullscreen mode Exit fullscreen mode

Example request

POST /api/notifications
Content-Type: application/json

{
  "recipientId": "user-123",
  "channels": ["email", "sms", "push"],
  "templateName": "welcome",
  "data": {
    "firstName": "Alice",
    "verificationLink": "https://app.example.com/verify?token=abc123"
  },
  "priority": "High"
}

Enter fullscreen mode Exit fullscreen mode

Response

{
  "notificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "Queued",
  "queuedAt": "2024-01-15T10:30:00Z"
}

Enter fullscreen mode Exit fullscreen mode


Part 8 — DI Wiring (Program.cs)

builder.Services.AddSingleton<ITemplateRepository, InMemoryTemplateRepository>();
builder.Services.AddScoped<ITemplateService, ScribanTemplateService>();

builder.Services.AddMemoryCache();
builder.Services.Configure<RateLimitOptions>(builder.Configuration.GetSection("RateLimit"));
builder.Services.AddSingleton<IRateLimiter, SlidingWindowRateLimiter>();

builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<TwilioOptions>(builder.Configuration.GetSection("Twilio"));
builder.Services.Configure<FirebaseOptions>(builder.Configuration.GetSection("Firebase"));
builder.Services.AddScoped<INotificationChannel, EmailChannel>();
builder.Services.AddScoped<INotificationChannel, SmsChannel>();
builder.Services.AddScoped<INotificationChannel, PushChannel>();

builder.Services.AddScoped<IDispatcher, NotificationDispatcher>();

builder.Services.AddRabbitMq(builder.Configuration);
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddAspNetCoreInstrumentation().AddConsoleExporter());

builder.Services.AddHealthChecks()
    .AddRabbitMQ(rabbitConnectionString: "amqp://guest:guest@localhost/");

app.MapHealthChecks("/health");

Enter fullscreen mode Exit fullscreen mode


Part 9 — Unit Tests

[Fact]
public void TryAcquire_AtLimit_ReturnsFalse()
{
    var limiter = CreateLimiter(max: 2);
    limiter.TryAcquire("user-1", "sms").Should().BeTrue();
    limiter.TryAcquire("user-1", "sms").Should().BeTrue();
    limiter.TryAcquire("user-1", "sms").Should().BeFalse(); // blocked
}

[Fact]
public void TryAcquire_DifferentChannels_TrackSeparately()
{
    var limiter = CreateLimiter(max: 1);
    limiter.TryAcquire("user-2", "email").Should().BeTrue();
    limiter.TryAcquire("user-2", "email").Should().BeFalse();
    limiter.TryAcquire("user-2", "sms").Should().BeTrue();  // sms is independent
}

[Fact]
public async Task DispatchAsync_WhenRateLimited_SkipsChannel()
{
    _rateLimiter.Setup(r => r.TryAcquireAsync("u1", "email", default))
        .ReturnsAsync(false);

    await dispatcher.DispatchAsync(request);

    _emailChannel.Verify(
        c => c.SendAsync(It.IsAny<RenderedNotification>(), default), Times.Never);
}

[Fact]
public async Task RenderAsync_InterpolatesVariables()
{
    var service = CreateService("Hello, {{ name }}!");
    var result  = await service.RenderAsync("tpl", new() { ["name"] = "Alice" }, "email", "a@b.com");
    result.Body.Should().Be("Hello, Alice!");
}

Enter fullscreen mode Exit fullscreen mode

Run the full suite:

dotnet test

Enter fullscreen mode Exit fullscreen mode


Part 10 — Docker Compose

services:
  api:
    build:
      context: .
      dockerfile: src/NotificationService.Api/Dockerfile
    ports:
      - "5000:8080"
    environment:
      - RabbitMq__Host=rabbitmq
    depends_on:
      rabbitmq:
        condition: service_healthy

  rabbitmq:
    image: rabbitmq:3.13-management-alpine
    ports:
      - "5672:5672"
      - "15672:15672"
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 10s
      retries: 5

  redis:
    image: redis:7.4-alpine
    ports:
      - "6379:6379"

Enter fullscreen mode Exit fullscreen mode

Start everything:

docker-compose up -d

Enter fullscreen mode Exit fullscreen mode

API → http://localhost:5000 · Swagger → http://localhost:5000/swagger · RabbitMQ UI → http://localhost:15672


Part 11 — GitHub Actions CI

name: CI
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    services:
      rabbitmq:
        image: rabbitmq:3.13-alpine
        ports: [ 5672:5672 ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x
      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test tests/NotificationService.UnitTests -c Release --logger trx

  docker:
    runs-on: ubuntu-latest
    needs: build-and-test
    steps:
      - uses: actions/checkout@v4
      - run: docker build -f src/NotificationService.Api/Dockerfile -t notification-service:${{ github.sha }} .

Enter fullscreen mode Exit fullscreen mode


Extending the Service

Add a new channel (e.g. Slack) — 3 steps

Step 1 — Implement INotificationChannel:

public class SlackChannel(IOptions<SlackOptions> opts) : INotificationChannel
{
    public string ChannelName => "slack";

    public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct)
    {
        var payload  = new { text = $"*{n.Subject}*\n{n.Body}" };
        var response = await _httpClient.PostAsJsonAsync(opts.Value.WebhookUrl, payload, ct);

        return response.IsSuccessStatusCode
            ? DeliveryResult.Success()
            : DeliveryResult.Failure(response.ReasonPhrase!);
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 2 — Register it:

builder.Services.AddScoped<INotificationChannel, SlackChannel>();

Enter fullscreen mode Exit fullscreen mode

Step 3 — Add templates for slack:subject and slack:body. The Dispatcher discovers it automatically. Zero changes to existing code.

Distributed rate limiting (Redis)

public async Task<bool> TryAcquireAsync(string recipientId, string channel, CancellationToken ct)
{
    var key   = $"rl:{channel}:{recipientId}";
    var count = await _db.StringIncrementAsync(key);
    if (count == 1)
        await _db.KeyExpireAsync(key, TimeSpan.FromSeconds(_opts.WindowSeconds));
    return count <= _opts.MaxPerWindow;
}

Enter fullscreen mode Exit fullscreen mode


Key Design Decisions

Message queue over direct delivery — The API never waits on SMTP, Twilio, or FCM. Response time is constant regardless of provider latency. Dead-letter queuing means no silent message loss during outages.

Scriban over Razor — Scriban compiles to a sandboxed AST. It cannot execute arbitrary C# code — important when templates might be user-editable. It also benchmarks significantly faster for pure string interpolation at scale.

Rate limiting in the Dispatcher, not the API — Notifications can enter from multiple sources. Placing rate limiting in the Dispatcher guarantees it applies everywhere, not just the HTTP path.

Task.WhenAll fan-out — One slow provider doesn't delay others. Email taking 800ms doesn't block push from delivering in 50ms. Failures are isolated — one channel exception never affects the others.

IEnumerable<INotificationChannel> in the Dispatcher — ASP.NET Core's DI resolves all registered implementations automatically. Adding a channel is one AddScoped call with zero changes to the Dispatcher.


What's Next

  • Delivery receipts — webhook callbacks from Twilio/FCM to track delivery status in your DB
  • User preferences — let recipients opt out of channels or specific notification types
  • Scheduled notifications — Hangfire or Quartz.NET for delayed delivery
  • Template management API — CRUD endpoints to edit templates without redeploying
  • Redis rate limiter — for multi-instance deployments
  • End-to-end OpenTelemetry traces — trace a notification from API call through queue through dispatcher through provider

Source Code

The complete, production-ready source code is on GitHub — includes all the code shown in this post, Docker Compose setup, GitHub Actions CI pipeline, full test suite (xUnit + Moq + FluentAssertions), and architecture documentation.

🔗 github.com/naimulkarim/NotificationService

If this was helpful, drop a ⭐ on the repo and feel free to open issues or PRs for improvements.


Built with .NET 8 · RabbitMQ · MailKit · Twilio · Firebase FCM · Scriban · OpenTelemetry