Skip to content

Set Up Notifications

Granit.Notifications is a fan-out notification engine that delivers messages across multiple channels from a single PublishAsync() call. This guide walks you through installing the engine, registering channels, defining notification types, and publishing notifications to users.

  • A working Granit application with Granit.Wolverine configured
  • PostgreSQL database (for durable stores)
  • Redis instance (if using SignalR backplane or caching)

Add the core package and the channels you need:

Terminal window
dotnet add package Granit.Notifications
dotnet add package Granit.Notifications.EntityFrameworkCore
dotnet add package Granit.Notifications.Endpoints

Then add one or more channel packages depending on your requirements:

Terminal window
dotnet add package Granit.Notifications.Email
dotnet add package Granit.Notifications.Email.Smtp

Declare the module dependency in your application module:

using Granit.Core.Modularity;
using Granit.Notifications.EntityFrameworkCore;
[DependsOn(typeof(GranitNotificationsEntityFrameworkCoreModule))]
public sealed class MyAppModule : GranitModule { }

Register the EF Core store with your database provider:

builder.AddGranitNotificationsEntityFrameworkCore(
opts => opts.UseNpgsql(connectionString));

Add each channel in Program.cs. Only register the channels your application needs — unregistered channels are skipped with a log warning (graceful degradation).

// Real-time: choose ONE of SSE or SignalR (mutually exclusive)
builder.Services.AddGranitNotificationsSignalR("redis:6379");
// OR: builder.Services.AddGranitNotificationsSse();
// Email via MailKit SMTP
builder.Services.AddGranitNotificationsEmail();
builder.Services.AddGranitNotificationsEmailSmtp();
// SMS
builder.Services.AddGranitNotificationsSms();
// Web Push (W3C VAPID -- no dependency on FCM or APNs)
builder.Services.AddGranitNotificationsPush();
app.MapGranitNotificationEndpoints();

This registers the inbox, preferences, subscriptions, and entity follower endpoints under the /notifications prefix.

Add the configuration for each registered channel in appsettings.json:

{
"Notifications": {
"MaxParallelDeliveries": 8,
"Email": {
"Provider": "Smtp",
"SenderAddress": "noreply@example.com",
"SenderName": "My Application"
},
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UseSsl": true,
"Username": "api-user",
"Password": "vault://secret/smtp-password"
},
"Push": {
"VapidSubject": "mailto:admin@example.com",
"VapidPublicKey": "BBase64UrlSafe...",
"VapidPrivateKey": "vault://secret/vapid-private-key"
},
"SignalR": {
"RedisConnectionString": "redis:6379"
}
}
}

Each notification type is declared as a class deriving from NotificationType<TData>:

public sealed class OrderShippedNotification : NotificationType<OrderShippedData>
{
public static readonly OrderShippedNotification Instance = new();
public override string Name => "Orders.Shipped";
public override NotificationSeverity DefaultSeverity => NotificationSeverity.Info;
public override IReadOnlyList<string> DefaultChannels =>
[NotificationChannels.InApp, NotificationChannels.Email, NotificationChannels.Push];
}
public sealed record OrderShippedData
{
public required string OrderId { get; init; }
public required string TrackingNumber { get; init; }
}

Register notification definitions via a provider:

public sealed class OrderNotificationDefinitionProvider : INotificationDefinitionProvider
{
public void Define(INotificationDefinitionContext context)
{
context.Add(new NotificationDefinition("Orders.Shipped")
{
DisplayName = "Order shipped",
Description = "Sent when an order is shipped to the customer.",
GroupName = "Orders",
DefaultChannels = [NotificationChannels.InApp, NotificationChannels.Email],
AllowUserOptOut = true,
});
}
}
// In ConfigureServices:
services.AddNotificationDefinitions<OrderNotificationDefinitionProvider>();

Inject INotificationPublisher and choose one of three publishing strategies:

public sealed class OrderService(INotificationPublisher notifications)
{
public async Task ShipOrderAsync(Order order, CancellationToken cancellationToken)
{
// ... business logic ...
await notifications.PublishAsync(
OrderShippedNotification.Instance,
new OrderShippedData
{
OrderId = order.Id.ToString(),
TrackingNumber = order.TrackingNumber,
},
recipientUserIds: [order.CustomerId],
cancellationToken);
}
}

For automatic notifications when tracked properties change, implement ITrackedEntity on your EF Core entities:

public sealed class Patient : Entity, ITrackedEntity
{
public static string EntityTypeName => "Patient";
public string GetEntityId() => Id.ToString();
public string Status { get; set; } = string.Empty;
public static IReadOnlyDictionary<string, TrackedPropertyConfig> TrackedProperties =>
new Dictionary<string, TrackedPropertyConfig>
{
["Status"] = new()
{
NotificationTypeName = "Patient.StatusChanged",
Severity = NotificationSeverity.Warning,
},
};
}

The EntityTrackingInterceptor (provided by Granit.Notifications.EntityFrameworkCore) detects changes during SaveChangesAsync and automatically publishes notifications to entity followers.

Users follow or unfollow entities via the REST API:

  • POST /notifications/entity/{entityType}/{entityId}/follow
  • DELETE /notifications/entity/{entityType}/{entityId}/follow

Every delivery attempt is recorded in an immutable NotificationDeliveryAttempt table (ISO 27001 audit trail). Failed deliveries are retried with exponential backoff:

AttemptDelayElapsed
110 seconds10 s
21 minute~1 min 10 s
35 minutes~6 min 10 s
430 minutes~36 min 10 s
52 hours~2 h 36 min

After 5 attempts, the message moves to the Wolverine Dead Letter Queue.

MethodRouteDescription
GET/notificationsPaginated inbox
GET/notifications/unread/countUnread count
POST/notifications/{id}/readMark as read
POST/notifications/read-allMark all as read
GET/notifications/preferencesUser preferences
PUT/notifications/preferencesUpdate a preference
GET/notifications/typesRegistered notification types
POST/notifications/subscriptions/{typeName}Subscribe to a type
DELETE/notifications/subscriptions/{typeName}Unsubscribe