How It Works
End-to-end pipeline: from a raw field string to a resolved, typed, formatted value.
Contents
System Overview
Every field value in a workflow node can optionally contain one or more expressions. The framework detects these, resolves them at runtime using the correct directive, applies any requested transformations, and returns the final value to the node executor.
The Three-Layer Pipeline
Parse
Directive
Options
Response
| Layer | Responsible Class | Purpose |
|---|---|---|
| Orchestration | ExpressionOrchestrator | Entry point — detects wildcards, coordinates parsing, routing, caching, option application |
| Directive | BaseDirectiveEvaluator + implementations | Resolves a single expression to a raw value using domain-specific logic |
| Options | IDirectiveOption implementations | Post-resolution transforms (format, validate, encode, cache) |
Step 1 — Parsing
The active IWildcardParser inspects the field string and answers three questions:
- CanParse? — Does the string contain any wildcard patterns?
- IsTemplate? — Does it contain multiple expressions or surrounding literal text?
- Parse / FindAllRegions — Extract directive tokens, path, and option tokens
// Input field value
"{@ $ctx.user.name @uppercase }"
// After parsing → WildcardParseResult
DirectiveToken[] Directives = [
new("ctx"), // first directive (routes to $ctx handler)
// second directive would be data-source qualifier if present
]
string Path = "user.name"
string[] Options = ["uppercase"]
string RawExpression = "{@ $ctx.user.name @uppercase }"
Three parsers are registered. The active one is selected via IWildcardParserResolver based on EvaluationContext.ActiveSyntax:
| Parser | Syntax | When to use |
|---|---|---|
AtBraceWildcardParser | {@ $dir.path @opt } | Default for all BizFirst workflows |
DoubleBraceWildcardParser | {{ $dir.path @opt }} | Importing n8n / Handlebars workflows |
AtSignWildcardParser | @alias.path | Canned expression short form |
Step 2 — Directive Routing
The orchestrator looks up Directives[0].Name in the DirectiveServiceRegistry (DI-registered map of name → IExpressionDirectiveService). The matched directive's InvokeAsync is called, which:
- Starts a
Stopwatchfor duration tracking - Calls the abstract
EvaluateAsyncmethod on the concrete directive class - Attaches elapsed milliseconds to the
EvaluationResponse
$ character. The DI key for $ctx is "ctx", for $var is "var". This keeps registration clean.
If no directive matches, the IDefaultExpressionDirectiveService fallback is used (typically the $var directive, configurable per host).
Step 3 — Option Application
After the directive returns a raw value, registered options are applied in order:
PopulateConstraints(FieldConstraints)— each option records Required/NotEmpty/DefaultValue constraintsApply(resolvedValue)— each option transforms the value (uppercase, JSON encode, etc.)FormattedValue— lazy string property onEvaluationResponse, computed on first access
ICacheMarkerOption (e.g., @cache, @cache-thread) are handled by the orchestrator before the directive is called. A cache hit short-circuits the entire directive invocation.
Template String Mode
When IWildcardParser.IsTemplate(text) returns true, the orchestrator enters interpolation mode:
// Template input
"Dear {@ $ctx.user.name }, your order {@ $var.orderId } is ready."
// Process:
1. FindAllRegions() → [{start:5, end:28}, {start:43, end:62}]
2. Evaluate all regions in parallel (Task.WhenAll)
3. Substitute each FormattedValue into original string
4. Scan assembled string for remaining wildcards
5. If new wildcards found AND Depth < 10 → re-enter pipeline
6. Return assembled string
The VisitedKeys set is shared across all passes, preventing infinite loops when a resolved value itself contains a previously-seen expression.
Context vs Request — Two Key Objects
| Object | Lifetime | Created by | Contains |
|---|---|---|---|
EvaluationContext |
Per node execution | BaseNodeExecutor |
TenantId, ExecutionId, NodeKey, IsolationLevel, ActiveSyntax, Cache, Memory, NodeExecutionData, CurrentUser |
EvaluationRequest |
Per expression call | ExpressionOrchestrator |
Parsed Directives[], Path, RawExpression, Options[], Depth (chain depth), VisitedKeys, reference to Context |
EvaluationContext is created once and reused for all field evaluations within a node execution. EvaluationRequest is immutable — chaining creates a new request via request.Chain() or request.ForSubDirective().
Caching
The IExpressionCache is scope-aware. Cache scope is controlled by the @cache option family:
| Option | Scope | Invalidated when |
|---|---|---|
@cache (default) | Node | Node execution completes |
@cache-thread | Thread | Workflow thread completes |
@cache-process | Process | Process (top-level workflow) completes |
Cache is purely in-memory — no Redis. Cache hits set EvaluationResponse.CacheHit = true and DurationMs = 0.
Security Model
The ExpressionIsolationLevel on the EvaluationContext controls which directives are permitted:
| Level | Permitted directives | Use when |
|---|---|---|
Safe | All data-read directives: $ctx, $var, $input, $output, $exec, $flow, $items, $math | Default for all nodes |
Sandboxed | All Safe + $js (Jint sandbox) | Nodes that need script logic |
Trusted | All + $cs, $api | Requires EnableTrustedExecutionEnvironment=true |
EvaluationResponse.Failure with EvaluationErrorCode.IsolationViolation — not a silent null.