Notify is a .NET library for asynchronous, broker-backed notification delivery. It decouples producers (APIs, jobs, services) from workers that perform the actual delivery (email, SMS, push), giving you:
- Consistent payloads across channels.
- Broker-agnostic publishing/consumption with pluggable providers.
- Operational controls for batching, concurrency, and resilience.
- Observability hooks via metrics and pipelines.
If you need to enqueue notifications quickly and dispatch them reliably with tuned throughput, Notify exists to fill that gap.
Producer (publishing notifications):
dotnet add package Notify.Core
dotnet add package Notify.Broker.RabbitMQWorker (consuming + dispatching):
dotnet add package Notify.Hosting
dotnet add package Notify.Broker.RabbitMQIf you only need shared contracts, add
Notify.Abstractions.
using Notify.Abstractions;
using Notify.Core;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services
.AddNotify(builder.Configuration.GetSection("Notify"))
.UseRabbitMq();
WebApplication app = builder.Build();
app.MapPost("/notify/email", async (INotify notify, CancellationToken ct) =>
{
NotificationPackage package = new()
{
Channel = NotificationChannel.Email,
Title = "Welcome!",
Description = "Thanks for signing up.",
CorrelationId = Guid.NewGuid().ToString("N"),
CustomData = new Dictionary<string, string>
{
["recipient"] = "person@example.com"
}
};
await notify.SendAsync(package, ct);
return Results.Accepted();
});
app.Run();using Notify.Abstractions;
using Notify.Core;
using Notify.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
INotifyBuilder notifyBuilder = builder.Services
.AddNotify(builder.Configuration.GetSection("Notify"))
.UseRabbitMq()
.AddNotifyDispatcher();
notifyBuilder.AddEmailProvider<SampleEmailProvider, SampleProviderOptions>("Notify:Providers:Email");
IHost app = builder.Build();
await app.RunAsync();
public sealed class SampleProviderOptions
{
public string ProviderName { get; set; } = "SampleProvider";
public string DefaultRecipient { get; set; } = "sample@example.com";
}
public sealed class SampleEmailProvider : ProviderBase<SampleProviderOptions>
{
public SampleEmailProvider(ILogger<SampleEmailProvider> logger, IOptionsMonitor<SampleProviderOptions> optionsMonitor)
: base(logger, optionsMonitor)
{
}
public override NotificationChannel Channel => NotificationChannel.Email;
public override Task SendAsync(NotificationPackage package, CancellationToken ct = default)
{
string recipient = package.CustomData?.GetValueOrDefault("recipient") ?? Options.DefaultRecipient;
Logger.LogInformation("[Email] {Provider} -> {Recipient} ({Title})", Options.ProviderName, recipient, package.Title);
return Task.CompletedTask;
}
}{
"Notify": {
"QueuePrefix": "myapp",
"Serialization": "Json",
"Compression": {
"Enabled": true,
"Algorithm": "Lz4"
},
"Publishing": {
"BatchSize": 50,
"MaxInFlight": 200
},
"Email": {
"Concurrency": 4,
"Prefetch": 100,
"BatchSize": 10,
"BatchMaxWaitMs": 250
},
"Sms": {
"Concurrency": 2,
"Prefetch": 50,
"BatchSize": 5,
"BatchMaxWaitMs": 250
},
"Push": {
"Concurrency": 4,
"Prefetch": 100,
"BatchSize": 20,
"BatchMaxWaitMs": 250
},
"Providers": {
"Email": {
"ProviderName": "Ses",
"DefaultRecipient": "fallback@example.com"
}
},
"Broker": {
"RabbitMq": {
"Host": "localhost",
"Port": 5672,
"Username": "guest",
"Password": "guest",
"VirtualHost": "/",
"ExchangeName": "notify",
"UseTls": false
}
}
}
}Notify names queues as:
{queuePrefix}.email
{queuePrefix}.sms
{queuePrefix}.push
- The prefix is read from
Notify:QueuePrefixwhen provided. - If
QueuePrefixis not set, Notify falls back toIHostEnvironment.ApplicationName. - Channel segments are lowercased (see
BrokerNaming.BuildQueueName).
RabbitMQ options are read from Notify:Broker:RabbitMq when present, with a fallback to a legacy root RabbitMq section.
Use a stable prefix per environment (e.g., myapp-prod) to avoid cross-talk.
Implement the interface directly when you need full control:
public sealed class WebhookProvider : IProvider
{
public NotificationChannel Channel => NotificationChannel.Push;
public Task SendAsync(NotificationPackage package, CancellationToken ct = default)
=> SendBatchAsync([package], ct);
public Task SendBatchAsync(IReadOnlyList<NotificationPackage> packages, CancellationToken ct = default)
{
// Send to external service
return Task.CompletedTask;
}
}Use ProviderBase<TOptions> to get option monitoring and a logger:
public sealed class SmsProvider : ProviderBase<SmsOptions>
{
public SmsProvider(ILogger<SmsProvider> logger, IOptionsMonitor<SmsOptions> options)
: base(logger, options) { }
public override NotificationChannel Channel => NotificationChannel.Sms;
public override Task SendAsync(NotificationPackage package, CancellationToken ct = default)
{
// Use Options from configuration
return Task.CompletedTask;
}
}notifyBuilder.AddSmsProvider<SmsProvider, SmsOptions>("Notify:Providers:Sms");Notify:Providers:Sms is bound via IOptionsMonitor<SmsOptions>, so changes can be reloaded at runtime.
Notify:Publishing:BatchSizecontrols producer in-flight batching.Notify:*:Concurrencysets worker handler concurrency per channel.Notify:*:Prefetchcaps how many messages are fetched from the broker per channel.Notify:*:BatchSize+BatchMaxWaitMscontrols how aggressively the worker groups messages for provider dispatch.
Guidance:
- Increase prefetch to improve throughput for IO-bound providers.
- Increase concurrency to scale out processing, but ensure providers are thread-safe.
- For bulk APIs, set batch size > 1 to reduce per-call overhead.
- Set BatchMaxWaitMs to keep latency predictable at low traffic.
- RabbitMQ: Prefetch is critical; set
Prefetch >= Concurrency. Use queues per channel with a stable prefix. - Kafka (via a custom
IBrokerClient): tune consumer groups, partitions, and max poll interval. Kafka batching often happens in the broker/client, so you may keep Notify batch sizes smaller and rely on broker batching.
- Serialization:
Json(readable, flexible) vsMessagePack(smaller, faster). Set inNotify:Serialization. - Compression: Enable LZ4 (
Notify:Compression) when payloads are large or network-bound. Keep it off for tiny payloads to save CPU.
Notify provides extension points for resilience:
- Retries via
INotificationRetryStrategy(inject in DI). Use exponential backoff with jitter. - Circuit breaker via
INotificationCircuitBreakerto short-circuit failing providers. - Dead-letter queues (DLQ) are broker-specific: configure RabbitMQ DLX/TTL policies or Kafka topic routing for failed messages.
Pair retries with DLQ so poison messages don’t block dispatching.
Notify emits metrics via System.Diagnostics.Metrics under the Notify meter:
notify_published_totalnotify_consumed_totalnotify_sent_totalnotify_failed_totalnotify_retry_totalnotify_send_latency_ms
Hook this into OpenTelemetry by registering a meter provider for the Notify meter name.
There is no built-in ActivitySource, but you can add tracing by implementing INotificationPipeline and wrapping publish/dispatch operations with Activity or OpenTelemetry spans. Use NotificationPipelineContext to tag channel, operation, provider, and correlation ID.