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.