Granit.Templating & DocumentGeneration
Granit.Templating provides a multi-stage rendering pipeline for text-based output (email, SMS, push notifications) while Granit.DocumentGeneration extends it with binary document generation (PDF via headless Chromium, Excel via ClosedXML). Templates are strongly typed, support culture fallback, and follow a full lifecycle with ISO 27001 audit trail (Draft, PendingReview, Published, Archived).
Package structure
Section titled “Package structure”DirectoryGranit.Templating/ Core pipeline: ITextTemplateRenderer, resolvers, engines, enrichers, global contexts, template store CQRS
- Granit.Templating.Scriban Scriban engine (sandboxed, no I/O or reflection), template caching, built-in global contexts
- Granit.Templating.EntityFrameworkCore EF Core persistence (TemplatingDbContext), HybridCache-backed store, StoreTemplateResolver
- Granit.Templating.Endpoints 16 admin endpoints (CRUD, preview, publish, categories, history, variables)
- Granit.Templating.Workflow Bridge to Granit.Workflow: FSM validation, approval routing, unified audit trail
DirectoryGranit.DocumentGeneration/ IDocumentGenerator facade, 2-stage pipeline (text render + binary conversion)
- Granit.DocumentGeneration.Pdf PuppeteerSharp headless Chromium, PdfRenderOptions, PDF/A-3b conversion
- Granit.DocumentGeneration.Excel ClosedXML engine, Base64-encoded XLSX templates, direct binary output
| Package | Role | Depends on |
|---|---|---|
Granit.Templating | Core pipeline: rendering, resolution, enrichment, store interfaces | — |
Granit.Templating.Scriban | Scriban template engine (sandboxed), now.* and context.* globals | Granit.Templating |
Granit.Templating.EntityFrameworkCore | EF Core store, StoreTemplateResolver (Priority=100), HybridCache | Granit.Templating, Granit.Persistence |
Granit.Templating.Endpoints | 16 admin Minimal API endpoints, Templates.Manage permission | Granit.Templating, Granit.Authorization |
Granit.Templating.Workflow | Workflow bridge: FSM validation, IWorkflowTransitionRecorder | Granit.Templating, Granit.Workflow |
Granit.DocumentGeneration | IDocumentGenerator facade, IDocumentRenderer abstraction | Granit.Templating |
Granit.DocumentGeneration.Pdf | PuppeteerSharp PDF renderer, IPdfAConverter | Granit.DocumentGeneration |
Granit.DocumentGeneration.Excel | ClosedXML ITemplateEngine (direct binary output) | Granit.Templating |
Dependency graph
Section titled “Dependency graph”graph TD
T[Granit.Templating]
TS[Granit.Templating.Scriban] --> T
TEF[Granit.Templating.EntityFrameworkCore] --> T
TEF --> P[Granit.Persistence]
TE[Granit.Templating.Endpoints] --> T
TE --> AUTH[Granit.Authorization]
TW[Granit.Templating.Workflow] --> T
TW --> W[Granit.Workflow]
DG[Granit.DocumentGeneration] --> T
DGP[Granit.DocumentGeneration.Pdf] --> DG
DGE[Granit.DocumentGeneration.Excel] --> T
[DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitTemplatingEndpointsModule), typeof(GranitDocumentGenerationPdfModule), typeof(GranitDocumentGenerationExcelModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitTemplatingEntityFrameworkCore(options => options.UseNpgsql(context.Configuration.GetConnectionString("Templating"))); }}Map the admin endpoints in Program.cs:
app.MapGranitTemplatingAdmin(options =>{ options.RoutePrefix = "api/v1/templates"; options.TagName = "Template Administration";});[DependsOn(typeof(GranitTemplatingScribanModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { // Register embedded HTML templates from the assembly context.Services.AddEmbeddedTemplates(typeof(AcmeTemplates).Assembly); }}No database, no admin UI. Templates are compiled into the assembly as embedded resources.
[DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitTemplatingWorkflowModule))]public class AppModule : GranitModule { }The Workflow bridge replaces NullTemplateTransitionHook with WorkflowTemplateTransitionHook,
enabling FSM-validated transitions and unified audit trail via IWorkflowTransitionRecorder.
Template rendering pipeline
Section titled “Template rendering pipeline”The text rendering pipeline executes three stages in sequence:
flowchart LR
subgraph "1. Enrich"
D[TData] --> E1["ITemplateDataEnricher #1"]
E1 --> E2["ITemplateDataEnricher #2"]
E2 --> ED[Enriched TData]
end
subgraph "2. Resolve"
ED --> R1["StoreTemplateResolver<br/>(Priority=100)"]
R1 -->|miss| R2["EmbeddedTemplateResolver<br/>(Priority=-100)"]
R1 -->|hit| TD[TemplateDescriptor]
R2 --> TD
end
subgraph "3. Render"
TD --> ENG["ITemplateEngine<br/>(Scriban)"]
ED --> ENG
GC["Global Contexts<br/>now.*, context.*"] --> ENG
ENG --> RC[RenderedTextResult]
end
Stage 1 — Enrichment. ITemplateDataEnricher<TData> instances run in ascending Order.
Each enricher returns a new immutable copy (record with expression) — the original data
is never mutated. Use enrichers to inject computed values (QR codes, aggregated totals,
remote blob URLs) without coupling that logic to the domain layer.
Stage 2 — Resolution. ITemplateResolver implementations are tried by descending Priority.
The resolver chain applies culture fallback: first (Name, "fr-BE"), then (Name, null).
Tenant scoping is the resolver’s responsibility.
| Resolver | Priority | Source |
|---|---|---|
StoreTemplateResolver | 100 | EF Core database (published revisions, HybridCache) |
EmbeddedTemplateResolver | -100 | Assembly embedded resources (code-level fallback) |
Stage 3 — Engine rendering. ITemplateEngine selects itself via CanRender(descriptor)
based on MIME type. Scriban handles text/html and text/plain. Global contexts are injected
under their ContextName namespace.
Declaring template types
Section titled “Declaring template types”// Text template (email, SMS, push)public static class AcmeTemplates{ public static readonly TextTemplateType<AppointmentReminderData> AppointmentReminder = new AppointmentReminderTemplateType();
private sealed class AppointmentReminderTemplateType : TextTemplateType<AppointmentReminderData> { public override string Name => "Acme.AppointmentReminder"; }}
public sealed record AppointmentReminderData( string PatientName, DateTimeOffset AppointmentDate, string DoctorName, string ClinicAddress);Rendering a text template
Section titled “Rendering a text template”public sealed class AppointmentReminderService( ITextTemplateRenderer renderer, IEmailSender emailSender){ public async Task SendReminderAsync( Patient patient, Appointment appointment, CancellationToken cancellationToken) { var data = new AppointmentReminderData( PatientName: patient.FullName, AppointmentDate: appointment.ScheduledAt, DoctorName: appointment.Doctor.FullName, ClinicAddress: appointment.Location.Address);
RenderedTextResult result = await renderer .RenderAsync(AcmeTemplates.AppointmentReminder, data, cancellationToken) .ConfigureAwait(false);
await emailSender .SendAsync(patient.Email, subject: "Appointment reminder", html: result.Html, cancellationToken) .ConfigureAwait(false); }}Data enrichment
Section titled “Data enrichment”public sealed class PaymentQrCodeEnricher : ITemplateDataEnricher<InvoiceDocumentData>{ public int Order => 10;
public Task<InvoiceDocumentData> EnrichAsync( InvoiceDocumentData data, CancellationToken cancellationToken = default) { using QRCodeGenerator generator = new(); QRCodeData qrData = generator.CreateQrCode( data.PaymentUrl, QRCodeGenerator.ECCLevel.M); SvgQRCode svg = new(qrData);
return Task.FromResult(data with { PaymentQrCodeSvg = svg.GetGraphic(5) }); }}Register enrichers in the DI container:
services.AddTransient<ITemplateDataEnricher<InvoiceDocumentData>, PaymentQrCodeEnricher>();Global contexts
Section titled “Global contexts”Global contexts inject ambient data into every template under a named variable.
Two are registered by default when using Granit.Templating.Scriban:
| Namespace | Variable | Example output |
|---|---|---|
now | {{ now.date }} | 27/02/2026 |
now | {{ now.datetime }} | 27/02/2026 14:35 |
now | {{ now.iso }} | 2026-02-27T14:35:00+00:00 |
now | {{ now.year }} | 2026 |
now | {{ now.month }} | 02 |
now | {{ now.time }} | 14:35 |
context | {{ context.culture }} | fr-BE |
context | {{ context.culture_name }} | fran\u00e7ais (Belgique) |
context | {{ context.tenant_id }} | 3fa85f64-... or empty |
context | {{ context.tenant_name }} | Acme Corp or empty |
Custom global context
Section titled “Custom global context”public sealed class AcmeBrandingContext : ITemplateGlobalContext{ public string ContextName => "brand";
public object Resolve() => new { logo_url = "https://cdn.acme.com/logo.svg", primary_color = "#1A73E8", };}
// Registrationservices.AddSingleton<ITemplateGlobalContext, AcmeBrandingContext>();Template usage: {{ brand.logo_url }}, {{ brand.primary_color }}.
Scriban sandboxing
Section titled “Scriban sandboxing”The Scriban engine runs in strict sandboxed mode:
EnableRelaxedMemberAccess = false— no access to .NET internals- No I/O, reflection, or network access from templates
- Snake_case property mapping —
PatientNamebecomes{{ model.patient_name }} - Template caching — parsed
Templateobjects are cached byRevisionId(thread-safe) - CancellationToken propagation — long-running templates can be cancelled
Document generation pipeline
Section titled “Document generation pipeline”IDocumentGenerator extends the text pipeline with a binary conversion step:
flowchart LR
subgraph "Text Pipeline"
DATA[TData] --> ENRICH[Enrichers]
ENRICH --> RESOLVE[Resolver Chain]
RESOLVE --> ENGINE[ITemplateEngine]
end
ENGINE -->|TextRenderedContent| RENDERER["IDocumentRenderer<br/>(PuppeteerSharp)"]
ENGINE -->|BinaryRenderedContent| RESULT
RENDERER --> RESULT[DocumentResult]
Two paths through the pipeline:
| Path | Engine output | Next step | Example |
|---|---|---|---|
| HTML-based | TextRenderedContent | IDocumentRenderer converts HTML to binary | Invoice PDF (Scriban HTML → Chromium PDF) |
| Native binary | BinaryRenderedContent | Direct output, no renderer needed | Excel report (ClosedXML XLSX) |
Declaring a document template type
Section titled “Declaring a document template type”public sealed class InvoiceTemplateType : DocumentTemplateType<InvoiceDocumentData>{ public override string Name => "Billing.Invoice"; public override DocumentFormat DefaultFormat => DocumentFormat.Pdf;}
public sealed record InvoiceDocumentData( string InvoiceNumber, DateTimeOffset InvoiceDate, string CustomerName, string CustomerAddress, IReadOnlyList<InvoiceLineItem> Lines, decimal TotalExclVat, decimal VatAmount, decimal TotalInclVat, string PaymentUrl, string? PaymentQrCodeSvg = null);Generating a PDF document
Section titled “Generating a PDF document”public sealed class InvoiceService(IDocumentGenerator generator){ public async Task<DocumentResult> GenerateInvoicePdfAsync( InvoiceDocumentData data, CancellationToken cancellationToken) { DocumentResult result = await generator .GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken) .ConfigureAwait(false);
// result.Content contains PDF bytes // result.Format == DocumentFormat.Pdf return result; }}Generating an Excel document
Section titled “Generating an Excel document”Excel templates use Base64-encoded XLSX workbooks stored as template content.
Placeholders ({{model.property}}) in string cells are replaced with data values.
Nested objects use dot notation ({{model.address.city}}), arrays use bracket notation
({{model.lines[0].amount}}).
public sealed class MonthlyReportTemplateType : DocumentTemplateType<MonthlyReportData>{ public override string Name => "Reporting.MonthlyReport"; public override DocumentFormat DefaultFormat => DocumentFormat.Excel;}The ClosedXmlTemplateEngine returns BinaryRenderedContent directly — no
IDocumentRenderer is needed for Excel output.
PDF/A-3b conversion
Section titled “PDF/A-3b conversion”For long-term archival and electronic invoicing (Factur-X / ZUGFeRD EN 16931),
use IPdfAConverter to convert standard PDFs to PDF/A-3b:
public async Task<DocumentResult> GenerateArchivalInvoiceAsync( InvoiceDocumentData data, IPdfAConverter pdfAConverter, IDocumentGenerator generator, CancellationToken cancellationToken){ DocumentResult pdf = await generator .GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken) .ConfigureAwait(false);
return await pdfAConverter .ConvertToPdfAAsync(pdf, new PdfAConversionOptions(), cancellationToken) .ConfigureAwait(false);}Template lifecycle
Section titled “Template lifecycle”Templates stored in the EF Core store follow a strict lifecycle:
stateDiagram-v2
[*] --> Draft : SaveDraftAsync
Draft --> PendingReview : Workflow bridge<br/>(optional)
PendingReview --> Published : Approve
PendingReview --> Draft : Reject
Draft --> Published : PublishAsync<br/>(without Workflow)
Published --> Archived : New version published<br/>or UnpublishAsync
Archived --> [*] : Preserved forever<br/>(ISO 27001)
| Status | Description | Deletable? |
|---|---|---|
Draft | Being edited. Not visible to the rendering pipeline. Only one draft per key. | Yes |
PendingReview | Submitted for approval. Only with Granit.Templating.Workflow. | No |
Published | Active version. Exactly one per TemplateKey. Resolved by StoreTemplateResolver. | No |
Archived | Superseded by a newer publication. Preserved indefinitely for ISO 27001 audit trail. | No |
Lifecycle operations
Section titled “Lifecycle operations”| Operation | Method | Effect |
|---|---|---|
| Save draft | IDocumentTemplateStoreWriter.SaveDraftAsync | Creates or replaces the draft for a key |
| Publish | IDocumentTemplateStoreWriter.PublishAsync | Promotes draft to Published, archives previous |
| Unpublish | IDocumentTemplateStoreWriter.UnpublishAsync | Archives the published revision |
| Delete draft | IDocumentTemplateStoreWriter.DeleteDraftAsync | Physically deletes the draft (drafts only) |
Workflow bridge
Section titled “Workflow bridge”Without the Granit.Templating.Workflow package, transitions are direct
(Draft → Published → Archived) via NullTemplateTransitionHook. Installing the
Workflow bridge replaces this with WorkflowTemplateTransitionHook, which:
- Validates transitions via
IWorkflowManager<WorkflowLifecycleStatus>FSM - Records transitions via
IWorkflowTransitionRecorderfor unified ISO 27001 audit trail - Supports approval routing (Draft → PendingReview → Published)
Resolver chain
Section titled “Resolver chain”Templates are resolved by trying resolvers in descending priority order:
| Priority | Resolver | Source | Package |
|---|---|---|---|
| 100 | StoreTemplateResolver | EF Core database (published revisions only) | Granit.Templating.EntityFrameworkCore |
| -100 | EmbeddedTemplateResolver | Assembly embedded resources | Granit.Templating |
Culture fallback strategy (per resolver):
(Name, Culture)— e.g.("Acme.Invoice", "fr-BE")(Name, null)— culture-neutral fallback
Tenant scoping is handled inside each resolver. The store-backed resolver looks for a tenant-scoped template first, then falls back to the host-level one.
Embedded template resource naming
Section titled “Embedded template resource naming”| Culture | Resource name |
|---|---|
Specific ("fr") | {AssemblyName}.Templates.{TemplateName}.fr.html |
| Neutral (fallback) | {AssemblyName}.Templates.{TemplateName}.html |
Register embedded templates:
services.AddEmbeddedTemplates(typeof(AcmeTemplates).Assembly);Admin endpoints
Section titled “Admin endpoints”16 endpoints are registered via MapGranitTemplatingAdmin(). All require the
Templates.Manage permission.
Template endpoints
Section titled “Template endpoints”| Method | Route | Name | Description |
|---|---|---|---|
GET | / | ListTemplates | Paginated list with filters (status, culture, category, search) |
GET | /{name} | GetTemplateDetail | Draft + published detail for a template |
POST | / | CreateTemplateDraft | Create a new template draft |
PUT | /{name} | UpdateTemplateDraft | Update an existing draft |
DELETE | /{name}/draft | DeleteTemplateDraft | Delete draft only (published/archived preserved) |
POST | /{name}/publish | PublishTemplate | Publish the current draft |
POST | /{name}/unpublish | UnpublishTemplate | Archive the published revision |
GET | /{name}/lifecycle | GetTemplateLifecycle | Lifecycle status + available transitions |
POST | /{name}/preview | PreviewTemplate | Render draft with test data, returns HTML |
GET | /{name}/variables | GetTemplateVariables | Available variables for autocompletion |
GET | /{name}/history | GetTemplateHistory | Paginated revision history (summaries) |
GET | /{name}/history/{revisionId} | GetTemplateRevisionDetail | Full detail of a specific revision |
Category endpoints
Section titled “Category endpoints”| Method | Route | Name | Description |
|---|---|---|---|
GET | /categories | ListTemplateCategories | All categories ordered by sort order |
POST | /categories | CreateTemplateCategory | Create a new category |
PUT | /categories/{id} | UpdateTemplateCategory | Update a category |
DELETE | /categories/{id} | DeleteTemplateCategory | Delete a category (409 if templates associated) |
Configuration reference
Section titled “Configuration reference”PDF renderer
Section titled “PDF renderer”Bound from configuration section DocumentGeneration:Pdf:
{ "DocumentGeneration": { "Pdf": { "PaperFormat": "A4", "Landscape": false, "MarginTop": "10mm", "MarginBottom": "10mm", "MarginLeft": "10mm", "MarginRight": "10mm", "HeaderTemplate": null, "FooterTemplate": "<div style='font-size:9px;text-align:center;width:100%'><span class='pageNumber'></span>/<span class='totalPages'></span></div>", "PrintBackground": true, "ChromiumExecutablePath": null, "MaxConcurrentPages": 4 } }}| Property | Default | Description |
|---|---|---|
PaperFormat | "A4" | Paper size ("A4", "A5", "Letter") |
Landscape | false | Landscape orientation |
MarginTop / Bottom / Left / Right | "10mm" | Margins in CSS units |
HeaderTemplate | null | HTML header (supports pageNumber, totalPages classes) |
FooterTemplate | null | HTML footer (same classes as header) |
PrintBackground | true | Print background graphics |
ChromiumExecutablePath | null | Custom Chromium path (auto-download if null) |
MaxConcurrentPages | 4 | Max parallel Chromium tabs (1—32) |
Endpoints
Section titled “Endpoints”app.MapGranitTemplatingAdmin(options =>{ options.RoutePrefix = "api/v1/templates"; // default: "templates" options.TagName = "Template Administration"; // default: "Templates"});Scriban template example
Section titled “Scriban template example”A discharge letter template stored in the EF Core store or as an embedded resource:
<h1>Discharge Summary</h1><p>Date: {{ now.date }}</p><p>Dear {{ model.patient_name }},</p><p> You were admitted on {{ model.admission_date }} and discharged on {{ model.discharge_date }} from the {{ model.ward_name }} ward.</p><h2>Diagnosis</h2><p>{{ model.primary_diagnosis }}</p><h2>Follow-up Instructions</h2><ul> {{ for instruction in model.follow_up_instructions }} <li>{{ instruction }}</li> {{ end }}</ul><p style="font-size: 0.8em; color: #666;"> Culture: {{ context.culture }} | Generated: {{ now.datetime }}</p>Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitTemplatingModule, GranitTemplatingScribanModule, GranitTemplatingEntityFrameworkCoreModule, GranitTemplatingEndpointsModule, GranitTemplatingWorkflowModule | — |
| Module | GranitDocumentGenerationModule, GranitDocumentGenerationPdfModule, GranitDocumentGenerationExcelModule | — |
| Pipeline | ITextTemplateRenderer, ITemplateResolver, ITemplateEngine | Granit.Templating |
| Pipeline | TemplateDescriptor, RenderedContent, TextRenderedContent, BinaryRenderedContent, RenderedTextResult | Granit.Templating |
| Keys | TemplateType<TData>, TextTemplateType<TData>, TemplateKey, DocumentFormat | Granit.Templating |
| Keys | DocumentTemplateType<TData> | Granit.DocumentGeneration |
| Enrichment | ITemplateDataEnricher<TData> | Granit.Templating |
| Global context | ITemplateGlobalContext | Granit.Templating |
| Store (read) | IDocumentTemplateStoreReader, ITemplateCategoryStoreReader | Granit.Templating |
| Store (write) | IDocumentTemplateStoreWriter, ITemplateCategoryStoreWriter | Granit.Templating |
| Lifecycle | TemplateLifecycleStatus, ITemplateTransitionHook, TemplateRevision | Granit.Templating |
| Document gen | IDocumentGenerator, IDocumentRenderer, DocumentResult | Granit.DocumentGeneration |
PdfRenderOptions, IPdfAConverter, PdfAConversionOptions | Granit.DocumentGeneration.Pdf | |
| Permissions | TemplatingPermissions.Manage ("Templates.Manage") | Granit.Templating.Endpoints |
| Extensions | AddGranitTemplating(), AddGranitTemplatingWithScriban(), AddEmbeddedTemplates() | — |
| Extensions | AddGranitDocumentGeneration(), AddGranitDocumentGenerationPdf(), AddGranitDocumentGenerationExcel() | — |
| Extensions | MapGranitTemplatingAdmin() | Granit.Templating.Endpoints |
See also
Section titled “See also”- Core module — Module system, domain base types
- Persistence module —
AuditedEntityInterceptor,ApplyGranitConventions - Wolverine module — Durable messaging for async document generation
- Notifications module — Email, SMS, push channels that consume rendered templates
- Background Jobs module — Schedule document generation as background jobs
- API Reference (auto-generated from XML docs)