Skip to content

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).

  • 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
PackageRoleDepends on
Granit.TemplatingCore pipeline: rendering, resolution, enrichment, store interfaces
Granit.Templating.ScribanScriban template engine (sandboxed), now.* and context.* globalsGranit.Templating
Granit.Templating.EntityFrameworkCoreEF Core store, StoreTemplateResolver (Priority=100), HybridCacheGranit.Templating, Granit.Persistence
Granit.Templating.Endpoints16 admin Minimal API endpoints, Templates.Manage permissionGranit.Templating, Granit.Authorization
Granit.Templating.WorkflowWorkflow bridge: FSM validation, IWorkflowTransitionRecorderGranit.Templating, Granit.Workflow
Granit.DocumentGenerationIDocumentGenerator facade, IDocumentRenderer abstractionGranit.Templating
Granit.DocumentGeneration.PdfPuppeteerSharp PDF renderer, IPdfAConverterGranit.DocumentGeneration
Granit.DocumentGeneration.ExcelClosedXML ITemplateEngine (direct binary output)Granit.Templating
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";
});

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.

ResolverPrioritySource
StoreTemplateResolver100EF Core database (published revisions, HybridCache)
EmbeddedTemplateResolver-100Assembly 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.

// 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);
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);
}
}
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 inject ambient data into every template under a named variable. Two are registered by default when using Granit.Templating.Scriban:

NamespaceVariableExample 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
public sealed class AcmeBrandingContext : ITemplateGlobalContext
{
public string ContextName => "brand";
public object Resolve() => new
{
logo_url = "https://cdn.acme.com/logo.svg",
primary_color = "#1A73E8",
};
}
// Registration
services.AddSingleton<ITemplateGlobalContext, AcmeBrandingContext>();

Template usage: {{ brand.logo_url }}, {{ brand.primary_color }}.

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 mappingPatientName becomes {{ model.patient_name }}
  • Template caching — parsed Template objects are cached by RevisionId (thread-safe)
  • CancellationToken propagation — long-running templates can be cancelled

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:

PathEngine outputNext stepExample
HTML-basedTextRenderedContentIDocumentRenderer converts HTML to binaryInvoice PDF (Scriban HTML → Chromium PDF)
Native binaryBinaryRenderedContentDirect output, no renderer neededExcel report (ClosedXML XLSX)
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);
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;
}
}

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.

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);
}

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)
StatusDescriptionDeletable?
DraftBeing edited. Not visible to the rendering pipeline. Only one draft per key.Yes
PendingReviewSubmitted for approval. Only with Granit.Templating.Workflow.No
PublishedActive version. Exactly one per TemplateKey. Resolved by StoreTemplateResolver.No
ArchivedSuperseded by a newer publication. Preserved indefinitely for ISO 27001 audit trail.No
OperationMethodEffect
Save draftIDocumentTemplateStoreWriter.SaveDraftAsyncCreates or replaces the draft for a key
PublishIDocumentTemplateStoreWriter.PublishAsyncPromotes draft to Published, archives previous
UnpublishIDocumentTemplateStoreWriter.UnpublishAsyncArchives the published revision
Delete draftIDocumentTemplateStoreWriter.DeleteDraftAsyncPhysically deletes the draft (drafts only)

Without the Granit.Templating.Workflow package, transitions are direct (Draft → Published → Archived) via NullTemplateTransitionHook. Installing the Workflow bridge replaces this with WorkflowTemplateTransitionHook, which:

  1. Validates transitions via IWorkflowManager<WorkflowLifecycleStatus> FSM
  2. Records transitions via IWorkflowTransitionRecorder for unified ISO 27001 audit trail
  3. Supports approval routing (Draft → PendingReview → Published)

Templates are resolved by trying resolvers in descending priority order:

PriorityResolverSourcePackage
100StoreTemplateResolverEF Core database (published revisions only)Granit.Templating.EntityFrameworkCore
-100EmbeddedTemplateResolverAssembly embedded resourcesGranit.Templating

