Nested Templates
Template strings, multi-pass resolution, depth tracking, and cycle prevention.
Contents
Single Expression vs Template String
The parser classifies a field value into one of three cases:
| Case | Example | Handling |
|---|---|---|
| No wildcard | "hello world" |
Short-circuit — returned as-is, no evaluation |
| Single expression | "{@ $var.name }" |
Parse once → single directive invocation → return value |
| Template string | "Hello {@ $ctx.user.name }, order {@ $var.id } is ready" |
FindAllRegions → parallel evaluation → string assembly → multi-pass if needed |
IWildcardParser.IsTemplate(text) returns true when: (a) there are two or more expressions, or (b) there is literal text outside the expression boundary.
Parallel Interpolation
When a template string is detected, all regions are evaluated simultaneously:
// Field value:
"Dear {@ $ctx.user.name }, your balance is {@ $var.balance @json } as of {@ $ctx.today }"
// Step 1: FindAllRegions() → 3 regions
// Step 2: Task.WhenAll — all 3 evaluated concurrently
// Region 0: $ctx.user.name → "Alice"
// Region 1: $var.balance → 1250.50
// Region 2: $ctx.today → "2024-03-15"
// Step 3: Substitute FormattedValues
// Result: "Dear Alice, your balance is 1250.5 as of 2024-03-15"
Task.WhenAll. If your expressions are independently resolvable (most are), this is free parallelism. The order of substitution is determined by region positions, not evaluation order.
Multi-Pass Resolution
After assembling the interpolated string, the orchestrator scans it again for wildcards. If new wildcards are found (because a resolved value itself contained an expression), it re-enters the pipeline:
// Pass 1:
"Recipient: {@ @report.recipient }"
// @report.recipient → "{@ $ctx.tenant.billingEmail }" (this alias IS an expression)
// Assembled: "Recipient: {@ $ctx.tenant.billingEmail }"
// Scan → new wildcards found → Pass 2:
"Recipient: {@ $ctx.tenant.billingEmail }"
// $ctx.tenant.billingEmail → "billing@acme.com"
// Assembled: "Recipient: billing@acme.com"
// Scan → no wildcards → done
// Final result: "Recipient: billing@acme.com"
Multi-pass continues as long as new wildcards are found AND Depth < 10. Each pass increments depth by 1.
VisitedKeys — Preventing Cycles
The VisitedKeys set is shared across all passes. Each expression's unique key is added before evaluation. If it's already in the set, a cycle is detected:
// Scenario: canned expression loop
// @loopA → "{@ @loopB }"
// @loopB → "{@ @loopA }"
// Pass 1: evaluate @loopA → key "loopA" added to VisitedKeys
// Result: "{@ @loopB }"
// Pass 2: evaluate @loopB → key "loopB" added
// Result: "{@ @loopA }"
// Pass 3: @loopA → "loopA" already in VisitedKeys → CycleDetected error
Depth Tracking Across Passes
Both multi-pass interpolation and directive chaining share the same Depth counter on the EvaluationRequest:
- Each multi-pass iteration increments depth by 1
- Each
ChainExpressionAsyncorChainToDirectiveAsynccall increments depth by 1 - Hard limit is 10 total across both
This prevents the combination of chaining + multi-pass from bypassing the depth limit:
// 5 levels of canned expression chaining (depth 0→5)
// + 5 multi-pass iterations (depth 5→10)
// = hit depth limit at exactly 10
$tpl — Liquid Template Directive
The $tpl directive is a specialized case of template rendering. It uses the Fluid (Liquid) library and handles its own internal variable substitution — it does NOT re-enter the BizFirst expression pipeline for Liquid {{ variable }} references. Instead, it populates the Liquid context from EvaluationContext:
| Liquid variable | Source |
|---|---|
{{ user.name }} | Context.CurrentUser.Name |
{{ tenant.name }} | Context.TenantId → metadata provider |
{{ tenant.timezone }} | Tenant metadata |
{{ exec.executionId }} | Context.ExecutionId |
{{ input.fieldName }} | Context.NodeExecutionData.CurrentItem |
{{ vars.myVar }} | Context.Memory |
{{ now }} | DateTime.UtcNow |
{@ $var.x }) and Liquid expressions ({{ x }}) are different syntaxes processed by different engines. A $tpl directive renders Liquid — the result is a plain string. If that string needs further BizFirst expression evaluation, it would require another pass through the orchestrator.
Sample Walkthroughs
Sample 1 — Email Subject with Dynamic Values
// Field: Email Subject
"[{@ $ctx.tenant.name }] Invoice #{@ $var.invoiceNumber } — {@ $var.amount @default:0 } {@ @company.currency }"
// Regions found: 4
// Parallel evaluation:
// $ctx.tenant.name → "Acme Corp"
// $var.invoiceNumber → "INV-2024-001"
// $var.amount → 4500
// @company.currency → "EUR" (via canned expression → $ctx.tenant.currency)
// Assembled: "[Acme Corp] Invoice #INV-2024-001 — 4500 EUR"
// Scan → no new wildcards → done (1 pass)
Sample 2 — Nested Canned Expression (2 passes)
// Canned: @hr.approver → "{@ @tenant.hrManager }"
// Canned: @tenant.hrManager → "{@ $ctx.tenant.hrManagerEmail }"
// Field: "Approval required from: @hr.approver"
// Pass 1: @hr.approver → "{@ @tenant.hrManager }"
// Assembled: "Approval required from: {@ @tenant.hrManager }"
// Scan → wildcard found → Pass 2
// Pass 2: @tenant.hrManager → "{@ $ctx.tenant.hrManagerEmail }"
// Assembled: "Approval required from: {@ $ctx.tenant.hrManagerEmail }"
// Scan → wildcard found → Pass 3
// Pass 3: $ctx.tenant.hrManagerEmail → "hr@acme.com"
// Assembled: "Approval required from: hr@acme.com"
// Scan → no wildcards → done (3 passes, depth 3)
Sample 3 — $tpl with Context Data
// Node field: "{@ $tpl.InvoiceEmail }"
// $tpl directive:
// 1. Loads template "InvoiceEmail" from database
// 2. Builds Liquid context from EvaluationContext
// 3. Renders Liquid → plain HTML string
// 4. Returns EvaluationResponse.Success(htmlString)
// Liquid template stored in DB:
// ---
// Dear {{ user.name }},
//
// Your invoice {{ vars.invoiceNumber }} for {{ vars.amount | money }} is ready.
// Payment due: {{ vars.dueDate | date: "%B %d, %Y" }}
// ---
// Result: fully rendered HTML email body (single BizFirst evaluation pass)