UniversalTemplate supports Notification, Reporting, Document, Integration, and Audit scenarios. One domain object groups root metadata, retained version history, and variable declarations, while persistence splits them across three tenant tables. This is an explicit multi-table asymmetric exception, not a template for reintroducing a mapper set per aggregate.
1. Template identity and uniqueness
TemplateKey follows {Module}.{Scenario}.{EventType}. The database unique key is (TenantId, TemplateKey); Language, Channel, and OwnerModule are not part of it.
POST /api/templates HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "templateKey": "Tickets.Assignment.Changed.Email.zh-CN", "templateName": "Ticket assignment email", "templateType": "Notification", "templateCategory": "Transactional", "channelType": "Email", "templateEngine": "Scriban", "language": "zh-CN", "titleTemplate": "Ticket {{ ticket_number }} was assigned", "bodyTemplate": "<p>{{ assignee_name }}, respond before {{ due_at }}.</p>", "namingConvention": "SnakeCase", "ownerModule": "Notification", "description": "Chinese email for a Tickets assignment event"}With the current schema, locale and channel must be encoded in TemplateKey or the second variant conflicts. Identity seeds define zh-CN and en-US with the same key; the seed loop skips an existing key, so it stores only the first locale per scenario—not the documented eight templates.
A target resolver should make Locale+Channel explicit in either the unique key or columns and define exact locale → language fallback → tenant default → platform default.
2. Lifecycle state machine
Create generates and activates 1.0.0. Update changes TemplateName, appends a patch version, deactivates the prior active version, activates the new one, and writes UpdateTime. Activate moves the pointer to any historical version instead of creating a new version.
ActivateVersion does not update UpdateTime. Root IsActive and “has an active version” are separate states. SoftDelete sets root IsDeleted=true and IsActive=false while retaining version rows.
3. Version-number defect
GenerateNextVersionNumber sorts version strings lexicographically, then increments the final segment. After 1.0.10, 1.0.9 sorts above 1.0.10, so a later update can generate 1.0.10 again.
static SemanticVersion Next(IEnumerable<string> versions){ // Parse Major.Minor.Patch strictly; quarantine invalid history instead of guessing. var parsed = versions.Select(SemanticVersion.Parse).ToList(); var latest = parsed.Count == 0 ? new SemanticVersion(1, 0, -1) : parsed.Max();
// Automatic updates increment Patch; Major/Minor need explicit publish actions. return new SemanticVersion(latest.Major, latest.Minor, latest.Patch + 1);}The current version index is not unique. Add (TenantId,TemplateId,VersionNumber) uniqueness and test two concurrent Updates that compute the next version.
4. Three-table persistence
TemplateRepository assigns physical ids to the root and children, saves the root, then runs SyncVersions and SyncVariables. It expects the surrounding Command transaction to cover all three tables; a direct caller of SaveAsync must supply an equivalent boundary.
Version synchronization does not delete historical rows omitted by the aggregate. Variable synchronization does delete variables removed from the current aggregate. Every child read/write includes root TenantId+TemplateId.
GetById reads the root using TemplateId only and relies on IEntitySet’s global tenant filter. Background work, platform templates, and impersonation need an explicit tenant argument to avoid hidden context.
5. What variable declarations actually do
TemplateVariable stores VariableName, VariablePath, DataType, IsRequired, DefaultValue, Description, and ExampleValue. The aggregate supports AddVariable and name uniqueness, but no variable CRUD HTTP endpoint exists, and CreateTemplate accepts no variable list.
Identity seeds add variables internally. Ordinary administrators cannot do so through current HTTP. ImportAsync also ignores Variables from the JSON it reads and creates only a new 1.0.0 from active title/body. Variable rows are therefore not a guaranteed schema for all templates.
The renderer does not enforce aggregate Variables for type/default/required binding either. It analyzes template text and the submitted dictionary separately. Do not describe the variable table as a runtime strong-type contract.
6. Update and permission drift
UpdateTemplate.Command declares Description, but its handler and ITemplateManager.UpdateTemplateAsync omit it, so the value is never updated. A UI must not claim it was saved until the contract is repaired and covered.
TemplatePermissions defines View/Create/Update/Delete/Activate/Preview. Generated requests map as follows:
| Path | Current action | Effective permission semantics |
|---|---|---|
| create | Create | .create |
| update/activate | Update | .update; .activate unused |
| delete | Delete | .delete |
| get/search/versions/render/preview/validate | View | .view; .preview unused |
The current model cannot grant preview without general read or activation without edit.
7. Import/export is not round-trip
TemplateImportExportService has no HTTP endpoint. ExportAsync emits active content, variables, and all versions. ImportAsync deserializes them but calls only CreateTemplateAsync, ignoring dto.Variables and dto.Versions.
ExportAllAsync requests PageSize=int.MaxValue, but the repository clamps it to 100. Only the first 100 templates are exported, followed by N+1 GetById queries.
var exported = await service.ExportAsync(source.TemplateId, ct);var imported = await service.ImportAsync( exported.Value!, "tenant-target", "migration-tool", ct);
// Today only root summary and active content survive; history/variables do not.var target = await repository.GetByIdAsync(imported.Value!.TemplateId, ct);target.Value!.Versions.Count.ShouldBe(source.Versions.Count);target.Value.Variables.Count.ShouldBe(source.Variables.Count);
// These are intentional red assertions until round-trip behavior is implemented.Production import also needs schemaVersion, dry-run, signed source, conflict policy, validation of every historical version, transactional batches, and reconciliation reports.
8. Platform and tenant templates
Identity seeds shared templates with TenantId=PLATFORM. RenderAsync looks up an empty tenant and has no tenant-override/PLATFORM-fallback algorithm. Shared templates and tenant customization therefore lack a working inheritance model.
Define who may edit PLATFORM, key/locale/channel override rules, platform-update effects, cache isolation, safe fallback after override deletion, and audited publication/rollback.
9. No template-change events or approval stage
TemplateAppService explicitly emits no events. The compile cache uses template-text SHA256, so new text naturally gets a new entry; old entries live until cache expiry. Cross-service replication, approval, and publication audit have no event stream.
Commercial template governance usually needs Draft→Review→Published. Current Create and every Update immediately activate content. Update is effectively “edit and publish,” not draft editing.
10. Required tests
- Tenant+Key uniqueness, locale/channel variants, and casing;
- 1.0.9→1.0.10→1.0.11 and concurrent Update;
- auto-activation, activating old versions, and update/activate after deletion;
- actual Description persistence;
- one transaction across root/version/variable failures;
- cross-tenant child injection, TemplateId collisions, and global filters;
- variable duplicate/type/default/required runtime behavior;
- ExportAll with 101+ templates and full round-trip;
- both zh-CN/en-US seeds and locale resolution;
- PLATFORM fallback, tenant override, audit, and permission separation.