Skip to content

Advanced ​

This page covers production-level topics: retry policies, scheduling, observability, multi-tenancy, database schema, migrations, and testing strategies.

Retry Policies ​

GrydNotifications uses Polly-based retry policies for transient failures in email delivery, push notifications, and webhook calls.

Configuration ​

csharp
builder.Services.AddGrydNotifications(options =>
{
    options.Retry.MaxRetries = 3;
    options.Retry.BackoffType = BackoffType.Exponential;
    options.Retry.BaseDelay = TimeSpan.FromSeconds(5);
    options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
    options.Retry.UseJitter = true;
    options.Retry.NonRetryableReasons = new HashSet<DeliveryFailureReason>
    {
        DeliveryFailureReason.InvalidRecipient,
        DeliveryFailureReason.InvalidTemplate,
        DeliveryFailureReason.AuthenticationFailed
    };
});

Backoff Types ​

TypeFormulaExample (BaseDelay = 5s)
FixedBaseDelay5s, 5s, 5s
LinearBaseDelay × attempt5s, 10s, 15s
ExponentialBaseDelay × 2^(attempt-1)5s, 10s, 20s

When UseJitter = true, a random variation (±25%) is added to prevent thundering herd when multiple notifications retry simultaneously.

Retry Flow ​

Send Attempt 1 ──► Failure (transient)
       │
       ▼
  Wait BaseDelay (5s + jitter)
       │
       ▼
Send Attempt 2 ──► Failure (transient)
       │
       ▼
  Wait BaseDelay × 2 (10s + jitter)
       │
       ▼
Send Attempt 3 ──► Failure (transient)
       │
       ▼
  MaxRetries exceeded → Mark as Failed
  Reason stored in notification record

Non-Retryable Reasons ​

If a failure reason matches any value in NonRetryableReasons, the notification is immediately marked as Failed without retrying — saving resources on permanent errors.

Dead Letters ​

Notifications that exhaust all retry attempts are moved to a dead-letter state:

csharp
// Query failed notifications for investigation
var deadLetters = await _mediator.Send(new GetNotificationsQuery
{
    Status = NotificationStatus.Failed,
    Page = 1,
    PageSize = 50
}, ct);

Queue Processing ​

The background queue processes notifications asynchronously when EnableQueue = true:

csharp
options.Queue.BatchSize = 10;          // Notifications per batch
options.Queue.PollingInterval = 
    TimeSpan.FromSeconds(5);           // Poll frequency
options.Queue.MaxConcurrency = 4;      // Parallel senders
options.Queue.MessageExpiration = null; // No expiration by default

Queue Architecture ​

Producer (API/Command)
       │
       ▼
┌─────────────────┐
│  Queue Storage   │  (in-memory Channel<T> — default)
│  (pending msgs)  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Queue Processor │  (BackgroundService)
│  4 workers       │────► Email Sender
│  10 per batch    │────► Push Sender
│  5s polling      │────► InApp Sender
└─────────────────┘

Scheduling Notifications ​

Send Later ​

Schedule a notification for future delivery:

csharp
await _mediator.Send(new ScheduleNotificationCommand
{
    Channel = NotificationChannel.Email,
    Recipients = new[] { "user@example.com" },
    Subject = "Your trial expires tomorrow",
    TemplateId = trialExpiryTemplateId,
    Variables = new Dictionary<string, object>
    {
        ["user_name"] = "John",
        ["expiry_date"] = DateTime.UtcNow.AddDays(1)
    },
    ScheduledAt = DateTime.UtcNow.AddDays(6) // Send 1 day before trial ends
}, ct);

Cancel a Scheduled Notification ​

csharp
await _mediator.Send(new CancelScheduledNotificationCommand
{
    NotificationId = notificationId
}, ct);

Integration with GrydJobs ​

For complex scheduling (recurring reminders, cron expressions), integrate with the GrydJobs module:

csharp
// Register via GrydNotifications.Scheduling package
builder.Services.AddGrydNotifications(options => { /* ... */ })
    .AddSchedulingIntegration();
csharp
// Recurring weekly digest
await _mediator.Send(new CreateRecurringNotificationJobCommand
{
    JobName = "weekly-digest",
    CronExpression = "0 9 * * MON", // Every Monday at 9 AM
    Channel = NotificationChannel.Email,
    TemplateId = weeklyDigestTemplateId,
    RecipientQuery = "active-subscribers" // Named query
}, ct);

Observability ​

OpenTelemetry Integration ​

GrydNotifications emits traces and metrics via Gryd.Observability.OpenTelemetry:

