Common Mistakes
1. Using SendAsync in Event Handlers
Section titled “1. Using SendAsync in Event Handlers”// WRONG: blocks event processing while waiting for SMTP delivery[EventHandler]public sealed partial class Handler(INotificationService svc) : IDomainEventHandler<ReservationConfirmed>{ public async Task HandleAsync(ReservationConfirmed e, CancellationToken ct) { await svc.SendAsync(new NotificationRequest { ... }, ct); // blocks }}// CORRECT: enqueue for background deliverypublic async Task HandleAsync(ReservationConfirmed e, CancellationToken ct){ await svc.EnqueueAsync(new NotificationRequest { ... }, ct); // returns immediately}SendAsync waits for the SMTP server response. In event handlers (which may run inside a database transaction or block other handlers), use EnqueueAsync to offload delivery to the background worker.
2. Using EnqueueAsync in Jobs
Section titled “2. Using EnqueueAsync in Jobs”// WRONG: double-queuing — the job runner already handles retry/background[Job]public sealed partial class ReminderJob(INotificationService svc) : IJob<ReminderParams>{ public async Task ExecuteAsync(ReminderParams p, JobContext ctx, CancellationToken ct) { await svc.EnqueueAsync(new NotificationRequest { ... }, ct); }}// CORRECT: use SendAsync — the job runner provides retry and schedulingpublic async Task ExecuteAsync(ReminderParams p, JobContext ctx, CancellationToken ct){ await svc.SendAsync(new NotificationRequest { ... }, ct);}Jobs already execute in the background with retry policies ([Retry]). Using EnqueueAsync means the job completes instantly without knowing if delivery succeeded, defeating the retry mechanism.
3. Forgetting to Register a Channel
Section titled “3. Forgetting to Register a Channel”// WRONG: no channel registered — all notifications silently skippedapp.UseNotifications(n =>{ // No AddSmtp(), AddWebhook(), or AddChannel<T>()});If no INotificationChannel is registered, DefaultChannelFactory.GetRegisteredChannels() returns an empty list, and every notification routes to NotificationChannel.None. Check logs for the warning: “Channel {Channel} is not registered — skipping”.
4. Empty Subject or Body
Section titled “4. Empty Subject or Body”// WRONG: validation rejects empty subjectvar request = new NotificationRequest{ Audience = NotificationAudience.EndUser, Recipient = NotificationRecipient.Direct("guest@hotel.com"), Content = new NotificationContent { Subject = "", // validation fails Body = "Your reservation is confirmed.", },};var result = await svc.SendAsync(request);// result.Success == false, result.Errors == ["Notification subject is required."]The pipeline validates that both Subject and Body are non-empty before any processing. Always provide both.
5. Not Checking NotificationResult
Section titled “5. Not Checking NotificationResult”// WRONG: ignoring the result — silent failuresawait notificationService.SendAsync(request);// CORRECT: check the resultvar result = await notificationService.SendAsync(request);if (!result.Success){ logger.LogWarning("Notification failed: {Errors}", string.Join(", ", result.Errors ?? []));}SendAsync returns NotificationResult with Success, Errors, and DeliveryIds. Even partial failures (some channels succeed, some fail) set Success = true with errors attached. Fully failed deliveries set Success = false.
6. Using InMemoryNotificationStore in Production
Section titled “6. Using InMemoryNotificationStore in Production”// WRONG: notifications are lost on restart, no cross-instance visibilityapp.UseNotifications(n =>{ n.AddSmtp(sender => { ... }, transport => { ... }); // InMemoryNotificationStore is the default});// CORRECT: use EF Core store for durable trackingapp.UseNotifications(n =>{ n.AddSmtp(sender => { ... }, transport => { ... }); n.UseEfCoreStore(db => db.UseNpgsql(connectionString));});The InMemoryNotificationStore is suitable for development only. It loses all data on restart and is not shared across instances. In production, use UseEfCoreStore() for the __Notifications table.
7. Hardcoding Recipient Addresses Instead of Using Audiences
Section titled “7. Hardcoding Recipient Addresses Instead of Using Audiences”// WRONG: hardcoded email, no preference support, no user resolutionvar request = new NotificationRequest{ Audience = NotificationAudience.System, // misleading audience Recipient = NotificationRecipient.Direct("admin@hotel.com"), Content = new NotificationContent { Subject = "Alert", Body = "Overbooking" },};// CORRECT: use role-based targetingvar request = new NotificationRequest{ Audience = NotificationAudience.Admin, Recipient = NotificationRecipient.Role("hotel-manager"), Content = new NotificationContent { Subject = "Overbooking Alert", Body = "..." }, Priority = NotificationPriority.Critical,};Direct addresses bypass user resolution and preferences. When targeting known user groups, use NotificationRecipient.User(), Role(), or Tenant() so the pipeline can resolve addresses dynamically and apply preference filtering.
8. Not Providing HtmlBody for Email
Section titled “8. Not Providing HtmlBody for Email”// SUBOPTIMAL: email clients render plain text poorlynew NotificationContent{ Subject = "Reservation Confirmed", Body = "Your reservation from Mar 15 to Mar 20 is confirmed.", // HtmlBody = null — email will be plain text only}// BETTER: provide both Body (fallback) and HtmlBodynew NotificationContent{ Subject = "Reservation Confirmed", Body = "Your reservation from Mar 15 to Mar 20 is confirmed.", HtmlBody = """ <h2>Reservation Confirmed</h2> <p>Your reservation from <strong>Mar 15</strong> to <strong>Mar 20</strong> is confirmed.</p> """,}The SmtpChannel uses Body as the plain text part and HtmlBody as the HTML part. Most email clients prefer HTML. Always provide both for best rendering.
9. Overriding Channel Without Checking Registration
Section titled “9. Overriding Channel Without Checking Registration”// WRONG: Slack is not registered, override forces it, delivery silently skippedvar request = new NotificationRequest{ Audience = NotificationAudience.Developer, Recipient = NotificationRecipient.Direct("dev@company.com"), Content = new NotificationContent { Subject = "Build Failed", Body = "..." }, ChannelOverride = NotificationChannel.Slack, // not registered};ChannelOverride bypasses the router but not the channel factory. If the overridden channel has no registered INotificationChannel, the pipeline logs a warning and skips delivery. Only override to channels you know are registered.
10. Setting Category Without Preference Integration
Section titled “10. Setting Category Without Preference Integration”// MISLEADING: categories only work when a custom INotificationPreferenceProvider is registeredvar request = new NotificationRequest{ Category = "marketing", // ignored by DefaultPreferenceProvider // ...};The Category field is used by the router to check MutedCategories in user preferences. The DefaultPreferenceProvider returns empty preferences (no muted categories), so setting Category has no effect until you implement INotificationPreferenceProvider and users can manage their muted categories.
11. Sending High-Volume Notifications Synchronously
Section titled “11. Sending High-Volume Notifications Synchronously”// WRONG: sends 500 emails synchronously, blocking the requestforeach (var guest in guests){ await notificationService.SendAsync(new NotificationRequest { Recipient = NotificationRecipient.Direct(guest.Email), // ... });}// CORRECT: enqueue for background processingforeach (var guest in guests){ await notificationService.EnqueueAsync(new NotificationRequest { Recipient = NotificationRecipient.Direct(guest.Email), // ... });}For bulk notifications, always use EnqueueAsync. The BoundedChannel has backpressure (default capacity: 1000), so the producer waits if the channel is full rather than running out of memory.