<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick</title>
    <description>The latest articles on DEV Community by Nick (@homolibere).</description>
    <link>https://dev.to/homolibere</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1181392%2F50ad4a50-d1dc-4910-975d-76c4f5a8bb7a.jpeg</url>
      <title>DEV Community: Nick</title>
      <link>https://dev.to/homolibere</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/homolibere"/>
    <language>en</language>
    <item>
      <title>Part 15: Workflow Patterns and Recipes - Data Transformation</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Fri, 26 Jun 2026 08:03:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-15-workflow-patterns-and-recipes-data-transformation-27ml</link>
      <guid>https://dev.to/homolibere/part-15-workflow-patterns-and-recipes-data-transformation-27ml</guid>
      <description>&lt;p&gt;As we conclude this 15-part series on Vyshyvanka, we want to leave you with practical tools. One of the most common challenges in workflow automation is not moving data, but transforming it. Today, we look at the patterns we use to map, clean, and reshape data as it flows through your pipelines.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Transformation Challenge
&lt;/h3&gt;

&lt;p&gt;In a real-world workflow, you rarely get the data exactly in the format you need. Service A might return a deeply nested JSON object, while Service B expects a flat structure with different field names. Vyshyvanka handles this through its expression engine and Code Node — giving you both declarative and programmatic transformation options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Path Projection with Expressions
&lt;/h3&gt;

&lt;p&gt;When you have a large JSON response and only need specific values, use expressions in your node configuration to extract exactly what you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.api.data.items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;user.id&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This plucks a deeply nested ID from an API response and passes it as a clean input to your next node. You can use this directly in any node's configuration field.&lt;/p&gt;

&lt;p&gt;For more complex extraction, combine with built-in functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;toUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;user.name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.user.data.firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nodes.user.data.lastName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.nickname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nodes.api.data.name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Anonymous"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pattern 2: Structural Remapping with Code Nodes
&lt;/h3&gt;

&lt;p&gt;When you need to transform the entire shape of the data, the Code Node is your best tool. It supports two runtimes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JavaScript (via Jint engine):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;General-purpose JavaScript for complex transformations. You have access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;input&lt;/code&gt; — the full input data from upstream nodes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;executionId&lt;/code&gt; — current execution ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workflowId&lt;/code&gt; — current workflow ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;log(message)&lt;/code&gt; — logging that appears in execution output&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getItems()&lt;/code&gt; — returns input as an array (wraps single values)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;toJson(value)&lt;/code&gt; — serializes a value to a JSON string
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Remap an API response to a different structure&lt;/span&gt;
&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Transforming user data: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isVerified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;createdYear&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;JSONata:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A declarative query and transformation language, ideal for complex path selection and restructuring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;items.&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;firstName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$count(items)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JSONata shines when you need to reshape deeply nested structures without imperative loop code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Batch Processing with runForEachItem
&lt;/h3&gt;

&lt;p&gt;Both JavaScript and JSONata support two execution modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;runOnce&lt;/code&gt;:&lt;/strong&gt; Executes your code against the full input object. Use this for single-item transformations or when you need access to the entire dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;runForEachItem&lt;/code&gt;:&lt;/strong&gt; If your input is an array, the engine executes your logic for every item, collecting the results into a new array.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mode: runForEachItem&lt;/span&gt;
&lt;span class="c1"&gt;// 'input' here is a single item from the array&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the equivalent of a &lt;code&gt;.map()&lt;/code&gt; operation, but managed by the engine with proper error handling and logging for each item.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 4: Conditional Routing with Logic Nodes
&lt;/h3&gt;

&lt;p&gt;Not all transformation is about reshaping data — sometimes you need to route data differently based on its content. The &lt;strong&gt;Switch&lt;/strong&gt; node evaluates a value and routes to different output ports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configure the switch value: &lt;code&gt;{{ nodes.api.data.status }}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Define cases: &lt;code&gt;"active"&lt;/code&gt; → port A, &lt;code&gt;"suspended"&lt;/code&gt; → port B, default → port C&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each downstream branch can then apply its own transformation logic appropriate to that case.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;If&lt;/strong&gt; node handles binary decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Condition: &lt;code&gt;{{ nodes.check.data.amount }}&lt;/code&gt; &amp;gt; threshold&lt;/li&gt;
&lt;li&gt;True branch: process normally&lt;/li&gt;
&lt;li&gt;False branch: send alert&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pattern 5: Aggregation with Loop + Merge
&lt;/h3&gt;

