Skip to content

Getting Started

Step-by-step guide to integrating Pragmatic.Notifications into a hotel booking application, matching the Showcase project.

Terminal window
dotnet add package Pragmatic.Notifications

For production, add persistent tracking:

Terminal window
dotnet add package Pragmatic.Notifications.EFCore

For webhook delivery:

Terminal window
dotnet add package Pragmatic.Notifications.Webhook

Minimal setup with SMTP email:

app.UseNotifications(n =>
{
n.AddSmtp(
sender =>
{
sender.SenderAddress = "noreply@showcase.pragmatic.design";
sender.SenderName = "Showcase Hotel";
},
transport =>
{
transport.Host = app.Configuration["Smtp:Host"] ?? "localhost";
transport.Port = int.TryParse(app.Configuration["Smtp:Port"], out var p) ? p : 587;
transport.UseSsl = !app.Environment.IsDevelopment();
transport.MaxConnections = 3;
});
});

This registers:

  • INotificationService (core service)
  • SmtpChannel (email delivery via Pragmatic.Email)
  • NotificationPipeline (validate/resolve/route/deliver/track)
  • NotificationDeliveryWorker (background service for EnqueueAsync)
  • InMemoryNotificationStore (default tracking — swap for EF Core in production)
public sealed class BookingService(INotificationService notifications)
{
public async Task ConfirmBooking(Reservation reservation)
{
// ... save reservation ...
await notifications.SendAsync(new NotificationRequest
{
Audience = NotificationAudience.EndUser,
Recipient = NotificationRecipient.Direct(reservation.GuestEmail),
Content = new NotificationContent
{
Subject = $"Booking Confirmed - {reservation.CheckIn:D}",
Body = $"Your booking from {reservation.CheckIn:D} to {reservation.CheckOut:D} is confirmed.",
},
Category = "transactional",
});
}
}
await notifications.EnqueueAsync(new NotificationRequest
{
Audience = NotificationAudience.EndUser,
Recipient = NotificationRecipient.User(guestId.ToString()),
Content = new NotificationContent
{
Subject = "Welcome to Showcase Hotel",
Body = "Thank you for creating your account.",
},
Priority = NotificationPriority.Low,
Category = "onboarding",
});
await notifications.SendAsync(new NotificationRequest
{
Audience = NotificationAudience.Admin,
Recipient = NotificationRecipient.Role("hotel-manager"),
Content = new NotificationContent
{
Subject = "Overbooking Detected",
Body = $"Room {roomNumber} is double-booked for {date:D}.",
},
Priority = NotificationPriority.Critical,
});
await notifications.SendAsync(new NotificationRequest
{
Audience = NotificationAudience.System,
Recipient = NotificationRecipient.ToWebhook("https://hooks.example.com/bookings"),
Content = new NotificationContent
{
Subject = "Reservation Created",
Body = "New reservation",
JsonPayload = JsonSerializer.Serialize(new { reservationId, guestId, checkIn, checkOut }),
},
});

The Showcase project uses domain events to trigger notifications. When a reservation is confirmed, an event handler enqueues the email:

[EventHandler]
public sealed partial class ReservationConfirmedNotificationHandler(
INotificationService notificationService) : IDomainEventHandler<ReservationConfirmed>
{
public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct)
{
await notificationService.EnqueueAsync(new NotificationRequest
{
Audience = NotificationAudience.EndUser,
Recipient = NotificationRecipient.User(@event.GuestId.ToString()),
Content = new NotificationContent
{
Subject = $"Reservation Confirmed - {FormatDates(@event.CheckIn, @event.CheckOut)}",
Body = $"""
Your reservation has been confirmed.
Check-in: {@event.CheckIn:D}
Check-out: {@event.CheckOut:D}
Total: {@event.TotalAmount:N2} {@event.Currency}
""",
HtmlBody = $"""
<h2>Reservation Confirmed</h2>
<p>Check-in: <strong>{@event.CheckIn:D}</strong></p>
<p>Total: {@event.TotalAmount:N2} {@event.Currency}</p>
""",
},
Category = "transactional",
}, ct);
}
private static string FormatDates(DateTimeOffset checkIn, DateTimeOffset checkOut)
=> $"{checkIn:MMM dd} - {checkOut:MMM dd, yyyy}";
}

Key pattern: use EnqueueAsync (not SendAsync) in event handlers so notification delivery does not block domain event processing.

Schedule a delayed notification using Pragmatic.Jobs:

[Job]
[Retry(MaxAttempts = 3, Strategy = BackoffStrategy.ExponentialWithJitter, BaseDelayMs = 500)]
public sealed partial class SendCheckInReminderJob(
INotificationService notificationService) : IJob<CheckInReminderParams>
{
public async Task ExecuteAsync(CheckInReminderParams p, JobContext context, CancellationToken ct)
{
await notificationService.SendAsync(new NotificationRequest
{
Audience = NotificationAudience.EndUser,
Recipient = NotificationRecipient.Direct(p.GuestEmail),
Content = new NotificationContent
{
Subject = $"Check-in Reminder - {p.CheckIn:D}",
Body = "Your check-in is tomorrow. We look forward to welcoming you!",
},
Priority = NotificationPriority.High,
Category = "transactional",
}, ct);
}
}