csharp
builder.Services.AddGrydObservability(options =>
{
    options.ServiceName = "my-api";
    options.EnableTracing = true;
    options.EnableMetrics = true;
});

Metrics ​

MetricTypeDescription
gryd.notifications.sentCounterTotal notifications sent
gryd.notifications.failedCounterTotal notifications failed
gryd.notifications.retriedCounterTotal retry attempts
gryd.notifications.queue.depthGaugeCurrent queue depth
gryd.notifications.queue.processing_timeHistogramQueue processing latency
gryd.notifications.delivery.durationHistogramEnd-to-end delivery time

All metrics include dimensions: channel, status, tenant_id.

Traces ​

Each notification generates a trace span:

notification.send
  ├── template.render (if template used)
  ├── channel.email.send (MailKit/SendGrid)
  ├── channel.push.send (FCM)
  └── channel.inapp.persist (PostgreSQL + SignalR)

Health Checks ​

csharp
builder.Services.AddHealthChecks()
    .AddGrydNotificationsHealthCheck();

The health check verifies:

  • Database connectivity — Can reach PostgreSQL notifications schema
  • Queue processor — BackgroundService is running (if enabled)
  • SignalR hub — Hub is accepting connections (if enabled)
  • SMTP — Can connect to SMTP server (if email enabled)
http
GET /health

{
  "status": "Healthy",
  "checks": {
    "gryd-notifications": {
      "status": "Healthy",
      "data": {
        "database": "OK",
        "queue": "OK",
        "signalr": "OK",
        "smtp": "OK"
      }
    }
  }
}

Multi-Tenancy ​

GrydNotifications is multi-tenancy aware through ITenantOptionalAware:

Tenant Isolation ​

All commands and queries accept an optional TenantId:

csharp
await _mediator.Send(new SendNotificationCommand
{
    TenantId = currentTenant.Id, // Nullable<Guid>
    Channel = NotificationChannel.Email,
    Recipients = new[] { "user@example.com" },
    Subject = "Welcome!"
}, ct);

When TenantId is set:

  • Notifications are scoped to the tenant
  • Templates fall back: tenant-specific → global
  • SignalR groups include tenant: tenant_{tenantId}
  • Queries only return tenant-scoped data

Template Tenant Fallback ​

Lookup: (key="welcome", locale="pt-BR", tenantId="abc")
  1. key="welcome", locale="pt-BR", tenantId="abc"    ← Tenant + locale
  2. key="welcome", locale="pt",    tenantId="abc"    ← Tenant + language
  3. key="welcome", locale="pt-BR", tenantId=null     ← Global + locale
  4. key="welcome", locale="pt",    tenantId=null     ← Global + language
  5. key="welcome", locale=null,    tenantId=null     ← Default fallback

Per-Tenant Provider Configuration ​

Override email/push settings per tenant:

csharp
// In a custom ITenantNotificationConfigProvider
public class TenantNotificationConfig : ITenantNotificationConfigProvider
{
    public Task<EmailOptions?> GetEmailOptionsAsync(Guid tenantId, CancellationToken ct)
    {
        // Load from database, cache, etc.
        return Task.FromResult(new EmailOptions
        {
            DefaultFrom = "noreply@tenant-domain.com",
            SmtpHost = "smtp.tenant-provider.com"
        });
    }
}

Database Schema ​

All tables live in the notifications schema:

sql
-- Core tables
notifications.notifications          -- Main notification records
notifications.notification_recipients -- Per-recipient tracking
notifications.notification_templates  -- Scriban templates
notifications.notification_queue      -- Async queue (if enabled)

-- In-App tables
notifications.user_notifications     -- Per-user in-app notifications
notifications.user_devices           -- Push notification device tokens

Key Indexes ​

TableIndexPurpose
notificationsix_notifications_statusQueue processing
notificationsix_notifications_tenant_createdTenant + date queries
user_notificationsix_user_notifications_user_unreadUnread count badge
user_notificationsix_user_notifications_collapse_keyCollapse key lookup
notification_templatesix_templates_key_locale_tenantTemplate resolution

Migrations ​

Apply on Startup ​

csharp
var app = builder.Build();
await app.ApplyGrydNotificationsMigrationsAsync();

Generate SQL Script ​

bash
dotnet ef migrations script \
  --project src/Modules/Notifications/GrydNotifications.Infrastructure \
  --context NotificationsDbContext \
  --output migrations.sql

Add a New Migration ​

bash
dotnet ef migrations add AddCustomColumn \
  --project src/Modules/Notifications/GrydNotifications.Infrastructure \
  --startup-project src/Core/Gryd.API \
  --context NotificationsDbContext

Testing ​

Unit Testing Handlers ​

csharp
using NSubstitute;
using Xunit;

