Skip to content

Notifications package conventions

A Granit.{Module}.Notifications package is the bridge between a domain module and Granit.Notifications: it ships the notification types (what events users care about), the Wolverine handlers (what triggers them) and the email templates (what the user sees). Several such packages ship across the framework — in granit-dotnet: Granit.Privacy.Notifications, Granit.Identity.Local.Notifications, Granit.Auditing.Notifications, Granit.BackgroundJobs.Notifications, Granit.Webhooks.Notifications; in granit-business: Granit.Invoicing.Notifications, Granit.Activities.Notifications, Granit.CustomerBalance.Notifications, Granit.Documents.Notifications, and more. This page captures the convention every new package must follow so the inventory stays homogeneous.

The same checklist is mirrored — terser — in the project CLAUDE.md under *.Notifications package — canonical checklist”. Keep both in sync.

Build one when your module emits a domain or integration event that someone needs to be told about. Concretely:

  • The user must know an asynchronous request finished (export ready, payment succeeded, scheduled deletion confirmed).
  • A tenant admin must react to a degraded condition (recurring job failing, quota exceeded, webhook circuit broken).
  • A platform admin must audit a sensitive operation (token exchange, anomaly detected, API key rotated).

If the event is purely internal plumbing — cache invalidation, projection rebuild, saga step — skip the notifications package and let the message stay in the event bus.

  • Directorysrc/Granit.{Module}.Notifications/
    • Granit.{Module}.Notifications.csproj
    • Granit{Module}NotificationsModule.cs DI module + template registration
    • {Action}NotificationType.cs one per user-facing event
    • DirectoryGlobalContexts/ optional — {{ module }} global template variables
      • {Module}ContactGlobalContext.cs
    • DirectoryHandlers/ Wolverine event handlers
      • {Trigger}NotificationHandler.cs
    • DirectoryTemplates/ HTML, embedded as resources
      • {module}.{name}.html EN neutral
      • {module}.{name}.fr.html FR baseline
      • {module}.{name}.de.html generated by scripts/translate-templates.py
    • DirectoryInternal/ optional
      • {Module}NotificationDefinitionProvider.cs admin-UI exposure
  • Directorytests/Granit.{Module}.Notifications.Tests/
    • Handlers/{Trigger}NotificationHandlerTests.cs
    • Templates/EmbeddedTemplatesTests.cs manifest pinning
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Granit.{Module}.Notifications</PackageId>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Granit.{Module}.Notifications.Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Templates\**\*.html" WithCulture="false" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Granit.{Module}\Granit.{Module}.csproj" />
<ProjectReference Include="..\Granit.Notifications.Abstractions\Granit.Notifications.Abstractions.csproj" />
<ProjectReference Include="..\Granit.Templating\Granit.Templating.csproj" />
</ItemGroup>
</Project>
[DependsOn(
typeof(GranitNotificationsAbstractionsModule),
typeof(Granit{Module}Module),
typeof(GranitTemplatingModule))]
public sealed class Granit{Module}NotificationsModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// Templates are embedded — register the assembly with the templating engine.
context.Services.AddEmbeddedTemplates(
typeof(Granit{Module}NotificationsModule).Assembly);
// Layout glob — every template named "{module-prefix}.*" wraps in Layout.Email.
// The host app registers the actual `Layout.Email`; if absent, templates render
// without a layout (warning logged, no crash).
context.Services.AddTemplateLayout("{module-prefix}.*", "Layout.Email");
// Optional — a `{{ module }}` global context (controller/DPO contact, branding…).
// context.Services.AddTemplateGlobalContext<{Module}ContactGlobalContext>();
// REQUIRED — register the definition provider (see §3.5 for the runtime impact).
context.Services.AddSingleton<INotificationDefinitionProvider,
{Module}NotificationDefinitionProvider>();
}
}
public sealed class CreditExpiringNotificationType
: NotificationType<CreditExpiringNotificationData>
{
public static readonly CreditExpiringNotificationType Instance = new();
public override string Name => "customer-balance.credit_expiring";
public override NotificationSeverity DefaultSeverity => NotificationSeverity.Warning;
public override IReadOnlyList<string> DefaultChannels { get; } =
[NotificationChannels.Email, NotificationChannels.InApp];
}
public sealed record CreditExpiringNotificationData(
Guid AccountId,
decimal RemainingAmount,
DateTimeOffset ExpiresAt);
FieldConvention
Namesnake_case: module.event_name (preferred) or module-kebab.event_name. Legacy PascalCase like Privacy.LegalDocumentObsolete still works but should NEVER be repeated for new types. The Name is also the key for Templates/{Name}.html.
InstanceSingleton — handlers reference it directly when calling INotificationPublisher.PublishAsync(...).
DefaultChannelsUse NotificationChannels.* constants. Each channel listed here MUST have a corresponding renderer (e.g. Email → template).
DefaultSeverityInfo (default), Warning, Error. Drives styling in the in-app inbox.
Data recordSame file as the notification type. sealed record of primitives + Guid.

