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). The framework already includes eight such packages (Granit.Privacy.Notifications, Granit.Identity.Local.Notifications, Granit.Invoicing.Notifications, …); 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>();
// Optional — expose definitions to the admin UI (preferences, channels, etc.).
// 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.

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.