DEV Community

Nick
Nick

Posted on

Part 7: Expression Language Syntax - Powering Logic in Nodes

In our previous parts, we looked at how Vyshyvanka executes tasks and handles failures. But what makes a workflow truly dynamic? The answer is our Expression Language. Today, we are going to look at how we turn static node configuration into dynamic logic that adapts to data at runtime.

Why Expressions?

If nodes were static, workflows would be useless for anything beyond fixed sequences. You would need to hard-code every email address, every database query value, and every condition. That does not work in the real world. You need your workflow to respond to incoming data — like a dynamic ID from a webhook or a calculated value from a previous database query.

The Double-Brace Syntax

We use a simple, double-brace syntax to reference data. If you have ever used Jinja2 or Liquid, you will feel right at home.

Pattern Resolves To
{{ nodes.<nodeId>.data }} Full JSON output of a previous node
{{ nodes.<nodeId>.data.prop }} Nested property access
{{ nodes.<nodeId>.data.items[0].name }} Array index + property
{{ variables.executionId }} Current execution ID
{{ variables.workflowId }} Current workflow ID

The two valid roots are nodes (referencing other node outputs) and variables (execution metadata). Any other root will be rejected at validation time with a clear error message.

How it Works

When the engine pipeline hits a node, it does not just pass the configuration object as-is. It runs an evaluation pass via the ExpressionEvaluator. It uses a compiled regex to find all {{ ... }} placeholders, resolves each path against the IExecutionContext, and replaces the placeholder with the actual value before the node's ExecuteAsync is called.

A key design decision: if the entire configuration value is a single expression (e.g., {{ nodes.api.data }}), the evaluator returns the actual typed value (a JsonElement, an integer, etc.) rather than a string. This means you can pass complex objects between nodes without serialization loss. When expressions are embedded in a longer string, they are interpolated as strings.

Built-in Functions

Beyond simple path resolution, our expression engine supports a rich library of built-in functions for data transformation directly within expressions:

String functions:

{{ toUpper(nodes.webhook.data.name) }}
{{ toLower(nodes.api.data.email) }}
{{ trim(nodes.form.data.input) }}
{{ replace(nodes.api.data.url, "http://", "https://") }}
{{ concat(nodes.user.data.firstName, " ", nodes.user.data.lastName) }}
{{ substring(nodes.api.data.code, 0, 3) }}
Enter fullscreen mode Exit fullscreen mode

Date/time functions:

{{ now() }}
{{ utcNow() }}
{{ formatDate(nodes.db.data.createdAt, "yyyy-MM-dd") }}
{{ addDays(nodes.schedule.data.dueDate, 7) }}
Enter fullscreen mode Exit fullscreen mode

Math functions:

{{ round(nodes.calc.data.total, 2) }}
{{ abs(nodes.diff.data.value) }}
{{ min(nodes.a.data.x, nodes.b.data.y) }}
Enter fullscreen mode Exit fullscreen mode

Type conversion and logic:

{{ toString(nodes.api.data.count) }}
{{ toNumber(nodes.form.data.quantity) }}
{{ coalesce(nodes.api.data.nickname, nodes.api.data.name, "Anonymous") }}
{{ iif(nodes.check.data.isActive, "enabled", "disabled") }}
Enter fullscreen mode Exit fullscreen mode

These functions are strictly data-access and transformation utilities — they cannot perform I/O, call external services, or modify state. Complex logic belongs in Logic Nodes or Code Nodes, not inside expressions.

Validation

Before a workflow executes, you can validate expressions with the Validate method. It checks:

  • That expression syntax is well-formed (matching {{ }} braces)
  • That paths start with a valid root (nodes or variables)
  • That paths are complete (not just {{ nodes }} without a node ID)
var result = evaluator.Validate("{{ nodes.api.data.items[0].id }}");
// result.IsValid == true

var badResult = evaluator.Validate("{{ credentials.apiKey }}");
// result.IsValid == false
// Error: "Unknown expression root 'credentials'. Expected 'nodes' or 'variables'"
Enter fullscreen mode Exit fullscreen mode

Security First

Dynamic expression evaluation is a dangerous tool if not handled correctly. A classic vulnerability in workflow engines is allowing malicious users to execute arbitrary code via expression injection.

We treat this with extreme care:

  1. Sandboxed Evaluation: We do not use eval() or equivalent code execution. We use a strictly controlled property resolver that can only access the execution context through well-defined paths.
  2. Controlled Function Set: The available functions are a fixed, curated set compiled into the engine. Users cannot register custom functions or call arbitrary methods.
  3. Sanitization: All values resolved from the expression engine are treated as untrusted input. Before these values are passed to nodes (especially those performing database queries or shell commands), they are sanitized or parameterized.
  4. No Side Effects: Expression functions are pure — they cannot modify the execution context, perform network calls, or write to disk.

Building Dynamic Workflows

This syntax is how you build a real workflow. Imagine a sequence where:

  1. A WebhookTrigger receives an order payload.
  2. An HttpRequest node calls an external API using {{ nodes.webhook.data.orderId }}.
  3. A Switch node checks the result with {{ nodes.http.data.status }}.
  4. An EmailSend node formats a message with {{ concat("Order ", nodes.webhook.data.orderId, " is ", toLower(nodes.http.data.status)) }}.

Every step references the output of a previous step dynamically. The workflow adapts to whatever data flows through it at runtime.

In the next part, we will discuss Part 8: Persistence and State - EF Core, Migrations, and Reliability. Stay tuned!


Check out the project source code here: https://github.com/homolibere/Vyshyvanka

Top comments (0)