Lives in Internal/{Module}NotificationDefinitionProvider.cs. One NotificationDefinition per NotificationType<T> declared in the package.

internal sealed class CustomerBalanceNotificationDefinitionProvider
: INotificationDefinitionProvider
{
private const string GroupName = "Customer Balance";
public void Define(INotificationDefinitionContext context)
{
context.Add(new NotificationDefinition(CreditExpiringNotificationType.Instance.Name)
{
GroupName = GroupName,
DisplayName = "Credit Expiring Soon",
Description = "Notifies the user before their balance credit expires.",
DefaultSeverity = NotificationSeverity.Warning,
DefaultChannels = [NotificationChannels.Email, NotificationChannels.InApp],
AllowUserOptOut = true, // user convenience
// RequiredPermission = "CustomerBalance.Credit.Read", // see below
// RequiredFeature = "CustomerBalance.Enabled", // see below
});
}
}

Conventions:

FieldConvention
NameMirror the NotificationType<T>.Instance.Name — drift between the two is a runtime bug.
GroupNameTitle Case English label. Drives the section header in the preferences UI.
DisplayName / DescriptionEnglish copy. Localization layer to be added separately.
DefaultChannelsMirror what’s declared on NotificationType<T>.DefaultChannels. The fan-out engine reads it from the definition, not the type.
AllowUserOptOutfalse for security / GDPR / operational alerts. true for user-facing convenience (mentions, comments, workflow approvals).
AllowDoNotDisturbBypasstrue only for security-critical alerts that must reach the user even in DnD (suspicious login, MFA disabled, breach notice).
RequiredPermissionOptional. UI-only filter — see below.
RequiredFeatureOptional. UI-only filter — see below.

Hiding notifications the user shouldn’t see (RequiredPermission / RequiredFeature)

Section titled “Hiding notifications the user shouldn’t see (RequiredPermission / RequiredFeature)”

These two optional fields filter the GET /api/v1/notifications/types endpoint so end-users don’t see preferences for notifications that don’t apply to them.

context.Add(new NotificationDefinition(AuditingAnomalyDetectedNotificationType.Instance.Name)
{
GroupName = "Security",
DefaultSeverity = NotificationSeverity.Warning,
DefaultChannels = [NotificationChannels.Email, NotificationChannels.InApp],
AllowUserOptOut = false,
RequiredPermission = "Auditing.AuditEntries.Read", // SOC analysts only
RequiredFeature = "Auditing.Enabled", // tenant-level feature flag
});
  • RequiredPermission is checked via IPermissionChecker.GetGrantedAsync (already wired in Granit.Notifications.Endpoints). When the host hasn’t mounted the *.Endpoints module that declares the permission, the check is treated as “not granted” — the notification stays hidden.
  • RequiredFeature is checked via INotificationFeatureGate, an optional adapter hosts wire over Granit.Features.IFeatureChecker. When no adapter is registered, the feature filter is a no-op.
  • Both are UI filters only. Fan-out is unaffected — a notification dispatched to a recipient is still delivered even if the recipient lacks the listed permission. Use AllowUserOptOut = false to enforce delivery.