Key pattern: use SendAsync (not EnqueueAsync) in jobs because the job runner already provides retry and background execution.

app.UseNotifications(n =>
{
n.AddSmtp(sender => { ... }, transport => { ... });
n.UseEfCoreStore(db => db.UseNpgsql(
app.Configuration.GetConnectionString("Notifications")));
});

This stores all delivery attempts in the __Notifications table with status tracking, timestamps, and indexes for efficient querying.

app.UseNotifications(n =>
{
n.AddSmtp(sender => { ... }, transport => { ... });
n.AddWebhook();
});

The webhook channel sends HTTP POST requests with JSON body to the URL specified in NotificationRecipient.WebhookUrl. It uses a named HttpClient (Pragmatic.Notifications.Webhook) that you can configure via IHttpClientFactory.

Replace INotificationService with the test harness:

public class ReservationNotificationTests
{
[Fact]
public async Task ConfirmReservation_SendsConfirmationEmail()
{
// Arrange
var services = new ServiceCollection();
var harness = services.AddNotificationTestHarness();
// ... register other dependencies ...
var sp = services.BuildServiceProvider();
var svc = sp.GetRequiredService<INotificationService>();
// Act
await svc.SendAsync(new NotificationRequest
{
Audience = NotificationAudience.EndUser,
Recipient = NotificationRecipient.Direct("guest@example.com"),
Content = new NotificationContent
{
Subject = "Reservation Confirmed",
Body = "Your reservation has been confirmed.",
},
Category = "transactional",
});
// Assert
harness.Sent.Should().HaveCount(1);
harness.HasSentWithSubject("Reservation Confirmed").Should().BeTrue();
harness.HasSentTo(NotificationAudience.EndUser).Should().BeTrue();
}
[Fact]
public async Task EnqueueAsync_RecordsSynchronousFlag()
{
var services = new ServiceCollection();
var harness = services.AddNotificationTestHarness();
var sp = services.BuildServiceProvider();
var svc = sp.GetRequiredService<INotificationService>();
await svc.EnqueueAsync(new NotificationRequest { /* ... */ });
harness.Sent[0].Synchronous.Should().BeFalse();
}
}
MethodDescription
SentAll recorded notifications
HasSentTo(audience)Any notification to that audience
HasSentWithSubject(subject)Any notification with that subject
HasSent(predicate)Custom predicate match
SentWhere(predicate)Filter recorded notifications
Reset()Clear all recordings
public sealed class UserProfilePreferenceProvider(IUserStore userStore)
: INotificationPreferenceProvider
{
public async Task<NotificationPreferences?> GetPreferencesAsync(
string userIdOrAddress, CancellationToken ct)
{
var user = await userStore.FindByEmailOrIdAsync(userIdOrAddress, ct);
if (user is null) return null;
return new NotificationPreferences
{
Enabled = user.NotificationsEnabled,
PreferredChannel = user.PreferredNotificationChannel,
MutedCategories = user.MutedNotificationCategories,
DoNotDisturb = user.DoNotDisturb,
Locale = user.Locale,
};
}
}

Register:

app.UseNotifications(n =>
{
n.UsePreferences<UserProfilePreferenceProvider>();
// ...
});

For resolving user IDs to email addresses via your identity system:

public sealed class IdentityRecipientResolver(
IUserStore userStore,
INotificationPreferenceProvider prefs) : IRecipientResolver
{
public async Task<IReadOnlyList<ResolvedRecipient>> ResolveAsync(
NotificationRecipient recipient, NotificationAudience audience, CancellationToken ct)
{
var results = new List<ResolvedRecipient>();
if (recipient.UserId is { } userId)
{
var user = await userStore.GetByIdAsync(userId, ct);
if (user is not null)
{
var userPrefs = await prefs.GetPreferencesAsync(userId, ct);
results.Add(new ResolvedRecipient(
user.Email, NotificationChannel.Email, user.Locale, user.TenantId, userPrefs));
}
}
if (recipient.RoleName is { } role)
{
var users = await userStore.GetByRoleAsync(role, ct);
foreach (var user in users)
{
var userPrefs = await prefs.GetPreferencesAsync(user.Id, ct);
results.Add(new ResolvedRecipient(
user.Email, NotificationChannel.Email, user.Locale, user.TenantId, userPrefs));
}
}
// Direct addresses pass through
if (recipient.EmailAddress is not null)
results.Add(new ResolvedRecipient(recipient.EmailAddress, NotificationChannel.Email, null, null, null));
if (recipient.WebhookUrl is not null)
results.Add(new ResolvedRecipient(recipient.WebhookUrl, NotificationChannel.Webhook, null, null, null));
return results;
}
}

Register by replacing the default via DI (before AddPragmaticNotifications):

services.AddSingleton<IRecipientResolver, IdentityRecipientResolver>();
services.AddPragmaticNotifications(n => { ... }); // TryAdd won't overwrite