Culture fallback strategy (per resolver):

  1. (Name, Culture) — e.g. ("Acme.Invoice", "fr-BE")
  2. (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.

CultureResource name
Specific ("fr"){AssemblyName}.Templates.{TemplateName}.fr.html
Neutral (fallback){AssemblyName}.Templates.{TemplateName}.html

Register embedded templates:

services.AddEmbeddedTemplates(typeof(AcmeTemplates).Assembly);

16 endpoints are registered via MapGranitTemplatingAdmin(). All require the Templates.Manage permission.

MethodRouteNameDescription
GET/ListTemplatesPaginated list with filters (status, culture, category, search)
GET/{name}GetTemplateDetailDraft + published detail for a template
POST/CreateTemplateDraftCreate a new template draft
PUT/{name}UpdateTemplateDraftUpdate an existing draft
DELETE/{name}/draftDeleteTemplateDraftDelete draft only (published/archived preserved)
POST/{name}/publishPublishTemplatePublish the current draft
POST/{name}/unpublishUnpublishTemplateArchive the published revision
GET/{name}/lifecycleGetTemplateLifecycleLifecycle status + available transitions
POST/{name}/previewPreviewTemplateRender draft with test data, returns HTML
GET/{name}/variablesGetTemplateVariablesAvailable variables for autocompletion
GET/{name}/historyGetTemplateHistoryPaginated revision history (summaries)
GET/{name}/history/{revisionId}GetTemplateRevisionDetailFull detail of a specific revision
MethodRouteNameDescription
GET/categoriesListTemplateCategoriesAll categories ordered by sort order
POST/categoriesCreateTemplateCategoryCreate a new category
PUT/categories/{id}UpdateTemplateCategoryUpdate a category
DELETE/categories/{id}DeleteTemplateCategoryDelete a category (409 if templates associated)

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
}
}
}
PropertyDefaultDescription
PaperFormat"A4"Paper size ("A4", "A5", "Letter")
LandscapefalseLandscape orientation
MarginTop / Bottom / Left / Right"10mm"Margins in CSS units
HeaderTemplatenullHTML header (supports pageNumber, totalPages classes)
FooterTemplatenullHTML footer (same classes as header)
PrintBackgroundtruePrint background graphics
ChromiumExecutablePathnullCustom Chromium path (auto-download if null)
MaxConcurrentPages4Max parallel Chromium tabs (1—32)
app.MapGranitTemplatingAdmin(options =>
{
options.RoutePrefix = "api/v1/templates"; // default: "templates"
options.TagName = "Template Administration"; // default: "Templates"
});

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>
CategoryKey typesPackage
ModuleGranitTemplatingModule, GranitTemplatingScribanModule, GranitTemplatingEntityFrameworkCoreModule, GranitTemplatingEndpointsModule, GranitTemplatingWorkflowModule
ModuleGranitDocumentGenerationModule, GranitDocumentGenerationPdfModule, GranitDocumentGenerationExcelModule
PipelineITextTemplateRenderer, ITemplateResolver, ITemplateEngineGranit.Templating
PipelineTemplateDescriptor, RenderedContent, TextRenderedContent, BinaryRenderedContent, RenderedTextResultGranit.Templating
KeysTemplateType<TData>, TextTemplateType<TData>, TemplateKey, DocumentFormatGranit.Templating
KeysDocumentTemplateType<TData>Granit.DocumentGeneration
EnrichmentITemplateDataEnricher<TData>Granit.Templating
Global contextITemplateGlobalContextGranit.Templating
Store (read)IDocumentTemplateStoreReader, ITemplateCategoryStoreReaderGranit.Templating
Store (write)IDocumentTemplateStoreWriter, ITemplateCategoryStoreWriterGranit.Templating
LifecycleTemplateLifecycleStatus, ITemplateTransitionHook, TemplateRevisionGranit.Templating
Document genIDocumentGenerator, IDocumentRenderer, DocumentResultGranit.DocumentGeneration
PDFPdfRenderOptions, IPdfAConverter, PdfAConversionOptionsGranit.DocumentGeneration.Pdf
PermissionsTemplatingPermissions.Manage ("Templates.Manage")Granit.Templating.Endpoints
ExtensionsAddGranitTemplating(), AddGranitTemplatingWithScriban(), AddEmbeddedTemplates()
ExtensionsAddGranitDocumentGeneration(), AddGranitDocumentGenerationPdf(), AddGranitDocumentGenerationExcel()
ExtensionsMapGranitTemplatingAdmin()Granit.Templating.Endpoints