4. Wolverine handlers — local vs distributed

Section titled “4. Wolverine handlers — local vs distributed”

Handlers live in Handlers/{Trigger}NotificationHandler.cs:

public class CreditExpiredNotificationHandler // public class, NEVER static, NEVER internal
{
public static async Task HandleAsync( // public static method
CreditExpiredEto evt,
INotificationPublisher publisher,
CancellationToken cancellationToken)
{
await publisher.PublishAsync(
CreditExpiringNotificationType.Instance,
new CreditExpiringNotificationData(evt.AccountId, evt.Remaining, evt.ExpiresAt),
recipients: [evt.UserId.ToString()],
cancellationToken).ConfigureAwait(false);
}
}

Wolverine discovers the handler the same way regardless of where the trigger is emitted, but you must decide the transport explicitly per package.

The trigger originates inside the same API/worker that hosts the notifications package — for example a WebhookDeliveryFailureThresholdExceededEto raised in Granit.Webhooks when the webhooks module and its notifications module both deploy in the API host.

  • Subscribe via the local event bus (ILocalEventBus).
  • Prefer a *Event (IDomainEvent) over an *Eto so you don’t pay the outbox hop or the JSON round-trip.
  • Ordering and observability are easier — no queue lag.

A template is just an HTML fragment with Scriban placeholders. The neutral file is English; per-culture variants override it.

<title>Your data has been deleted</title>
<p>Hi,</p>
<p>We confirm that your personal data has been permanently deleted on
<strong>{{ model.executed_at | date.to_string '%B %d, %Y at %H:%M UTC' }}</strong>.</p>
<p>Reference: <code>{{ model.request_id }}</code></p>
{{ if privacy.dpo_email }}
<p>Contact our DPO at
<a href="mailto:{{ privacy.dpo_email }}">{{ privacy.dpo_email }}</a>.</p>
{{ end }}
VariableSourceNotes
{{ model.* }}The notification data recordField names rendered snake_case via StandardMemberRenamer (MissingProvidersDisplaymissing_providers_display)
{{ privacy.* }}PrivacyContactGlobalContext (when Granit.Privacy.Notifications is loaded)Controller name/email, DPO email, supervisory authority URL
{{ now.* }}Time providernow.utc_now, now.local_now
{{ context.* }}Notification dispatch contextRecipient locale, channel name, tenant id
{{ app.* }}Host-provided global contextBranding (app.name, app.support_email, app.logo_url)

Each module that ships its own global context registers it via AddTemplateGlobalContext<TContext>() in its Granit{Module}NotificationsModule.