&lt;p&gt;For workflows that need to process a collection and then aggregate the results:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Loop Node:&lt;/strong&gt; Iterates over an array, executing a subgraph for each item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subgraph nodes:&lt;/strong&gt; Transform/enrich each item (API calls, lookups, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loop completion:&lt;/strong&gt; The loop node collects all outputs from its "done" port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge Node:&lt;/strong&gt; Combines data from multiple branches into a single object.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The loop node exposes per-iteration context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.loop.data.index&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;        // Current iteration index
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.loop.data.item&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;         // Current item
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.loop.data.isFirst&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;      // Boolean
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.loop.data.isLast&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;       // Boolean
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;nodes.loop.data.totalCount&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;   // Total items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pattern 6: Data Enrichment Pipeline
&lt;/h3&gt;

&lt;p&gt;A common pattern is fetching additional data for each item in a collection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HttpRequest&lt;/strong&gt; → Get list of orders from API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loop&lt;/strong&gt; → For each order:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HttpRequest&lt;/strong&gt; → Fetch customer details by &lt;code&gt;{{ nodes.loop.data.item.customerId }}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Node&lt;/strong&gt; → Merge order + customer into enriched object&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DatabaseQuery&lt;/strong&gt; → Store enriched results&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step references the previous step's output through expressions, creating a data pipeline that transforms raw API responses into enriched, business-ready objects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best Practices
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep transformations close to their consumer:&lt;/strong&gt; Transform data in the node configuration or a Code Node immediately before the node that needs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use expressions for simple extractions:&lt;/strong&gt; &lt;code&gt;{{ nodes.api.data.user.name }}&lt;/code&gt; is clearer than a Code Node for simple path access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Code Nodes for complex logic:&lt;/strong&gt; Multi-field remapping, conditional logic, and calculations belong in Code Nodes where they are testable and readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer JSONata for pure data reshaping:&lt;/strong&gt; When you are only restructuring JSON without side effects, JSONata expressions are more concise than JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use runForEachItem for array transformations:&lt;/strong&gt; It handles iteration, error collection, and result aggregation automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Wrapping Up the Series
&lt;/h3&gt;

&lt;p&gt;We hope this series has shown you that Vyshyvanka is built for the real world — secure, extensible, and performant. We started with basic triggers and ended with complex data transformation patterns. The platform gives you the building blocks; how you combine them is up to your creativity.&lt;/p&gt;

&lt;p&gt;The community is the final piece of the puzzle. Now it is your turn. Go build something, share your custom nodes, and let us know what you think.&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>patterns</category>
      <category>data</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Part 14: Community and Ecosystem - Contributing to Vyshyvanka</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Thu, 25 Jun 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-14-community-and-ecosystem-contributing-to-vyshyvanka-570d</link>
      <guid>https://dev.to/homolibere/part-14-community-and-ecosystem-contributing-to-vyshyvanka-570d</guid>
      <description>&lt;p&gt;It is clear that Vyshyvanka is more than just code — it is an ecosystem. The true power of an open-source workflow engine lies in its community. Today, we want to talk about how you can get involved, whether you are interested in pushing the boundaries of the core engine or building specialized solutions with custom plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Core Engine vs. The Plugin Ecosystem
&lt;/h3&gt;

&lt;p&gt;A common question we get is: 'Should I contribute a PR to the core engine, or should I build a separate plugin?' The answer depends entirely on the scope of your contribution.&lt;/p&gt;

&lt;h4&gt;
  
  
  When to Contribute to Core
&lt;/h4&gt;

&lt;p&gt;The core engine (&lt;code&gt;Vyshyvanka.Core&lt;/code&gt;, &lt;code&gt;Vyshyvanka.Engine&lt;/code&gt;, &lt;code&gt;Vyshyvanka.Api&lt;/code&gt;, &lt;code&gt;Vyshyvanka.Designer&lt;/code&gt;) should be reserved for changes that benefit every user of the platform. Good candidates for core contributions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Performance improvements to the execution pipeline&lt;/li&gt;
&lt;li&gt;New fundamental port types or expression functions&lt;/li&gt;
&lt;li&gt;Bug fixes in the engine, validation, or persistence layers&lt;/li&gt;
&lt;li&gt;Enhancements to the Designer UI (canvas, node editor, property editors)&lt;/li&gt;
&lt;li&gt;Improvements to the API surface (new endpoints, better error responses)&lt;/li&gt;
&lt;li&gt;Documentation improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These changes require careful review and testing because they impact every installation. We encourage PRs here, but we also ask that you open an issue first so we can discuss the architectural impact.&lt;/p&gt;

&lt;h4&gt;
  
  
  When to Build a Plugin
&lt;/h4&gt;

&lt;p&gt;Plugins (&lt;code&gt;./plugins/&lt;/code&gt;) are the best way to extend functionality without increasing the maintenance burden of the core. Good candidates for plugins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Integration with a specific third-party SaaS tool (CRM, CI/CD, monitoring)&lt;/li&gt;
&lt;li&gt;Custom nodes specific to your industry or use case&lt;/li&gt;
&lt;li&gt;Experimental node behaviors that are not yet ready for core&lt;/li&gt;
&lt;li&gt;Proprietary integrations you want to keep separate from the open source project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plugins are independent, versionable, and can be maintained outside the core release cycle. They empower you to solve your specific problems immediately without waiting for a core release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project Structure at a Glance
&lt;/h3&gt;

&lt;p&gt;Understanding where things live is key to contributing effectively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vyshyvanka/
├── src/
│   ├── Vyshyvanka.Core/        # Domain layer (zero dependencies)
│   ├── Vyshyvanka.Engine/      # Execution engine, persistence, plugins
│   ├── Vyshyvanka.Api/         # REST API
│   ├── Vyshyvanka.Designer/    # Blazor WASM UI
│   ├── Vyshyvanka.AppHost/     # .NET Aspire orchestration
│   └── Vyshyvanka.ServiceDefaults/
├── plugins/
│   ├── Vyshyvanka.Plugin.AdvancedHttp/
│   ├── Vyshyvanka.Plugin.GitLab/
│   ├── Vyshyvanka.Plugin.Jira/
│   └── Vyshyvanka.Plugin.Tmplt/  # Starter template!
├── tests/
│   └── Vyshyvanka.Tests/
└── docs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dependencies flow strictly downward. Core has zero dependencies. Engine depends on Core. Api depends on Core and Engine. Plugins depend only on Core.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting Started with Plugin Development
&lt;/h3&gt;

&lt;p&gt;The fastest way to start is with the template plugin:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Copy the template:&lt;/strong&gt; Duplicate &lt;code&gt;plugins/Vyshyvanka.Plugin.Tmplt/&lt;/code&gt; and rename it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update &lt;code&gt;PluginInfo.cs&lt;/code&gt;:&lt;/strong&gt; Set your plugin ID, name, version, and author.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create your nodes:&lt;/strong&gt; Inherit from &lt;code&gt;BasePluginNode&lt;/code&gt;, add &lt;code&gt;[NodeDefinition]&lt;/code&gt; and &lt;code&gt;[ConfigurationProperty]&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build and test:&lt;/strong&gt; The test project can reference your plugin directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package:&lt;/strong&gt; Publish as a NuGet package for easy distribution.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the minimal structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PluginInfo.cs&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"com.yourorg.myplugin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"My Plugin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Does something useful"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Author&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Your Name"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Nodes/MyCustomNode.cs&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;NodeDefinition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"My Custom Action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Does the thing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Icon&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"fa-solid fa-star"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apiUrl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"API endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IsRequired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyCustomNode&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BasePluginNode&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"my-custom-action"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;NodeCategory&lt;/span&gt; &lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NodeCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeOutput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NodeInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetRequiredConfigValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"apiUrl"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Your logic here...&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;SuccessOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"done"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Contributing to Core: Guidelines
&lt;/h3&gt;

&lt;p&gt;If you want to contribute to the core:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open an issue first:&lt;/strong&gt; Describe the problem and your proposed solution. This helps us maintain consistency and prevents wasted effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow conventions:&lt;/strong&gt; File-scoped namespaces, primary constructors for DI, &lt;code&gt;CancellationToken&lt;/code&gt; in every async method, AwesomeAssertions in tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write tests:&lt;/strong&gt; Every change needs appropriate test coverage — unit tests for logic, integration tests for API changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the dependency rules:&lt;/strong&gt; Never introduce an upward or circular reference between projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the ripple effect:&lt;/strong&gt; If you change an interface in Core, update all implementations in Engine.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Running the Full Stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build everything&lt;/span&gt;
dotnet build

&lt;span class="c"&gt;# Run all tests&lt;/span&gt;
dotnet &lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="c"&gt;# Start the full application (API + Designer)&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Vyshyvanka.AppHost

&lt;span class="c"&gt;# Start just the API&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Vyshyvanka.Api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A Growing Ecosystem
&lt;/h3&gt;

&lt;p&gt;Every plugin you build and every bug you fix makes Vyshyvanka more valuable for everyone else. Whether you are an enterprise developer building mission-critical workflows or a hobbyist automating your home server, your contribution helps shape the future of open-source workflow automation.&lt;/p&gt;

&lt;p&gt;We are excited to see what you build.&lt;/p&gt;

&lt;p&gt;In the final part of this series, we will discuss Part 15: Workflow Patterns and Recipes - Data Transformation. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>opensource</category>
      <category>contribution</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Part 13: Deployment Strategies - Orchestrating Vyshyvanka with .NET Aspire</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Wed, 24 Jun 2026 08:02:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-13-deployment-strategies-orchestrating-vyshyvanka-with-net-aspire-3ke4</link>
      <guid>https://dev.to/homolibere/part-13-deployment-strategies-orchestrating-vyshyvanka-with-net-aspire-3ke4</guid>
      <description>&lt;p&gt;As we move from development to production, deployment complexity often becomes the biggest bottleneck. For Vyshyvanka, we chose .NET Aspire as our development orchestration framework and as the foundation for production-ready deployments. Today, we look at how Aspire simplifies running a multi-service workflow engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Aspire?
&lt;/h3&gt;

&lt;p&gt;Traditional development setup means manually configuring your API, your Blazor frontend, your database, and ensuring they can discover each other. If one service's port changes, everything breaks. Aspire solves this by treating the entire application as a single, orchestrated 'app model'. This app model describes how your services relate to each other — what resources they need, what ports they share, and how they discover each other via service discovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Our AppHost
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Vyshyvanka.AppHost&lt;/code&gt; project defines our application topology in C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DistributedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Check if PostgreSQL mode is enabled&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;usePostgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"UsePostgres"&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;IResourceBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ProjectResource&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usePostgres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Production: PostgreSQL with persistent volume&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPostgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDataVolume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vyshyvanka-postgres-data"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vyshyvankadb"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vyshyvanka_Api&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Development: SQLite (no external dependencies)&lt;/span&gt;
    &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vyshyvanka_Api&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Designer discovers API via service discovery&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vyshyvanka_Designer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"designer"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is actual production code from our repository. A few things to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conditional infrastructure:&lt;/strong&gt; The same AppHost supports both SQLite (for quick local dev) and PostgreSQL (for production-like environments). A single environment variable flips the switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service discovery:&lt;/strong&gt; The Designer automatically discovers the API endpoint — no hard-coded URLs in configuration files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency ordering:&lt;/strong&gt; &lt;code&gt;WaitFor&lt;/code&gt; ensures the API doesn't start until the database is ready, and the Designer doesn't start until the API is accepting requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Development Experience
&lt;/h3&gt;

&lt;p&gt;Running the full stack locally is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Vyshyvanka.AppHost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The PostgreSQL container (if configured) with a persistent data volume&lt;/li&gt;
&lt;li&gt;The API service with proper connection strings injected&lt;/li&gt;
&lt;li&gt;The Blazor WebAssembly Designer with API endpoint configured&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Aspire dashboard gives you a unified view of all services, their logs, and distributed traces — all without any additional configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service Defaults
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Vyshyvanka.ServiceDefaults&lt;/code&gt; project provides shared configuration that every service gets automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Health checks&lt;/strong&gt; — standardized readiness and liveness probes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry&lt;/strong&gt; — distributed tracing and metrics collection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience&lt;/strong&gt; — default HTTP client resilience policies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service discovery&lt;/strong&gt; — automatic endpoint resolution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each service opts in with a single line: &lt;code&gt;builder.AddServiceDefaults()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Containerization
&lt;/h3&gt;

&lt;p&gt;Aspire is built on a container-first mindset. In development, it manages Docker containers for infrastructure (PostgreSQL). For production deployment, the same application model can generate deployment manifests for container orchestrators.&lt;/p&gt;

&lt;p&gt;Because our application is already decomposed into well-defined services with explicit dependencies, the transition from local development to containerized production is straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API and Designer are standard .NET web applications — they containerize with the default .NET SDK container support.&lt;/li&gt;
&lt;li&gt;Database connection strings are injected via environment variables, whether running locally or in a container.&lt;/li&gt;
&lt;li&gt;Service discovery works the same way in both environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Environment-Specific Configuration
&lt;/h3&gt;

&lt;p&gt;We handle the dev/prod split cleanly through the AppHost configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Auth Provider&lt;/th&gt;
&lt;th&gt;Credential Storage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development&lt;/td&gt;
&lt;td&gt;SQLite (file)&lt;/td&gt;
&lt;td&gt;Built-in (seeded users)&lt;/td&gt;
&lt;td&gt;Built-in (AES-256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;PostgreSQL (container)&lt;/td&gt;
&lt;td&gt;Keycloak/Authentik&lt;/td&gt;
&lt;td&gt;Built-in (AES-256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;PostgreSQL (managed)&lt;/td&gt;
&lt;td&gt;OIDC provider&lt;/td&gt;
&lt;td&gt;Vault/OpenBao&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The application code is identical across all environments. Only the infrastructure wiring changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability Built In
&lt;/h3&gt;

&lt;p&gt;One of the biggest wins with Aspire is built-in observability. Through ServiceDefaults, every HTTP request, database query, and cross-service call is automatically traced with OpenTelemetry. When a workflow fails in production, you can trace the entire request path through the system using standard observability tools (Jaeger, Zipkin, Application Insights, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  Deployment Consistency
&lt;/h3&gt;

&lt;p&gt;The 'it works on my machine' problem is eliminated. With Aspire, your infrastructure is defined as code alongside your logic. The topology you test locally is the same topology that runs in production. You don't manage shell scripts to wire up services — you manage the C# model that defines your application architecture.&lt;/p&gt;

&lt;p&gt;By using Aspire as our orchestration layer, we have drastically reduced the time it takes to go from a local feature to a testable deployment.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 14: Community and Ecosystem - Contributing to Vyshyvanka. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspire</category>
      <category>deployment</category>
      <category>containers</category>
    </item>
    <item>
      <title>Part 12: Performance Optimization - High-Throughput Concurrency</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Tue, 23 Jun 2026 07:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-12-performance-optimization-high-throughput-concurrency-2mo5</link>
      <guid>https://dev.to/homolibere/part-12-performance-optimization-high-throughput-concurrency-2mo5</guid>
      <description>&lt;p&gt;In the world of workflow automation, efficiency is not just a nice-to-have; it is a fundamental requirement. A single workflow engine instance might need to manage hundreds of concurrent executions, each waiting on I/O, external APIs, or database operations. Today, we explore how Vyshyvanka achieves high-throughput performance using .NET's powerful concurrency model.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Async-First Architecture
&lt;/h3&gt;

&lt;p&gt;Vyshyvanka was built with an async-first philosophy. We recognized early on that workflow engines are inherently I/O-bound. You are constantly waiting: waiting for a database row to lock, waiting for an API to respond, or waiting for a disk write to confirm.&lt;/p&gt;

&lt;p&gt;If we were to use traditional synchronous threads, we would quickly exhaust the thread pool and bring the system to a halt. By using &lt;code&gt;async/await&lt;/code&gt; everywhere, we ensure that while a workflow waits for I/O, the underlying thread is returned to the .NET thread pool to handle other work. This allows a relatively small number of threads to manage hundreds of concurrent workflow executions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrency Control with SemaphoreSlim
&lt;/h3&gt;

&lt;p&gt;The engine uses &lt;code&gt;SemaphoreSlim&lt;/code&gt; to limit the degree of parallelism at each execution level. This prevents a single large workflow from saturating all available resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeExecutionResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteLevelWithThrottlingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Workflow&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ConcurrentBag&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeExecutionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ExecuteNodeInWorkflowAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;semaphore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default parallelism is &lt;code&gt;Environment.ProcessorCount * 2&lt;/code&gt;, configurable per workflow via &lt;code&gt;workflow.Settings.MaxDegreeOfParallelism&lt;/code&gt;. This gives you fine-grained control — a lightweight data-sync workflow might run with parallelism of 1, while a fan-out notification workflow might use 20.&lt;/p&gt;

&lt;h3&gt;
  
  
  Thread Safety Through Design
&lt;/h3&gt;

&lt;p&gt;Managing concurrency requires careful handling of shared state. Here is our approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Thread-Safe Collections:&lt;/strong&gt; The engine tracks active executions and node results using &lt;code&gt;ConcurrentDictionary&lt;/code&gt; and &lt;code&gt;ConcurrentBag&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ConcurrentDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_activeExecutions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Node results are collected concurrently from parallel branches&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ConcurrentBag&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeExecutionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scoped Execution Context:&lt;/strong&gt; Each execution gets its own &lt;code&gt;IExecutionContext&lt;/code&gt; instance. The context stores node outputs and execution variables. While the context is mutated during execution (nodes write their outputs into it), each execution is isolated — one workflow's context never touches another's.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linked Cancellation:&lt;/strong&gt; Each execution creates a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt; so that cancellation can come from either the caller or the engine's &lt;code&gt;CancelExecutionAsync&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateLinkedTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;_activeExecutions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutionId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Minimizing Thread Pool Starvation
&lt;/h3&gt;

&lt;p&gt;A common trap in .NET is 'sync-over-async' — calling &lt;code&gt;.Result&lt;/code&gt; or &lt;code&gt;.Wait()&lt;/code&gt; on an asynchronous method. This blocks a thread, preventing it from doing other work, and is the primary cause of thread pool starvation. We enforce a strict rule in our codebase: &lt;strong&gt;No blocking calls.&lt;/strong&gt; Every call from the engine core through the plugin layer must be properly awaited.&lt;/p&gt;

&lt;p&gt;This rule extends to plugin nodes. Plugin execution goes through &lt;code&gt;IPluginHost.ExecuteNodeInIsolationAsync&lt;/code&gt; with a configurable timeout. If a plugin blocks or hangs, the timeout fires and the node is marked as failed without affecting other executions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Efficiency
&lt;/h3&gt;

&lt;p&gt;We apply several techniques to reduce allocations in the hot path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cached empty objects:&lt;/strong&gt; A single &lt;code&gt;JsonDocument.Parse("{}").RootElement.Clone()&lt;/code&gt; is reused across all nodes that need an empty input, avoiding repeated allocations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;StringBuilder reuse:&lt;/strong&gt; Expression evaluation uses &lt;code&gt;StringBuilder&lt;/code&gt; with pre-estimated capacity for string interpolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiled regex:&lt;/strong&gt; The expression pattern matching uses source-generated &lt;code&gt;[GeneratedRegex]&lt;/code&gt; for zero-allocation matching.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cached once, shared across all executions&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;JsonElement&lt;/span&gt; &lt;span class="n"&gt;EmptyObjectElement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;RootElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugin Timeout Protection
&lt;/h3&gt;

&lt;p&gt;External plugin nodes get an additional layer of protection. The engine wraps plugin execution with a timeout to prevent runaway code from blocking the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;DefaultPluginTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeOutput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteNodeInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;INode&lt;/span&gt; &lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NodeInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_pluginHost&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_pluginHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsPluginNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pluginHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteNodeInIsolationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Leveraging the .NET Thread Pool
&lt;/h3&gt;

&lt;p&gt;The .NET Thread Pool is incredibly good at its job, but it needs the engine to be a 'good citizen'. By being truly asynchronous, we allow the .NET runtime to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dynamically scale the number of threads based on CPU load&lt;/li&gt;
&lt;li&gt;Optimize task scheduling to keep CPU caches warm&lt;/li&gt;
&lt;li&gt;Avoid context-switching overhead that comes with excessive multi-threading&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Results
&lt;/h3&gt;

&lt;p&gt;By combining non-blocking I/O with granular concurrency control, Vyshyvanka can scale horizontally. Because our execution state is persisted separately from the workflow definition, you can spin up multiple engine instances connected to the same database. Your throughput is limited by your infrastructure, not by a single process.&lt;/p&gt;

&lt;p&gt;Performance is a journey of constant refinement. By respecting the asynchronous nature of .NET, isolating execution contexts, and avoiding thread-blocking patterns, we have built an engine that is as fast as it is flexible.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 13: Deployment Strategies - Orchestrating Vyshyvanka with .NET Aspire. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>concurrency</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Part 11: Testing Strategies - Guaranteeing Rock-Solid Workflows</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Mon, 22 Jun 2026 07:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-11-testing-strategies-guaranteeing-rock-solid-workflows-3k57</link>
      <guid>https://dev.to/homolibere/part-11-testing-strategies-guaranteeing-rock-solid-workflows-3k57</guid>
      <description>&lt;p&gt;In the world of workflow automation, the cost of failure is high. A bug in a workflow can lead to lost data, broken integrations, or halted business processes. To prevent this, we have built a comprehensive testing strategy for Vyshyvanka that covers every layer — from isolated unit tests to full API integration tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Philosophy: Testable by Design
&lt;/h3&gt;

&lt;p&gt;Before we write a single test, we ensure our code is testable. We accomplish this by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Injecting dependencies (like &lt;code&gt;HttpClient&lt;/code&gt; or &lt;code&gt;ICredentialProvider&lt;/code&gt;) into constructors&lt;/li&gt;
&lt;li&gt;Using interfaces everywhere so we can substitute real implementations in tests&lt;/li&gt;
&lt;li&gt;Keeping node logic pure — given input and configuration, produce output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This simple architectural choice allows us to easily swap real services for test doubles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Our Testing Stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;xUnit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Test framework with async support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AwesomeAssertions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fluent assertion library (&lt;code&gt;.Should().Be(...)&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NSubstitute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Interface mocking (&lt;code&gt;Substitute.For&amp;lt;IService&amp;gt;()&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CsCheck&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Property-based testing for validation and serialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;bUnit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Blazor component testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebApplicationFactory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full API integration tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Example: Testing a Node
&lt;/h3&gt;

&lt;p&gt;Let us look at how we test the &lt;code&gt;HttpRequestNode&lt;/code&gt;. We do not make real network calls — we inject a custom &lt;code&gt;MockHttpHandler&lt;/code&gt; that returns preconfigured responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HttpRequestNodeTests&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ExecutionContext&lt;/span&gt; &lt;span class="nf"&gt;CreateContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;NullCredentialProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;HttpRequestNode&lt;/span&gt; &lt;span class="nf"&gt;CreateNodeWithHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMessageHandler&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;WhenGetRequestSucceedsThenReturnsSuccessWithResponseData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MockHttpHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpResponseMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StringContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"""{"&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="n"&gt;hello&lt;/span&gt;&lt;span class="s"&gt;"}"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateNodeWithHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com/data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GET"&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;NodeInput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;BeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;GetInt32&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key patterns here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MockHttpHandler&lt;/code&gt; is a custom &lt;code&gt;HttpMessageHandler&lt;/code&gt; that captures requests and returns preset responses.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NullCredentialProvider.Instance&lt;/code&gt; provides a no-op credential provider for tests that don't need auth.&lt;/li&gt;
&lt;li&gt;Configuration is passed as &lt;code&gt;JsonElement&lt;/code&gt; — matching exactly how the engine delivers it.&lt;/li&gt;
&lt;li&gt;Assertions use AwesomeAssertions' fluent syntax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mocking with NSubstitute
&lt;/h3&gt;

&lt;p&gt;For services behind interfaces, we use NSubstitute. Here is how we test the &lt;code&gt;PluginLoadingService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PluginLoadingServiceTests&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IPluginLoader&lt;/span&gt; &lt;span class="n"&gt;_pluginLoader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPluginLoader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IPluginValidator&lt;/span&gt; &lt;span class="n"&gt;_pluginValidator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPluginValidator&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;INodeRegistry&lt;/span&gt; &lt;span class="n"&gt;_nodeRegistry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INodeRegistry&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IPackageCache&lt;/span&gt; &lt;span class="n"&gt;_packageCache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Substitute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPackageCache&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;PluginLoadingService&lt;/span&gt; &lt;span class="n"&gt;_sut&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PluginLoadingServiceTests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_sut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PluginLoadingService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;_pluginLoader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_pluginValidator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_nodeRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_packageCache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;WhenPluginLoadsSuccessfullyThenRegistersNodes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;assembly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PluginLoadingServiceTests&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_pluginLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryLoadAssembly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;_pluginValidator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidatePlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PluginValidationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsValid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_sut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LoadAndValidatePluginsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"test-pkg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/install/path"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;BeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_nodeRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Received&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RegisterFromAssembly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NSubstitute gives us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Substitute.For&amp;lt;T&amp;gt;()&lt;/code&gt; — creates a test double for any interface&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Arg.Any&amp;lt;T&amp;gt;()&lt;/code&gt; — matches any argument&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.Returns(value)&lt;/code&gt; — configures return values&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.Received(n)&lt;/code&gt; — verifies a method was called n times&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Property-Based Testing with CsCheck
&lt;/h3&gt;

&lt;p&gt;For logic that should hold true for &lt;em&gt;all&lt;/em&gt; inputs (not just hand-picked examples), we use CsCheck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;WhenSerializedThenDeserializationRoundTrips&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;WhenWorkflowIdGeneratedThenAlwaysValid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;NotBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;BeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CsCheck generates hundreds of random inputs and verifies your invariants hold for all of them. We use this extensively for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Serialization round-trips&lt;/li&gt;
&lt;li&gt;Expression evaluator edge cases&lt;/li&gt;
&lt;li&gt;Validation logic completeness&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Integration Tests
&lt;/h3&gt;

&lt;p&gt;For end-to-end API testing, we use &lt;code&gt;WebApplicationFactory&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExecutionApiTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ExecutionApiTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;WhenWorkflowNotFoundThenReturns404&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;workflowId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/execution"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This spins up the full ASP.NET Core pipeline in-process — middleware, controllers, DI, database — giving us confidence that the entire stack works together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Organization
&lt;/h3&gt;

&lt;p&gt;All tests live in &lt;code&gt;Vyshyvanka.Tests/&lt;/code&gt;, organized by type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/Vyshyvanka.Tests/
├── Unit/                  # Isolated tests, no I/O
│   ├── Nodes/            # Node-specific tests
│   ├── Components/       # Blazor component tests (bUnit)
│   └── ...
├── Property/             # CsCheck property-based tests
├── Integration/          # WebApplicationFactory tests
│   └── Fixtures/         # Shared test fixtures
└── E2E/                  # End-to-end scenario tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Test Naming Convention
&lt;/h3&gt;

&lt;p&gt;We follow the pattern &lt;code&gt;When{Condition}Then{ExpectedResult}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;WhenWorkflowHasNoTriggerThenValidationFails&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;WhenGetRequestSucceedsThenReturnsSuccessWithResponseData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;WhenPluginValidationFailsThenReturnsError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes test output self-documenting — you can read the test names as a specification of the system's behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Benefits
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; Unit tests run in milliseconds. We can run the full suite on every commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence:&lt;/strong&gt; By mocking external dependencies, we verify our code's logic in isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; Tests are the ultimate specification — they show exactly how every component behaves under specific conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regression Protection:&lt;/strong&gt; Property-based tests catch edge cases that hand-written tests miss.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Testing is not a chore; it is a safety net. By making our nodes modular, injecting our dependencies, and rigorously testing every layer, we ensure that Vyshyvanka remains as reliable as the systems it automates.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 12: Performance Optimization - High-throughput processing and concurrency. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>testing</category>
      <category>xunit</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Part 10: Plugin System Architecture - Extensibility by Design</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Fri, 19 Jun 2026 08:02:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-10-plugin-system-architecture-extensibility-by-design-5035</link>
      <guid>https://dev.to/homolibere/part-10-plugin-system-architecture-extensibility-by-design-5035</guid>
      <description>&lt;p&gt;Our workflow engine is designed to be more than just a pre-defined set of nodes. We recognize that every business, every API, and every integration is unique. That is why we built Vyshyvanka around a robust, assembly-based plugin architecture that allows you to extend the engine without touching the core codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Foundation: Assembly Loading
&lt;/h3&gt;

&lt;p&gt;At the heart of our extensibility model is .NET's &lt;code&gt;AssemblyLoadContext&lt;/code&gt;. When the engine starts, the &lt;code&gt;PluginLoader&lt;/code&gt; scans a designated plugin directory for compiled &lt;code&gt;.dll&lt;/code&gt; files. Each plugin gets its own collectible &lt;code&gt;PluginLoadContext&lt;/code&gt;, which provides full isolation from the core application — preventing version conflicts between your dependencies and the engine's dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PluginLoadContext&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AssemblyLoadContext&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AssemblyDependencyResolver&lt;/span&gt; &lt;span class="n"&gt;_resolver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PluginLoadContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pluginPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isCollectible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_resolver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AssemblyDependencyResolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pluginPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AssemblyName&lt;/span&gt; &lt;span class="n"&gt;assemblyName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Try to resolve from plugin directory first&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;assemblyPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResolveAssemblyToPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assemblyName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assemblyPath&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;LoadFromAssemblyPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assemblyPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Fall back to default context for shared assemblies (like Vyshyvanka.Core)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;isCollectible: true&lt;/code&gt; flag means plugins can be &lt;strong&gt;unloaded at runtime&lt;/strong&gt; — enabling hot-reload scenarios and safe removal of installed packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Declaring Plugins
&lt;/h3&gt;

&lt;p&gt;Each plugin is identified by a &lt;code&gt;PluginAttribute&lt;/code&gt; on the assembly. This tells the engine who you are, what version you are on, and provides discovery metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PluginInfo.cs&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Vyshyvanka.Core.Interfaces&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"vyshyvanka.plugin.httpclient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Advanced HTTP Client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Advanced HTTP client nodes with retry, polling, and batch request capabilities."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Author&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Vyshyvanka"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an assembly does not have this attribute, the loader skips it silently — only intentional Vyshyvanka plugins get registered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating Custom Nodes
&lt;/h3&gt;

&lt;p&gt;The power of the system lies in how we define nodes. A node class inherits from &lt;code&gt;BasePluginNode&lt;/code&gt; and is decorated with attributes that describe its UI representation and configuration schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;NodeDefinition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GraphQL Request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Execute GraphQL queries and mutations against a GraphQL endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Icon&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"fa-solid fa-diagram-project"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;NodeInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Input"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;NodeOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Response"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PortType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GraphQL endpoint URL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IsRequired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GraphQL query or mutation string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IsRequired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"variables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Query variables as key-value pairs"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Additional HTTP headers"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ConfigurationProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Request timeout in seconds (default: 30)"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GraphQLNode&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BasePluginNode&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"graphql-request"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;NodeCategory&lt;/span&gt; &lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NodeCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeOutput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NodeInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetRequiredConfigValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetRequiredConfigValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetConfigValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"variables"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timeoutSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetConfigValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// ... execute the GraphQL request ...&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;SuccessOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responseData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that &lt;code&gt;[ConfigurationProperty]&lt;/code&gt; attributes are placed on the &lt;strong&gt;class itself&lt;/strong&gt; (not on individual properties). Configuration values are accessed at runtime through the &lt;code&gt;GetConfigValue&amp;lt;T&amp;gt;()&lt;/code&gt; and &lt;code&gt;GetRequiredConfigValue&amp;lt;T&amp;gt;()&lt;/code&gt; helper methods inherited from the base class. This design means the engine and Designer UI can discover the configuration schema via reflection without instantiating the node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plugin Validation
&lt;/h3&gt;

&lt;p&gt;Before a plugin is registered, the &lt;code&gt;PluginValidator&lt;/code&gt; checks it for correctness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it have the required &lt;code&gt;[Plugin]&lt;/code&gt; attribute?&lt;/li&gt;
&lt;li&gt;Do its node types properly implement &lt;code&gt;INode&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Are there any dependency conflicts?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If validation fails, the plugin is rejected but the engine continues operating. You get a clear error in the logs telling you exactly what went wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Package Management
&lt;/h3&gt;

&lt;p&gt;Beyond dropping DLLs into a directory, Vyshyvanka has a full &lt;strong&gt;NuGet-based package management&lt;/strong&gt; system. The &lt;code&gt;NuGetPackageManager&lt;/code&gt; orchestrates the lifecycle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PackageSearchService&lt;/code&gt;&lt;/strong&gt; — Searches configured NuGet sources, resolves versions, checks for updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PluginLoadingService&lt;/code&gt;&lt;/strong&gt; — Loads plugin assemblies, validates them, and registers their node types with the &lt;code&gt;NodeRegistry&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NuGetPackageManager&lt;/code&gt;&lt;/strong&gt; — Orchestrates install/update/uninstall using the above services.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means you can install plugins from private NuGet feeds, manage versions centrally, and update plugins without restarting the engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Design?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strong Typing:&lt;/strong&gt; By leveraging C# attributes, we automatically generate the UI schema for your nodes. When you add a new &lt;code&gt;[ConfigurationProperty]&lt;/code&gt;, it appears in the Designer without writing frontend code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; Each plugin runs in its own &lt;code&gt;AssemblyLoadContext&lt;/code&gt;. If a plugin fails to load or crashes during execution, the rest of the engine remains operational. Plugin nodes are executed through &lt;code&gt;IPluginHost&lt;/code&gt; with a timeout wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discoverability:&lt;/strong&gt; The &lt;code&gt;NodeRegistry&lt;/code&gt; automatically picks up all &lt;code&gt;INode&lt;/code&gt; implementations from loaded plugin assemblies, making them available in the Designer immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot Unload:&lt;/strong&gt; Because load contexts are collectible, you can uninstall a plugin at runtime. The GC reclaims the memory once all references are released.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Exploring Real Examples
&lt;/h3&gt;

&lt;p&gt;If you look into our &lt;code&gt;./plugins/&lt;/code&gt; directory, you can see this in action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vyshyvanka.Plugin.AdvancedHttp&lt;/code&gt;&lt;/strong&gt; — HTTP retry with exponential backoff, HTTP polling with success/failure conditions, batch requests with concurrency control, and GraphQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vyshyvanka.Plugin.GitLab&lt;/code&gt;&lt;/strong&gt; — Full GitLab integration: issues, merge requests, pipelines, releases, files, tags, and webhook triggers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vyshyvanka.Plugin.Jira&lt;/code&gt;&lt;/strong&gt; — Jira issues, comments, search (JQL), users, and versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vyshyvanka.Plugin.Tmplt&lt;/code&gt;&lt;/strong&gt; — A starter template for building your own plugin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Getting Started with Your Own Plugin
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a new class library targeting the same .NET version.&lt;/li&gt;
&lt;li&gt;Add a reference to &lt;code&gt;Vyshyvanka.Core&lt;/code&gt; (the only allowed dependency from core).&lt;/li&gt;
&lt;li&gt;Add your &lt;code&gt;PluginInfo.cs&lt;/code&gt; with &lt;code&gt;[assembly: Plugin(...)]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create node classes inheriting &lt;code&gt;BasePluginNode&lt;/code&gt; (or implement &lt;code&gt;INode&lt;/code&gt; directly).&lt;/li&gt;
&lt;li&gt;Decorate with &lt;code&gt;[NodeDefinition]&lt;/code&gt; and &lt;code&gt;[ConfigurationProperty]&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;Build, and either drop the DLL into the plugins directory or publish as a NuGet package.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building for extensibility ensures that as your business needs evolve, your automation platform evolves with you.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 11: Testing Strategies - How to guarantee your workflows are rock-solid. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>csharp</category>
      <category>plugins</category>
    </item>
    <item>
      <title>Part 9: Security First - Credentials, Auth, and Secrets Management</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Thu, 18 Jun 2026 08:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-9-security-first-credentials-auth-and-secrets-management-2ked</link>
      <guid>https://dev.to/homolibere/part-9-security-first-credentials-auth-and-secrets-management-2ked</guid>
      <description>&lt;p&gt;Building a workflow engine that interacts with external APIs, databases, and internal systems means you are constantly handling sensitive data. If you get security wrong here, the consequences are severe. Today, we are discussing how we handle credentials, authentication, and secrets management in Vyshyvanka.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: Where do secrets live?
&lt;/h3&gt;

&lt;p&gt;The biggest mistake developers make when building automation tools is hard-coding credentials. Whether it is an API key in a config file or a database password in an environment variable, these secrets eventually leak. We needed a way to manage secrets that is both developer-friendly and secure enough for production environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Credential Store
&lt;/h3&gt;

&lt;p&gt;In Vyshyvanka, we do not store raw secrets in the workflow definition. Instead, we use a dedicated &lt;strong&gt;Credential Store&lt;/strong&gt;. When you add a new service integration to your workflow, you create a Credential object through the Credential Manager UI. This object holds the encrypted access data for that service.&lt;/p&gt;

&lt;p&gt;Each node that needs authentication is assigned a &lt;code&gt;CredentialId&lt;/code&gt; in its configuration. At runtime, the engine resolves that reference through the &lt;code&gt;ICredentialProvider&lt;/code&gt;, decrypts the credential in-memory, and provides it to the node instance. The raw secrets never appear in workflow JSON, API responses, or execution logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// How a node receives its credential at execution time&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;NodeInput&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inputData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evaluatedConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CredentialId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CredentialId&lt;/span&gt;  &lt;span class="c1"&gt;// Reference, not the actual secret&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Three Storage Backends
&lt;/h3&gt;

&lt;p&gt;We support three credential storage providers, configurable via &lt;code&gt;appsettings.json&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Config Value&lt;/th&gt;
&lt;th&gt;How Secrets Are Stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BuiltIn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AES-256 encrypted in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HashiCorp Vault&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HashiCorpVault&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vault KV v2 secret engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenBao&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OpenBao&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenBao KV v2 secret engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three share the same &lt;code&gt;ICredentialService&lt;/code&gt; interface and &lt;code&gt;CredentialValidator&lt;/code&gt; logic. The choice of backend is transparent to the rest of the system — nodes never know where their credentials are stored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encryption at Rest (Built-in Provider)
&lt;/h3&gt;

&lt;p&gt;For the built-in provider, we use &lt;code&gt;AesCredentialEncryption&lt;/code&gt; with AES-256. The master encryption key is managed via environment variables — never stored in &lt;code&gt;appsettings.json&lt;/code&gt; or committed to source control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AesCredentialEncryption&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ICredentialEncryption&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Encrypts credential data before DB persistence&lt;/span&gt;
    &lt;span class="c1"&gt;// Decrypts on-demand when a node needs access&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if a database backup is compromised, the actual credentials remain encrypted and unusable without the master key.&lt;/p&gt;

&lt;h3&gt;
  
  
  External Secrets Managers (Vault / OpenBao)
&lt;/h3&gt;

&lt;p&gt;For enterprises that already manage secrets centrally, the &lt;code&gt;VaultCredentialService&lt;/code&gt; delegates all secret storage to HashiCorp Vault or OpenBao. Metadata (name, type, associations) stays in the database, but the actual sensitive values live in the vault's KV v2 engine.&lt;/p&gt;

&lt;p&gt;This gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Centralized secret rotation without touching Vyshyvanka&lt;/li&gt;
&lt;li&gt;Audit trails from the vault itself&lt;/li&gt;
&lt;li&gt;Integration with existing enterprise key management&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Authentication Providers
&lt;/h3&gt;

&lt;p&gt;Vyshyvanka supports four authentication strategies, selectable at deployment time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Session Tokens&lt;/th&gt;
&lt;th&gt;Login Flow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Local JWT&lt;/td&gt;
&lt;td&gt;Username/password via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak&lt;/td&gt;
&lt;td&gt;External OIDC&lt;/td&gt;
&lt;td&gt;Redirect to Keycloak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentik&lt;/td&gt;
&lt;td&gt;External OIDC&lt;/td&gt;
&lt;td&gt;Redirect to Authentik&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDAP&lt;/td&gt;
&lt;td&gt;Local JWT&lt;/td&gt;
&lt;td&gt;Verify against directory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For OIDC providers, the API validates external tokens and an &lt;code&gt;OidcClaimsTransformation&lt;/code&gt; middleware provisions local users on first login. For LDAP, the &lt;code&gt;LdapAuthenticationService&lt;/code&gt; verifies credentials against the directory server.&lt;/p&gt;

&lt;p&gt;Additionally, &lt;strong&gt;API key authentication&lt;/strong&gt; (&lt;code&gt;X-API-Key&lt;/code&gt; header) is always available regardless of the primary provider — useful for CI/CD integrations and automated workflow triggers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node-Level Auth Strategies
&lt;/h3&gt;

&lt;p&gt;Our node architecture supports various authentication strategies per credential type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Basic Auth:&lt;/strong&gt; Username/password for legacy services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bearer Tokens:&lt;/strong&gt; The standard for most modern REST APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Keys:&lt;/strong&gt; Header or query parameter based&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Headers:&lt;/strong&gt; For services with non-standard auth schemes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each node that requires credentials declares this with the &lt;code&gt;[RequiresCredential]&lt;/code&gt; attribute, and the Designer UI automatically shows the credential picker for that node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security Rules We Enforce
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Always:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encrypt credentials at rest (AES-256 or delegate to Vault/OpenBao)&lt;/li&gt;
&lt;li&gt;Validate resource ownership via &lt;code&gt;ICurrentUserService&lt;/code&gt; before any operation&lt;/li&gt;
&lt;li&gt;Use parameterized queries for all database operations&lt;/li&gt;
&lt;li&gt;Sanitize user input in expressions to prevent injection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Never:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return credential values in any API response&lt;/li&gt;
&lt;li&gt;Log credentials or sensitive data at any log level&lt;/li&gt;
&lt;li&gt;Allow cross-user workflow access without explicit sharing&lt;/li&gt;
&lt;li&gt;Store Vault/OpenBao tokens in plain text in config files&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Best Practices
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotate your keys:&lt;/strong&gt; The credential store allows you to update a secret without modifying workflow logic. The &lt;code&gt;CredentialId&lt;/code&gt; reference stays the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use scoped permissions:&lt;/strong&gt; If a service supports it, create API keys with minimum required permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the right backend:&lt;/strong&gt; Use Vault/OpenBao for production environments where compliance and audit trails matter. The built-in provider works well for development and small deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Security is not a feature you add at the end; it is the foundation on which everything else is built.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 10: Plugin System Architecture - Extensibility by Design. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>security</category>
      <category>csharp</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Part 8: Persistence and State - EF Core, Migrations, and Reliability</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Wed, 17 Jun 2026 08:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-8-persistence-and-state-ef-core-migrations-and-reliability-2d6o</link>
      <guid>https://dev.to/homolibere/part-8-persistence-and-state-ef-core-migrations-and-reliability-2d6o</guid>
      <description>&lt;p&gt;In the last part, we looked at how expressions make your workflows dynamic. Today, we are discussing the backbone of any production-ready system: Persistence. How do we keep your workflow data, execution history, and node states safe, even if the server crashes or restarts?&lt;/p&gt;

&lt;h3&gt;
  
  
  EF Core: Our Reliable Partner
&lt;/h3&gt;

&lt;p&gt;Vyshyvanka uses Entity Framework Core (EF Core) as the primary abstraction for database interaction. It allows us to work with our domain entities in a type-safe, object-oriented way while maintaining the flexibility to target different database backends.&lt;/p&gt;

&lt;p&gt;For development, we default to &lt;strong&gt;SQLite&lt;/strong&gt; because it makes setting up local testing environments trivial — no external services, no Docker containers, just a file. For production, we support &lt;strong&gt;PostgreSQL&lt;/strong&gt;, which gives us the robustness, scalability, and feature set required for high-volume automation tasks.&lt;/p&gt;

&lt;p&gt;The switch between backends is handled at startup via configuration and .NET Aspire service wiring, so the same application code works seamlessly against both.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Migration Workflow
&lt;/h3&gt;

&lt;p&gt;One of the most important rules in our development culture is this: &lt;strong&gt;Never use &lt;code&gt;EnsureCreatedAsync()&lt;/code&gt; in production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While it is tempting for quick prototyping, relying on auto-generation for production schemas is a recipe for disaster. We strictly use EF Core Migrations. This approach provides a version-controlled history of your database schema, ensuring that deployments are predictable and that we can roll back if something goes wrong.&lt;/p&gt;

&lt;p&gt;Our command for adding a migration is standardized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet ef migrations add &amp;lt;Name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt; src/Vyshyvanka.Engine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--startup-project&lt;/span&gt; src/Vyshyvanka.Api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-dir&lt;/span&gt; Persistence/Migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At startup, the application automatically applies any pending migrations with &lt;code&gt;MigrateAsync()&lt;/code&gt;. This ensures that every deployment brings the schema up to date without manual intervention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separation of Concerns
&lt;/h3&gt;

&lt;p&gt;Our persistence layer follows a clean separation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Vyshyvanka.Engine/Persistence/VyshyvankaDbContext.cs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The EF Core DbContext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Vyshyvanka.Engine/Persistence/Entities/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Entity classes (DB table mappings)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Vyshyvanka.Engine/Persistence/Migrations/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-generated migration files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Vyshyvanka.Core/Interfaces/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Repository interfaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Vyshyvanka.Engine/Persistence/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Repository implementations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This ensures that the domain layer (Core) never depends on EF Core or any specific database technology. The engine provides the implementation details.&lt;/p&gt;

&lt;h3&gt;
  
  
  State Separation
&lt;/h3&gt;

&lt;p&gt;We persist the &lt;strong&gt;workflow state&lt;/strong&gt; (the graph structure — nodes, connections, configuration) and the &lt;strong&gt;execution state&lt;/strong&gt; (the runtime status of every node during a run) separately. This separation allows us to perform high-frequency updates on the execution state without modifying the workflow definition itself.&lt;/p&gt;

&lt;p&gt;When an execution transitions states, we use database transactions to ensure atomicity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The node execution result is saved.&lt;/li&gt;
&lt;li&gt;The overall execution status is updated.&lt;/li&gt;
&lt;li&gt;Terminal states (&lt;code&gt;Completed&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt;, &lt;code&gt;Cancelled&lt;/code&gt;) are final — no further writes allowed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This prevents zombie executions or partially processed data from corrupting your results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Async All the Way Down
&lt;/h3&gt;

&lt;p&gt;Every repository method follows our async-first pattern with &lt;code&gt;CancellationToken&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Workflows&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures that database I/O never blocks threads, keeping the engine responsive even under heavy load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing Our Persistence
&lt;/h3&gt;

&lt;p&gt;Because we use EF Core, we can leverage lightweight in-memory providers to write unit tests for our data access logic without needing a real database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;WhenWorkflowSavedThenCanBeRetrieved&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DbContextOptionsBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;VyshyvankaDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseInMemoryDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;databaseName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;VyshyvankaDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;WorkflowRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateTestWorkflow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;NotBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;HaveCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For integration tests, we use &lt;code&gt;WebApplicationFactory&lt;/code&gt; with a real SQLite database to test the full API-to-database path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credential Storage
&lt;/h3&gt;

&lt;p&gt;Credentials get special treatment in our persistence layer. The &lt;code&gt;CredentialEntity&lt;/code&gt; stores encrypted data — never plain text. The encryption key is managed externally (environment variables or a secrets manager). Even if someone gets access to the database, the credential values remain protected by AES-256 encryption.&lt;/p&gt;

&lt;p&gt;Persistence is the quiet backbone of Vyshyvanka. By respecting migrations, separating concerns, and testing at every level, we ensure that your workflow data is always safe, consistent, and recoverable.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 9: Security First - Credentials, Authentication, and Secrets Management. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>csharp</category>
      <category>database</category>
    </item>
    <item>
      <title>Part 7: Expression Language Syntax - Powering Logic in Nodes</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Tue, 16 Jun 2026 08:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-7-expression-language-syntax-powering-logic-in-nodes-111j</link>
      <guid>https://dev.to/homolibere/part-7-expression-language-syntax-powering-logic-in-nodes-111j</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Expressions?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Double-Brace Syntax
&lt;/h3&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Resolves To&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ nodes.&amp;lt;nodeId&amp;gt;.data }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full JSON output of a previous node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ nodes.&amp;lt;nodeId&amp;gt;.data.prop }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nested property access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ nodes.&amp;lt;nodeId&amp;gt;.data.items[0].name }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Array index + property&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ variables.executionId }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current execution ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ variables.workflowId }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current workflow ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;h3&gt;
  
  
  How it Works
&lt;/h3&gt;

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

&lt;p&gt;A key design decision: if the entire configuration value is a single expression (e.g., &lt;code&gt;{{ nodes.api.data }}&lt;/code&gt;), the evaluator returns the &lt;strong&gt;actual typed value&lt;/strong&gt; (a &lt;code&gt;JsonElement&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in Functions
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;String functions:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;toUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.webhook.data.name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;toLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.form.data.input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"http://"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"https://"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.user.data.firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nodes.user.data.lastName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Date/time functions:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;utcNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.db.data.createdAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"yyyy-MM-dd"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.schedule.data.dueDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Math functions:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.calc.data.total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.diff.data.value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.a.data.x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nodes.b.data.y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Type conversion and logic:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.form.data.quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.api.data.nickname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nodes.api.data.name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Anonymous"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;iif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nodes.check.data.isActive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validation
&lt;/h3&gt;

&lt;p&gt;Before a workflow executes, you can validate expressions with the &lt;code&gt;Validate&lt;/code&gt; method. It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That expression syntax is well-formed (matching &lt;code&gt;{{ }}&lt;/code&gt; braces)&lt;/li&gt;
&lt;li&gt;That paths start with a valid root (&lt;code&gt;nodes&lt;/code&gt; or &lt;code&gt;variables&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;That paths are complete (not just &lt;code&gt;{{ nodes }}&lt;/code&gt; without a node ID)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evaluator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{ nodes.api.data.items[0].id }}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// result.IsValid == true&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;badResult&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evaluator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{ credentials.apiKey }}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// result.IsValid == false&lt;/span&gt;
&lt;span class="c1"&gt;// Error: "Unknown expression root 'credentials'. Expected 'nodes' or 'variables'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Security First
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;We treat this with extreme care:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxed Evaluation:&lt;/strong&gt; We do not use &lt;code&gt;eval()&lt;/code&gt; or equivalent code execution. We use a strictly controlled property resolver that can only access the execution context through well-defined paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled Function Set:&lt;/strong&gt; The available functions are a fixed, curated set compiled into the engine. Users cannot register custom functions or call arbitrary methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitization:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Side Effects:&lt;/strong&gt; Expression functions are pure — they cannot modify the execution context, perform network calls, or write to disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Building Dynamic Workflows
&lt;/h3&gt;

&lt;p&gt;This syntax is how you build a real workflow. Imagine a sequence where:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A WebhookTrigger receives an order payload.&lt;/li&gt;
&lt;li&gt;An HttpRequest node calls an external API using &lt;code&gt;{{ nodes.webhook.data.orderId }}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A Switch node checks the result with &lt;code&gt;{{ nodes.http.data.status }}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An EmailSend node formats a message with &lt;code&gt;{{ concat("Order ", nodes.webhook.data.orderId, " is ", toLower(nodes.http.data.status)) }}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step references the output of a previous step dynamically. The workflow adapts to whatever data flows through it at runtime.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 8: Persistence and State - EF Core, Migrations, and Reliability. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>csharp</category>
      <category>security</category>
    </item>
    <item>
      <title>Part 6: Engine Internals - The Execution Pipeline</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Mon, 15 Jun 2026 08:01:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-6-engine-internals-the-execution-pipeline-20o9</link>
      <guid>https://dev.to/homolibere/part-6-engine-internals-the-execution-pipeline-20o9</guid>
      <description>&lt;p&gt;We have seen how you design a workflow and how the domain model keeps it strictly type-safe. Today, we are peeling back the most complex layer of Vyshyvanka: The Execution Pipeline. This is where the graph you drew in the browser becomes a series of actual operations executing on the server.&lt;/p&gt;

&lt;h3&gt;
  
  
  From Graph to Execution
&lt;/h3&gt;

&lt;p&gt;When you hit 'Run', the engine doesn't just start running nodes randomly. It first performs a &lt;strong&gt;topological sort&lt;/strong&gt; on your directed graph using &lt;code&gt;BuildExecutionLevels&lt;/code&gt;. This groups nodes into execution levels - nodes at the same level have no dependencies on each other and can run in parallel, while each level depends only on previous levels.&lt;/p&gt;

&lt;p&gt;If Node B needs the output of Node A, the topological sort guarantees Node A finishes first. If there's a circular dependency in your graph, our validator catches it before it ever hits the engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Life Cycle of an Execution
&lt;/h3&gt;

&lt;p&gt;Every run is an immutable record that transitions through a strict state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pending ──→ Running ──→ Completed
  │            │
  │            ├──→ Failed
  │            │
  └──→ Cancelled ←──┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pending:&lt;/strong&gt; The trigger has fired, and we have initialized the execution context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running:&lt;/strong&gt; We start processing the sorted levels of nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal States:&lt;/strong&gt; Once a workflow hits &lt;code&gt;Completed&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt;, or &lt;code&gt;Cancelled&lt;/code&gt;, that's it. No further transitions allowed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We don't allow mutable state transitions back from a terminal state. This ensures that every execution record is an audit-ready snapshot that you can trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Pipeline at Work
&lt;/h3&gt;

&lt;p&gt;The pipeline itself is fully asynchronous. We leverage .NET's &lt;code&gt;async/await&lt;/code&gt; throughout the entire chain, passing a &lt;code&gt;CancellationToken&lt;/code&gt; into every method. If a user cancels a workflow or the server initiates a graceful shutdown, every single node in the pipeline receives that signal and halts immediately.&lt;/p&gt;

&lt;p&gt;Here is a simplified view of the core execution loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExecutionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Workflow&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;executionLevels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;BuildExecutionLevels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;GetEffectiveMaxParallelism&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxDegreeOfParallelism&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;executionLevels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;levelResults&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ExecuteLevelWithThrottlingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Check StopOnFirstError policy&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrorHandling&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ErrorHandlingMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StopOnFirstError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;failedResult&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;levelResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failedResult&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;BuildResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the pipeline, each node goes through several stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Input Gathering:&lt;/strong&gt; We collect outputs from upstream nodes via connection mapping. Multi-input nodes receive a merged JSON object keyed by target port name.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Expression Evaluation:&lt;/strong&gt; All &lt;code&gt;{{ nodes.someNode.data }}&lt;/code&gt; expressions in the node configuration are resolved in real-time from the execution context before the node runs.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Node Execution:&lt;/strong&gt; We invoke &lt;code&gt;ExecuteAsync&lt;/code&gt; on the node instance. Since all nodes inherit from our base classes (&lt;code&gt;BaseTriggerNode&lt;/code&gt;, &lt;code&gt;BaseActionNode&lt;/code&gt;, &lt;code&gt;BaseLogicNode&lt;/code&gt;), the engine handles them uniformly regardless of what they actually do.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Output Storage:&lt;/strong&gt; Successful node outputs are stored in the context, ready for downstream nodes to reference.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Parallel Branch Execution
&lt;/h3&gt;

&lt;p&gt;Nodes at the same execution level run in parallel, throttled by a &lt;code&gt;SemaphoreSlim&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeExecutionResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteLevelWithThrottlingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Workflow&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ConcurrentBag&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeExecutionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxParallelism&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ExecuteNodeInWorkflowAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodeResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default max parallelism is &lt;code&gt;Environment.ProcessorCount * 2&lt;/code&gt;, but you can configure it per workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Failure
&lt;/h3&gt;

&lt;p&gt;What happens when a node fails? The engine supports two error handling modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;StopOnFirstError:&lt;/strong&gt; Stops the entire workflow immediately when any node fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ContinueOnError:&lt;/strong&gt; Marks the failed node and continues executing independent branches. Dependent nodes on the failed branch are skipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because we have that immutable execution record, you can see exactly which node caused the issue, what data it received, and the error message — all structured in the &lt;code&gt;NodeExecutionResult&lt;/code&gt; list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plugin Node Isolation
&lt;/h3&gt;

&lt;p&gt;Plugin nodes (from external assemblies) are executed through &lt;code&gt;IPluginHost.ExecuteNodeInIsolationAsync&lt;/code&gt;, which adds an additional timeout layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeOutput&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteNodeInstanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;INode&lt;/span&gt; &lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NodeInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IExecutionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_pluginHost&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_pluginHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsPluginNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pluginHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteNodeInIsolationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;nodeInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures that a misbehaving plugin cannot hang the entire engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Asynchronous?
&lt;/h3&gt;

&lt;p&gt;We chose async-first because workflow engines are inherently I/O bound. You are constantly waiting for databases, external APIs, and file systems. By using &lt;code&gt;async/await&lt;/code&gt; everywhere, we ensure that while one workflow waits for a response, the thread is returned to the pool to serve other workflows. This is how a single engine instance can manage hundreds of concurrent executions without thread pool starvation.&lt;/p&gt;

&lt;p&gt;In the next part, we will discuss Part 7: Expression Language Syntax - How dynamic data flows between your nodes. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>csharp</category>
      <category>performance</category>
    </item>
    <item>
      <title>Part 5: Designing the Designer - Building a Blazor WASM Experience</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Fri, 12 Jun 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-5-designing-the-designer-building-a-blazor-wasm-experience-me7</link>
      <guid>https://dev.to/homolibere/part-5-designing-the-designer-building-a-blazor-wasm-experience-me7</guid>
      <description>&lt;p&gt;In our last deep dive, we talked about the logic and data structures that drive Vyshyvanka. But software isn't just about clean backends; it's about how the user interacts with those systems. Today, we're stepping into the browser to talk about the Vyshyvanka Designer - our Blazor WebAssembly canvas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Blazor WebAssembly?
&lt;/h3&gt;

&lt;p&gt;When we decided to build a visual workflow designer, we had a few options: React, Vue, or maybe a native desktop app. We chose Blazor WebAssembly for a few critical reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shared Logic:&lt;/strong&gt; By staying within the .NET ecosystem, we can share domain models and validation logic between our Engine and the Designer. When we update a node definition in the Core, the Designer understands those changes instantly without rewriting types in TypeScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; Blazor WASM gives us the performance of a compiled language inside the browser, which is crucial when you're dragging and dropping dozens of nodes or panning around a large workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer Velocity:&lt;/strong&gt; Our team works best in C#. Having a single language for both our ASP.NET Core API and our frontend allows us to move fast without the context switching of a JavaScript-heavy stack.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Designer Architecture
&lt;/h3&gt;

&lt;p&gt;The Designer isn't one big component; it's a system of decomposed services designed to keep the UI snappy and maintainable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;WorkflowStore:&lt;/strong&gt; The single source of truth. It holds your workflow data, node definitions, and the 'dirty' flag that tells the UI when you have unsaved changes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;WorkflowEditService:&lt;/strong&gt; This is the brains of the operation. Whenever you drag a node, add a connection, or delete a step, the EditService handles the business logic. It doesn't worry about rendering; it just worries about state.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;WorkflowValidationService:&lt;/strong&gt; We run validation in the browser &lt;em&gt;as you edit&lt;/em&gt;. If you try to connect an 'Object' output to a 'Boolean' input, the UI turns red before you even let go of your mouse.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;CanvasStateService:&lt;/strong&gt; Handles the 'feel' of the application. Panning, zooming, node selection, and undo/redo stacks are managed here.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Communicating with the Engine
&lt;/h3&gt;

&lt;p&gt;We made a hard architectural choice early on: &lt;strong&gt;The Designer communicates exclusively via HTTP.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;There are no direct WebSocket connections or shared memory paths to the Engine. By forcing all interaction through the &lt;code&gt;WorkflowApiClient&lt;/code&gt;, we ensure the Designer remains a pure client. This means we can treat the entire Designer as a standalone application that could be hosted anywhere. It doesn't care if the engine is running on your machine, in a Docker container, or in the cloud—as long as it can hit the API, it works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component Design Rules
&lt;/h3&gt;

&lt;p&gt;To keep our UI code clean, we enforce strict rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;No &lt;code&gt;@code&lt;/code&gt; blocks in .razor files:&lt;/strong&gt; All logic lives in &lt;code&gt;Name.razor.cs&lt;/code&gt; partial classes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Injection over &lt;code&gt;@inject&lt;/code&gt;:&lt;/strong&gt; We use &lt;code&gt;[Inject]&lt;/code&gt; attributes in our code-behind to keep the markup clean and the dependencies explicit.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;One Component, Three Files:&lt;/strong&gt; Every visual piece follows a &lt;code&gt;Name.razor&lt;/code&gt;, &lt;code&gt;Name.razor.cs&lt;/code&gt;, and &lt;code&gt;Name.razor.css&lt;/code&gt; pattern. It keeps our styles isolated and our logic readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Goal: Real-Time Clarity
&lt;/h3&gt;

&lt;p&gt;The goal of the Designer isn't just to look good; it's to provide real-time clarity. You should know exactly what your workflow is doing at every moment. By integrating our validation service directly into the canvas, we turn the design phase into an active conversation between you and the system.&lt;/p&gt;

&lt;p&gt;Building a visual editor in Blazor was an adventure, but the results—a type-safe, performant, and deeply integrated experience—made it all worth it.&lt;/p&gt;

&lt;p&gt;In the next part, we'll look at the execution engine internals: &lt;strong&gt;The Pipeline&lt;/strong&gt;. We'll explain how we resolve the graph, manage node execution order, and handle the state machine from 'Pending' to 'Completed'. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Part 4: The Domain Model - Understanding Workflows, Nodes, and Connections</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/homolibere/part-4-the-domain-model-understanding-workflows-nodes-and-connections-425c</link>
      <guid>https://dev.to/homolibere/part-4-the-domain-model-understanding-workflows-nodes-and-connections-425c</guid>
      <description>&lt;p&gt;In our last deep dive, we looked at how Vyshyvanka is put together under the hood. Today, we're zooming in on what actually makes the engine tick: the Domain Model. At the end of the day, Vyshyvanka isn't just a collection of services; it's a system designed to manage a directed graph of operations that we call a Workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workflow: A Directed Graph
&lt;/h3&gt;

&lt;p&gt;At its heart, a workflow is a directed graph where nodes represent operations and connections represent the flow of data. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Exactly One Trigger:&lt;/strong&gt; Every workflow needs a starting point. Whether it's a webhook, a timer, or a manual button press, we enforce that there is exactly one trigger node.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Ownership:&lt;/strong&gt; Every workflow is owned by a single user, ensuring that permissions and access are managed consistently across the board.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Nodes: The Atomic Operations
&lt;/h3&gt;

&lt;p&gt;Nodes are the building blocks of your automation. We categorize them based on their behavior, which dictates their base class in our engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trigger Nodes:&lt;/strong&gt; These start the workflow. They have no incoming connections because they define the entry point. Examples include our WebhookTrigger or ScheduleTrigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action Nodes:&lt;/strong&gt; These do the actual work. Think of them as your worker bees - sending an email, querying a database, or calling an external HTTP API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic Nodes:&lt;/strong&gt; These are the brains. They allow you to branch execution (If/Switch), create loops, or merge data streams.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you implement a new node in Vyshyvanka, you simply inherit from the appropriate base class, override the &lt;code&gt;ExecuteAsync&lt;/code&gt; method, and register it in the &lt;code&gt;NodeRegistry&lt;/code&gt;. The engine takes care of the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connections and Type Safety
&lt;/h3&gt;

&lt;p&gt;One of the most common sources of bugs in automation platforms is passing incompatible data between steps. We fixed that with a robust Port Type system.&lt;/p&gt;

&lt;p&gt;A connection is valid if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  The source port type equals the target port type.&lt;/li&gt;
&lt;li&gt;  Either port is an 'Any' type, which acts as a wildcard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our supported types include &lt;strong&gt;Object, Array, String, Number, and Boolean&lt;/strong&gt;. By enforcing this at the designer level, we ensure that by the time a workflow hits the engine, it's guaranteed to be syntactically and logically sound.&lt;/p&gt;

&lt;h3&gt;
  
  
  Execution: The Immutable Record
&lt;/h3&gt;

&lt;p&gt;When a workflow runs, we create an Execution record. This is an immutable snapshot of that specific run. Why immutable? Because transparency matters. Once an execution is marked as 'Completed', 'Failed', or 'Cancelled', it is final. You can trace back exactly what data was passed to each node, what the result was, and how the state machine transitioned.&lt;/p&gt;

&lt;p&gt;This transition logic is rigid:&lt;br&gt;
Pending -&amp;gt; Running -&amp;gt; (Completed | Failed | Cancelled)&lt;/p&gt;

&lt;p&gt;Terminal states are final, and that is a promise the engine keeps. This prevents any accidental state mutations or 'zombie' executions that haunt other automation systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validation Rules
&lt;/h3&gt;

&lt;p&gt;Before a workflow can even be executed, it must pass a battery of checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  No circular dependencies.&lt;/li&gt;
&lt;li&gt;  Every node must be reachable from the trigger.&lt;/li&gt;
&lt;li&gt;  Required properties must be set.&lt;/li&gt;
&lt;li&gt;  All connections must satisfy port compatibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pre-execution validation means that when you hit 'Run', you can be confident that the workflow isn't going to fail because of a simple wiring mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters
&lt;/h3&gt;

&lt;p&gt;By building a strict, type-safe domain model, we've turned what is usually a fragile scripting process into a robust software engineering task. You aren't just "writing a script"; you are defining a formal model that the system understands and can verify.&lt;/p&gt;

&lt;p&gt;In the next part, we'll look at the front end of this equation: &lt;strong&gt;The Designer&lt;/strong&gt;. We'll explain how we built a Blazor WebAssembly canvas that allows you to drag, drop, and wire up these nodes in real time. Stay tuned!&lt;/p&gt;




&lt;p&gt;Check out the project source code here: &lt;a href="https://github.com/homolibere/Vyshyvanka" rel="noopener noreferrer"&gt;https://github.com/homolibere/Vyshyvanka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>csharp</category>
      <category>ddd</category>
    </item>
  </channel>
</rss>