public class SendEmailNotificationHandlerTests
{
    private readonly IEmailSender _emailSender = Substitute.For<IEmailSender>();
    private readonly INotificationRepository _repo = Substitute.For<INotificationRepository>();
    private readonly SendEmailNotificationHandler _sut;

    public SendEmailNotificationHandlerTests()
    {
        _sut = new SendEmailNotificationHandler(_emailSender, _repo);
    }

    [Fact]
    public async Task Handle_ValidEmail_SendsAndPersists()
    {
        // Arrange
        var command = new SendEmailNotificationCommand
        {
            Recipients = new[] { "test@example.com" },
            Subject = "Test",
            Body = "<p>Hello</p>"
        };

        _emailSender.SendAsync(Arg.Any<EmailMessage>(), Arg.Any<CancellationToken>())
            .Returns(Result.Success());

        // Act
        var result = await _sut.Handle(command, CancellationToken.None);

        // Assert
        Assert.True(result.IsSuccess);
        await _emailSender.Received(1).SendAsync(
            Arg.Is<EmailMessage>(m => m.To.Contains("test@example.com")),
            Arg.Any<CancellationToken>());
        await _repo.Received(1).AddAsync(
            Arg.Any<Notification>(),
            Arg.Any<CancellationToken>());
    }
}

Testing Controllers ​

csharp
using MediatR;
using NSubstitute;
using Microsoft.AspNetCore.Mvc;

public class NotificationsControllerTests
{
    private readonly IMediator _mediator = Substitute.For<IMediator>();
    private readonly ILogger<NotificationsController> _logger = Substitute.For<ILogger<NotificationsController>>();
    private readonly NotificationsController _sut;

    public NotificationsControllerTests()
    {
        _sut = new NotificationsController(_mediator, _logger);
    }

    [Fact]
    public async Task Send_ValidRequest_ReturnsOk()
    {
        // Arrange
        var request = new SendNotificationRequest { /* ... */ };
        _mediator.Send(Arg.Any<SendNotificationCommand>(), Arg.Any<CancellationToken>())
            .Returns(Result<Guid>.Success(Guid.NewGuid()));

        // Act
        var result = await _sut.Send(request, CancellationToken.None);

        // Assert
        Assert.IsType<OkObjectResult>(result);
    }
}

Integration Tests with TestContainers ​

csharp
using Testcontainers.PostgreSql;

public class NotificationsIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
        // Apply migrations
    }

    public async Task DisposeAsync() => await _postgres.DisposeAsync();

    [Fact]
    public async Task FullNotificationLifecycle_SendAndRead()
    {
        // Create factory with real database
        var factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureServices(services =>
                {
                    services.AddGrydNotifications(options =>
                    {
                        options.ConnectionString = _postgres.GetConnectionString();
                        options.InApp.Enabled = true;
                    });
                });
            });

        var client = factory.CreateClient();

        // Send notification
        var response = await client.PostAsJsonAsync("/api/v1/notifications/send", new
        {
            channel = "InApp",
            userId = Guid.NewGuid(),
            title = "Test",
            body = "Integration test"
        });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }
}

Testing SignalR Hub ​

csharp
[Fact]
public async Task NotificationHub_ReceivesRealTimeNotification()
{
    var factory = new WebApplicationFactory<Program>();
    var server = factory.Server;

    var connection = new HubConnectionBuilder()
        .WithUrl(
            $"{server.BaseAddress}hubs/notifications",
            options => options.HttpMessageHandlerFactory = _ => server.CreateHandler())
        .Build();

    var received = new TaskCompletionSource<UserNotificationDto>();
    connection.On<UserNotificationDto>("ReceiveNotification", n =>
    {
        received.SetResult(n);
    });

    await connection.StartAsync();

    // Trigger notification via API
    var client = factory.CreateClient();
    await client.PostAsJsonAsync("/api/v1/notifications/send", new { /* ... */ });

    // Wait for real-time delivery
    var notification = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
    Assert.Equal("Test", notification.Title);
}

Performance Tips ​

TipDescription
Batch emailsUse BulkBatchSize and BulkDelayBetweenBatches for mass emails
Enable queueProcess notifications asynchronously to avoid blocking API requests
Use collapse keysPrevent notification spam for repetitive events
Set expirationDefaultTimeToLive avoids stale in-app notifications
Redis backplaneRequired for SignalR with multiple server instances
Template cachingTemplates are cached by default — avoid disabling in production
Tune concurrencyAdjust Queue.MaxConcurrency based on provider rate limits
Monitor queue depthAlert on gryd.notifications.queue.depth growing continuously

Released under the MIT License.