The framework ships EN + FR for every package out of the box. Additional cultures (de, nl, es, it, pt, zh, ja, pl, tr, ko, sv, cs, hi and the regional fr-CA, en-GB, pt-BR) are produced by scripts/translate-templates.py (epic #1306, Feature A0). Generated files carry a marker on the first line:

<!-- AUTO-TRANSLATED (claude-sonnet-4-5, 2026-04-15) — REVIEW BEFORE PRODUCTION -->
<title>Vos données ont été supprimées</title>

The script validates that every {{ … }} placeholder survives the translation intact (no whitespace mutation, no Scriban directive renaming). Apps requiring legally validated wording override embedded templates at runtime via the Granit.Templating admin API.

The email channel serializes the data record to JSON and flattens it into a Scriban dictionary via JsonElementToDictionary (in src/Granit.Notifications.Email/Internal/EmailNotificationChannel.cs). Arrays round-trip as their JSON string representation and are NOT iterable in Scriban. A naive IReadOnlyList<string> field renders as ["Auditing","Files"] — useless in a template.

Workaround: ship a *Display (string CSV) companion field alongside any list:

public sealed record PrivacyExportFailedNotificationData(
Guid RequestId,
string ArchiveBlobReferenceId,
IReadOnlyList<string> MissingProviders, // kept for audit/programmatic consumers
string MissingProvidersDisplay, // template-friendly: string.Join(", ", MissingProviders)
DateTimeOffset RequestedAt,
string Regulation);

The handler builds the display value when constructing the data record:

new PrivacyExportFailedNotificationData(
evt.RequestId,
evt.ArchiveBlobReferenceId,
evt.MissingProviders,
string.Join(", ", evt.MissingProviders),
evt.RequestedAt,
evt.Regulation);

In the template, use the display field:

<p>The following providers timed out: {{ model.missing_providers_display }}.</p>

The original list stays available for audit logs and retry orchestration — nothing is lost.

7. Overriding embedded templates at runtime

Section titled “7. Overriding embedded templates at runtime”

Embedded templates ship as defaults; host apps override them through the Granit.Templating admin API. The DB-backed resolver runs at higher priority than the embedded one, so any Template row with the matching Name (and optionally Culture) wins.

This is the supported way for an application to customize wording without forking the framework — and the only practical way to publish legally reviewed copy alongside the AI-translated baseline.

Every package ships a theory test that asserts the embedded resource manifest is exactly what the package promised. A renamed file or a botched .csproj glob breaks notification rendering at runtime — better to fail in CI.

// tests/Granit.{Module}.Notifications.Tests/Templates/EmbeddedTemplatesTests.cs
public sealed class EmbeddedTemplatesTests
{
public static TheoryData<string> ExpectedTemplates() =>
[
"Templates.{module}.{name1}.html", // EN neutral
"Templates.{module}.{name1}.fr.html", // FR baseline
// … one row per (notification, culture) shipped
];
[Theory, MemberData(nameof(ExpectedTemplates))]
public void EachExpectedTemplate_IsEmbeddedInTheAssembly(string suffix)
{
Assembly assembly = typeof(Granit{Module}NotificationsModule).Assembly;
string fullResourceName = $"{assembly.GetName().Name}.{suffix}";
assembly.GetManifestResourceNames().ShouldContain(fullResourceName);
}
[Theory, MemberData(nameof(ExpectedTemplates))]
public void EachExpectedTemplate_IsNotEmpty(string suffix)
{
Assembly assembly = typeof(Granit{Module}NotificationsModule).Assembly;
string fullResourceName = $"{assembly.GetName().Name}.{suffix}";
using Stream? stream = assembly.GetManifestResourceStream(fullResourceName);
stream.ShouldNotBeNull();
stream.Length.ShouldBeGreaterThan(0);
}
}

Reference implementation: tests/Granit.Privacy.Notifications.Tests/Templates/EmbeddedTemplatesTests.cs.

A future architecture test (epic #1306, US #1329) will additionally assert that every notification type whose DefaultChannels include a text-rendered channel has a matching Templates.{Name}.html resource — turning this from a package-local pin into a framework-wide invariant.

Add tests/Granit.{Module}.Notifications.Tests to the infrastructure shard in .github/test-shards.json (where the rest of *.Notifications.* lives). Then run:

Terminal window
python3 scripts/generate-shard-filters.py

The pre-push hook does this automatically when .csproj or test-shards.json files change. Without registration, CI silently skips the project.

A *.Notifications package describes what should be sent; delivery gates decide whether to actually send it. Any service registered as INotificationDeliveryGate is consulted by the fan-out engine per (userId, notificationType, channel) tuple — a false return drops that single delivery attempt without aborting the publish.

The canonical gate is Granit.Presence.Notifications, which suppresses push channels (SignalR, Sse, Push, MobilePush) when the recipient’s effective presence is DoNotDisturb or Offline. Store-and-forward channels (InApp, Email, Sms, WhatsApp, Zulip) are not gated — the user reads them when they return. See the Presence module overview for the full contract, including the cross-pod backplane requirement.