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.
When to build a *.Notifications package
Section titled “When to build a *.Notifications package”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.
File layout
Section titled “File layout”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
1. csproj
Section titled “1. csproj”<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>2. The module class
Section titled “2. The module class”[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>(); }}3. Notification types
Section titled “3. Notification types”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);| Field | Convention |
|---|---|
Name | snake_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. |
Instance | Singleton — handlers reference it directly when calling INotificationPublisher.PublishAsync(...). |
DefaultChannels | Use NotificationChannels.* constants. Each channel listed here MUST have a corresponding renderer (e.g. Email → template). |
DefaultSeverity | Info (default), Warning, Error. Drives styling in the in-app inbox. |
| Data record | Same 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*Etoso you don’t pay the outbox hop or the JSON round-trip. - Ordering and observability are easier — no queue lag.
The trigger flows from a different microservice via the bus — for example a
notifications worker consuming IdentityTokenExchangedEto produced by the
identity service.
- Configure the queue/topic routing in the host’s Wolverine setup.
- Stick with
*Eto(IIntegrationEvent) semantics; keep the contract stable. - Tag the consumer with the saga id when triggering follow-up workflows.
5. Templates
Section titled “5. Templates”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 }}Variables available in templates
Section titled “Variables available in templates”| Variable | Source | Notes |
|---|---|---|
{{ model.* }} | The notification data record | Field names rendered snake_case via StandardMemberRenamer (MissingProvidersDisplay → missing_providers_display) |
{{ privacy.* }} | PrivacyContactGlobalContext (when Granit.Privacy.Notifications is loaded) | Controller name/email, DPO email, supervisory authority URL |
{{ now.* }} | Time provider | now.utc_now, now.local_now |
{{ context.* }} | Notification dispatch context | Recipient locale, channel name, tenant id |
{{ app.* }} | Host-provided global context | Branding (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.
Cultures shipped by the framework
Section titled “Cultures shipped by the framework”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.
6. The JSON-array gotcha
Section titled “6. The JSON-array gotcha”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.
8. Manifest pinning test
Section titled “8. Manifest pinning test”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.cspublic 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.
9. CI test sharding
Section titled “9. CI test sharding”Add tests/Granit.{Module}.Notifications.Tests to the infrastructure shard
in .github/test-shards.json
(where the rest of *.Notifications.* lives). Then run:
python3 scripts/generate-shard-filters.pyThe pre-push hook does this automatically when .csproj or test-shards.json
files change. Without registration, CI silently skips the project.
See also
Section titled “See also”- Notifications overview — fan-out engine, channels, dependency graph
- Email channel — provider configuration (SMTP, SES, SendGrid…)
- Wolverine integration — durable outbox dispatch
Granit.Templating— embedded vs DB resolver, layouts, global contexts- Reference packages on GitHub:
Granit.Privacy.Notifications·Granit.Identity.Local.Notifications