AI News Hub Logo

AI News Hub

Building a Multi-Channel Notification Service in .NET 8

DEV Community
Naimul Karim

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. 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. 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 └─────────────────────┘ Your API receives POST /api/notifications and immediately publishes to RabbitMQ — the HTTP response is instant RabbitMqConsumer (a BackgroundService) picks up the message The Dispatcher resolves the recipient, checks the rate limiter, renders the template, and fans out to all requested channels in parallel Each channel worker calls its external provider (MailKit, Twilio, FCM) Failures nack the message — RabbitMQ retries up to 3 times, then routes to a dead-letter queue 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 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. 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 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 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 }; } The core interfaces define the contracts every implementation must honour: // Core/Interfaces/INotificationInterfaces.cs public interface INotificationChannel { string ChannelName { get; } Task SendAsync(RenderedNotification notification, CancellationToken ct = default); } public interface ITemplateService { Task RenderAsync( string templateName, Dictionary 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 TryAcquireAsync(string recipientId, string channel, CancellationToken ct = default); } 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; } } 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(ea.Body.Span)!; using var scope = _scopeFactory.CreateScope(); var dispatcher = scope.ServiceProvider.GetRequiredService(); 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; } } 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 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 logger) : ITemplateService { private static readonly ConcurrentDictionary _cache = new(); public async Task RenderAsync( string templateName, Dictionary 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 RenderPartAsync( string name, string channel, string part, Dictionary 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); } } 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 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 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) channels, IRateLimiter rateLimiter, IRecipientResolver recipientResolver, ILogger 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); } } Task.WhenAll means an ["email", "sms", "push"] request sends all three concurrently. One channel throwing never blocks the others. public class EmailChannel(IOptions opts, ILogger logger) : INotificationChannel { public string ChannelName => "email"; public async Task 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); } } } public class SmsChannel(IOptions opts, ILogger logger) : INotificationChannel { public string ChannelName => "sms"; public async Task 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); } } } public class PushChannel(IOptions opts, ILogger logger) : INotificationChannel { public string ChannelName => "push"; public async Task 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); } } } The controller is intentionally thin — validate, publish, return 202 Accepted. No business logic. [ApiController] [Route("api/[controller]")] public class NotificationsController( INotificationPublisher publisher, ILogger logger) : ControllerBase { [HttpPost] [ProducesResponseType(typeof(NotificationResponse), StatusCodes.Status202Accepted)] public async Task 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)); } } 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" } { "notificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "Queued", "queuedAt": "2024-01-15T10:30:00Z" } builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddMemoryCache(); builder.Services.Configure(builder.Configuration.GetSection("RateLimit")); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Twilio")); builder.Services.Configure(builder.Configuration.GetSection("Firebase")); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); 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"); [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(), default), Times.Never); } [Fact] public async Task RenderAsync_InterpolatesVariables() { var service = CreateService("Hello, {{ name }}!"); var result = await service.RenderAsync("tpl", new() { ["name"] = "Alice" }, "email", "[email protected]"); result.Body.Should().Be("Hello, Alice!"); } Run the full suite: dotnet test 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" Start everything: docker-compose up -d API → http://localhost:5000 · Swagger → http://localhost:5000/swagger · RabbitMQ UI → http://localhost:15672 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 }} . Step 1 — Implement INotificationChannel: public class SlackChannel(IOptions opts) : INotificationChannel { public string ChannelName => "slack"; public async Task 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!); } } Step 2 — Register it: builder.Services.AddScoped(); Step 3 — Add templates for slack:subject and slack:body. The Dispatcher discovers it automatically. Zero changes to existing code. public async Task 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 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. 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 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