DEV Community

Naimul Karim
Naimul Karim

Posted on

Building a Multi-Channel Notification Service in .NET 8

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

Top comments (0)