<?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: Andrii B.</title>
    <description>The latest articles on DEV Community by Andrii B. (@andriiboyko).</description>
    <link>https://dev.to/andriiboyko</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%2F3148430%2F126f7bdb-5753-4dde-9bb7-8658c467fa67.png</url>
      <title>DEV Community: Andrii B.</title>
      <link>https://dev.to/andriiboyko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andriiboyko"/>
    <language>en</language>
    <item>
      <title>Restate's Single Binary vs Temporal's Cluster: When the Lighter Engine Wins</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Sun, 09 Aug 2026 19:05:41 +0000</pubDate>
      <link>https://dev.to/andriiboyko/restates-single-binary-vs-temporals-cluster-when-the-lighter-engine-wins-3635</link>
      <guid>https://dev.to/andriiboyko/restates-single-binary-vs-temporals-cluster-when-the-lighter-engine-wins-3635</guid>
      <description>&lt;p&gt;Here is a bet that will save you a lot of arguing: pick your durable execution engine on what you have to &lt;em&gt;operate&lt;/em&gt;, not on what you have to &lt;em&gt;write&lt;/em&gt;. The code you write for Restate and the code you write for Temporal end up looking more alike than either vendor wants to admit. They both journal every step and replay it after a crash so your half-finished order doesn't get charged twice. What actually differs, and what you'll be living with at 2am, is the shape of the thing you have to run. One is a single binary. The other is a cluster of four services with an external database bolted to the side.&lt;/p&gt;

&lt;p&gt;That's the whole article in one sentence, but the sentence hides all the interesting parts: when the cluster is exactly what you want, when it's a tax you're paying for nothing, and why "single binary" isn't quite as simple as it sounds either. Let's get specific, because the marketing on both sides is loud and the honest version is more useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same problem both are solving
&lt;/h2&gt;

&lt;p&gt;Durable execution is a narrow, beautiful idea. Your code runs a multi-step operation: charge the card, reserve the inventory, send the email, mark the order shipped. Halfway through, the process dies. A normal service loses everything in memory and you're left reconciling a card that got charged against inventory that never got reserved. A durable execution engine writes each completed step to a journal, and when the process comes back it replays that journal, skips the steps that already finished, and continues from exactly where it stopped. No double charge, no lost reservation.&lt;/p&gt;

&lt;p&gt;Both Restate and Temporal do this. Both do it well. So the first thing to throw out is any pitch that frames one as "durable" and the other as "less durable." They are both real durable execution engines built on journal-and-replay. If someone is selling you on durability itself as the differentiator, they're selling you the thing you get from either one. The differentiator is everything around the journal: where it lives, what runs it, and what your app has to become to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temporal is a cluster, and your app becomes two services
&lt;/h2&gt;

&lt;p&gt;Temporal's server is not one process. It's four independently scalable services: a &lt;strong&gt;Frontend&lt;/strong&gt; that acts as the gateway (routing, rate limiting, auth), a &lt;strong&gt;History&lt;/strong&gt; service that owns the mutable workflow state and timers, a &lt;strong&gt;Matching&lt;/strong&gt; service that hosts the task queues that dispatch work, and an internal &lt;strong&gt;Worker&lt;/strong&gt; service for Temporal's own system workflows. Each is a separate process with its own gRPC endpoint, and the whole point of splitting them is that you scale them independently when you're big enough to need that.&lt;/p&gt;

&lt;p&gt;That cluster can't remember anything on its own. It requires an external database for persistence: PostgreSQL or MySQL in practice (SQLite exists for local dev). If you were about to reach for Cassandra because an old blog post told you to, don't: Cassandra was deprecated in Temporal Server v1.21 and removed in v1.24. And once you spawn more than a handful of workflows and want to search them by anything richer than an ID, you're looking at Elasticsearch or OpenSearch. That last one is a genuine nuance the internet gets wrong in both directions: SQL databases have supported Advanced Visibility since Server v1.20, so Elasticsearch is not strictly required. It's recommended once your volume grows. "Temporal always needs Elasticsearch" is a myth; "you'll probably want it eventually" is the truth.&lt;/p&gt;

&lt;p&gt;Then there's your own code. In Temporal, your workflow logic runs inside a &lt;strong&gt;Worker&lt;/strong&gt;, a process that hosts your workflow and activity code and long-polls the cluster's task queues for work. That worker is a separate deployable from your API. So adopting Temporal isn't just "run a server." It's: stand up the cluster, attach a database, probably add a search cluster later, and split your application into an API service plus a worker service that both depend on the Temporal server being up. Here's the shape of the code that runs in that worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// activities.ts - the side effects, retryable, run in a normal runtime&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;greet&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`Hello, &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="s2"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// workflows.ts - orchestration, and it must be deterministic&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;proxyActivities&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@temporalio/workflow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;activities&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./activities&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;greet&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxyActivities&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;activities&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;startToCloseTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 minute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;example&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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="nf"&gt;greet&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice you don't call &lt;code&gt;greet&lt;/code&gt; directly from the workflow. You go through &lt;code&gt;proxyActivities&lt;/code&gt;, because the workflow code is replayed from its event history on every recovery and must be deterministic: the same input has to produce the same sequence of commands every time, or Temporal raises a nondeterminism error and refuses to continue. That constraint is the price of Temporal's replay model, and it's a real thing you have to design around (no &lt;code&gt;Date.now()&lt;/code&gt; in a workflow, no random, no direct I/O). Temporal Cloud exists precisely so you don't have to run the cluster and database yourself, and if you're going to commit to Temporal at scale, paying them to operate it is usually the sane choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Frestate-vs-temporal-durable-execution%2Fone-binary-vs-a-cluster.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Frestate-vs-temporal-durable-execution%2Fone-binary-vs-a-cluster.avif" alt="Restate as one binary with embedded RocksDB and object-store snapshots, beside Temporal's four-service cluster with an external Postgres, an optional Elasticsearch, and a separate API and worker" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Restate is one binary, and the database is already inside it
&lt;/h2&gt;

&lt;p&gt;Restate takes the opposite bet. The server is a single binary written in Rust with a stream-processing architecture, and it carries its own storage: an embedded RocksDB key-value store holds the journal and the durable state. There is no external Postgres to provision, no Elasticsearch to babysit. You download one binary, you run it, and you have durable execution.&lt;/p&gt;

&lt;p&gt;The honest asterisk, because Restate's own comparison page glosses over it: "single binary" does not mean "single process and nothing else" once you care about high availability. For HA you run several instances of that binary, and RocksDB periodically snapshots to an object store (S3, GCS, or Azure Blob) so a node can fail and another can recover the state and trim its logs. So the real comparison isn't "one process vs a cluster." It's "several copies of one binary plus a bucket" versus "four service types plus a relational database plus an optional search cluster plus your own split-out worker." That's still a dramatic difference in operational surface, but say it accurately or someone will call your bluff in the comments.&lt;/p&gt;

&lt;p&gt;The programming model is ordinary-looking service handlers that Restate journals for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@restatedev/restate-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;service&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MyService&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;myHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;greeting&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="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;myService&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;Restate's distinctive primitive is the &lt;strong&gt;Virtual Object&lt;/strong&gt;: a stateful entity keyed by an id, with its own isolated key-value state and a single-writer guarantee, so only one handler mutates a given object's state at a time. It's durable keyed state without you standing up a separate store for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@restatedev/restate-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MyObject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;myHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ObjectContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;greeting&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&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="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;restate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;myObject&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;Restate also gives you durable promises (awakeables) to wait on external events, and durable timers via &lt;code&gt;ctx.sleep&lt;/code&gt;, all tracked across failures. It has SDKs for TypeScript, Java and Kotlin, Python, Go, and Rust. And crucially, it does not impose Temporal's hard determinism contract on your handler in the same way: the durable steps go through the context, and the framework replays the journal rather than re-running your whole function as strictly-deterministic orchestration. The scope is also framed more broadly. Temporal sells itself as workflow orchestration; Restate positions as durable execution for "any part of your backend," workflows, agents, microservices, event handlers. Whether that breadth matters to you is a real question, not just a slogan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The programming models diverge more than the marketing admits
&lt;/h2&gt;

&lt;p&gt;It's tempting to say "they're both durable execution, the code is basically the same." It isn't, and the difference is exactly the kind of thing that bites you six months in.&lt;/p&gt;

&lt;p&gt;Temporal's model is a hard split between &lt;strong&gt;deterministic workflows&lt;/strong&gt; and &lt;strong&gt;side-effecting activities&lt;/strong&gt;, enforced by replay. That split is powerful: it's what lets a workflow sleep for thirty days and wake up with its local variables intact, because Temporal isn't keeping your process alive, it's replaying your workflow's event history to reconstruct that state on demand. But it means your orchestration code lives under a determinism microscope. Every nondeterministic thing you're used to reaching for is a landmine, and "why did my workflow throw a nondeterminism error after I changed the code" is a rite of passage.&lt;/p&gt;

&lt;p&gt;Restate's model asks less of your mental model up front: you write handlers, you use the context for the durable operations, and durable keyed state is a Virtual Object rather than a workflow-scoped variable you're forbidden from computing nondeterministically. For a lot of backend work, "make this handler crash-proof and give it some durable state" is all you wanted, and you get it without adopting the full workflow-orchestration worldview. The flip side: Temporal's worldview, once you've paid for it, is genuinely better at the gnarliest long-running orchestration, and its retry and timeout configuration is deeper.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Frestate-vs-temporal-durable-execution%2Fdeterministic-workflow-vs-journaled-handler.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Frestate-vs-temporal-durable-execution%2Fdeterministic-workflow-vs-journaled-handler.avif" alt="Temporal's deterministic workflow calling an activity through proxyActivities with event-history replay, beside Restate's journaled handler with a Virtual Object holding durable keyed state" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So how much of this is code, and how much is ops?
&lt;/h2&gt;

&lt;p&gt;This is where the "mostly ops, not code" claim earns or loses its keep. The code delta is real but modest: with either engine you annotate or restructure some functions and route durable steps through a context. The operational delta is not modest at all.&lt;/p&gt;

&lt;p&gt;There's a widely-cited number here, and it's worth handling honestly because it's easy to misuse. DBOS, which is itself a competitor to both, published a benchmark where adopting Temporal on a sample app meant changing more than 100 lines, growing the app from 110 to 187 total lines, and splitting it into two services (a worker and the API) with a runtime dependency on a third (the Temporal server), three tightly-coupled services where any one going down takes the other two with it. That's a vendor's benchmark of DBOS versus Temporal, on one sample app, so don't quote it as a law of nature and definitely don't attribute it to Restate. But the &lt;em&gt;architectural&lt;/em&gt; claim underneath it is neutral and verifiable straight from Temporal's own docs: your workflow code runs in a separate worker deployable, and the cluster plus its database are separate infrastructure. That part isn't marketing. It's how Temporal is built.&lt;/p&gt;

&lt;p&gt;For Restate versus Temporal specifically, I couldn't find a clean neutral lines-of-code comparison, so I won't invent one. What I can say from the architecture is the honest version: standing up Restate is downloading a binary and pointing it at an object store; standing up Temporal is running a four-service cluster, attaching Postgres, planning for a search cluster, and splitting your app into API-plus-worker. If your instinct is that those are not remotely the same amount of ops, your instinct is correct, and no amount of "but Temporal Cloud makes it easy" changes the fact that you either run all of that or pay someone to.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the heavier engine earns it
&lt;/h2&gt;

&lt;p&gt;None of this means Temporal is overkill. It means Temporal is priced for a specific job, and when you have that job, the cluster is a bargain.&lt;/p&gt;

&lt;p&gt;Reach for Temporal when your workflows are genuinely long and human-timescale: things that sleep for days, weeks, or months and must survive every deploy and restart in between. Reach for it when you need deep, per-activity control over retries and multiple timeout types, when you want the largest ecosystem and the most battle-tested SDKs across TypeScript, Java, Python, Go, .NET, and PHP, and when scale and multi-region operation are real requirements rather than aspirations. Two 2026 signals matter here: Temporal Cloud gives you managed multi-region so you're not operating that cluster yourself, and &lt;strong&gt;Temporal Nexus is now generally available&lt;/strong&gt;, which lets teams compose durable executions across isolated namespaces, regions, and clouds with per-team blast-radius isolation. If your problem is "twelve teams each own a namespace and need to call each other's durable workflows without sharing a database," that's a Temporal-shaped problem, and Restate isn't trying to be the answer to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the lighter engine wins
&lt;/h2&gt;

&lt;p&gt;Here's the part the title promised. The lighter engine wins far more often than the "we're an enterprise, we need Temporal" reflex assumes.&lt;/p&gt;

&lt;p&gt;Restate wins when your durable needs are "don't lose this multi-step operation and give it some durable state," not "orchestrate month-long sagas across a dozen teams." It wins when the operational budget is the constraint, when a single binary plus an object store is a Tuesday and a four-service cluster plus a database plus a search cluster is a quarter of platform work you didn't want to fund. It wins when you want durable execution to sit inside your normal backend, your services, your event handlers, the agent you're building this year, rather than forcing everything through a workflow-orchestration frame. And it wins on adoption speed: the fastest way to have durable execution running in an afternoon is the one that doesn't start with provisioning a cluster.&lt;/p&gt;

&lt;p&gt;So the verdict, stated as a decision rule you can actually use: default to the lighter footprint, and only take on Temporal's cluster when you can name the specific Temporal feature you need that Restate doesn't give you. Month-long timers with per-step retry policies. Cross-namespace composition through Nexus. Battle-tested operation at a scale you're actually at, not the scale on your roadmap. Those are real reasons, and when they're your reasons, pay the operational tax gladly. But if you're standing up a Postgres, an Elasticsearch, and a two-service split to make a five-step checkout crash-proof, the engine isn't the thing that's overbuilt. The decision was. Both of these journal and replay. Pick the one whose operations page you'd actually enjoy owning.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;PS: English is not my native language, so I used AI to help with proofreading and phrasing. All ideas and technical content are my own.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/restate-vs-temporal-durable-execution" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you found this helpful, follow me here and on &lt;a href="https://www.linkedin.com/in/andriiboyko/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>typescript</category>
      <category>discuss</category>
      <category>devops</category>
    </item>
    <item>
      <title>You Probably Don't Need a Dedicated Vector Database</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:44:00 +0000</pubDate>
      <link>https://dev.to/andriiboyko/you-probably-dont-need-a-dedicated-vector-database-1d74</link>
      <guid>https://dev.to/andriiboyko/you-probably-dont-need-a-dedicated-vector-database-1d74</guid>
      <description>&lt;p&gt;Somewhere in the last two years, "we're doing RAG" quietly became "so we need a vector database," and the second half of that sentence stopped getting questioned. You add Pinecone or Qdrant or Weaviate to the stack, wire up a sync job, and now you own two datastores that have to agree with each other forever. For a demo, fine. For most production systems, you just bought a distributed-systems problem to solve a problem you didn't have.&lt;/p&gt;

&lt;p&gt;Here's the number that should have ended the debate before it started: pgvector, the vector extension that runs inside the Postgres you're already paying for, handles vector search comfortably into the low tens of millions of vectors on a single node. Not "for toy projects." Into the tens of millions. And it does it while your embeddings sit in the same transaction, the same backup, and the same &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; as the rest of your data. The dedicated vector database is a real tool with a real job. That job just starts a lot further up the scale curve than the people selling it want you to think.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a vector database actually does
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing and a vector database does one thing: approximate nearest-neighbor search over high-dimensional embeddings. You have a query vector, you have millions of stored vectors, and you want the closest few by cosine or L2 distance without comparing against every single one. The trick that makes it fast is an index, almost always HNSW (Hierarchical Navigable Small World), a graph you walk to find close neighbors in roughly logarithmic time instead of scanning the whole set.&lt;/p&gt;

&lt;p&gt;That's it. That's the special sauce. And Postgres has had it since pgvector 0.5.0 shipped HNSW back in 2023. Here's the entire "vector database" you need for most apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;doc_id&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;documents&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;tenant_id&lt;/span&gt;  &lt;span class="nb"&gt;bigint&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="n"&gt;content&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;  &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;-- e.g. OpenAI text-embedding-3-small&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The index that makes it fast. m and ef_construction trade build time for recall.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nearest-neighbor query is an &lt;code&gt;ORDER BY&lt;/code&gt; on a distance operator (&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is cosine distance, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; is L2, &lt;code&gt;&amp;lt;#&amp;gt;&lt;/code&gt; is negative inner product):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&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;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;-- $1 is your query embedding&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query, against an HNSW index that fits in memory, is not meaningfully slower than the same query against Pinecone at the scales most teams operate at. Benchmarks have pgvector with HNSW matching or beating dedicated engines at around 1M vectors. So the honest question isn't "is pgvector good enough" at a million vectors. It obviously is. The question is what you give up by not adding a second system, and the answer is: nothing. You gain things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff you get for free (and would have to rebuild)
&lt;/h2&gt;

&lt;p&gt;This is the part the comparison charts leave out, because it doesn't fit in a QPS column. When your vectors live in Postgres, every other thing Postgres does applies to them at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filtering and multi-tenancy are just a &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/strong&gt; Real RAG is almost never "search all vectors." It's "search this tenant's documents," or "search docs this user can see, from the last 90 days, in the 'published' state." In Postgres that's the query you already know how to write, and it runs in the same index scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that &lt;code&gt;JOIN&lt;/code&gt;. Your embedding result comes back already stitched to the document's title, its author, its permissions, whatever you need, in one round trip. In a dedicated vector store you get back a list of IDs, and then you make a second call to Postgres to hydrate them, and now you're doing a distributed join by hand in application code and hoping the two systems didn't drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-level security means tenant isolation you can't forget.&lt;/strong&gt; You can push tenancy down into the database so a missing &lt;code&gt;WHERE&lt;/code&gt; clause can't leak one customer's chunks into another's results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try enforcing that in a separate vector database. You can't, really, not at the storage layer. Tenant isolation becomes an application concern you re-implement and re-audit, which is exactly the kind of thing that turns into a security incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One transaction, one backup, one truth.&lt;/strong&gt; When you insert a document and its chunks and their embeddings, that's one transaction. It commits or it doesn't. There's no window where the document exists but its vectors don't, or where you deleted a record but its embedding is still floating in another system returning ghosts in search. Your existing backup, your existing replica, your existing point-in-time recovery already cover the vectors. You didn't add an operational surface. You added a column.&lt;/p&gt;

&lt;p&gt;That "one truth" point is the whole argument, honestly. The moment you split vectors into their own store, you own a synchronization problem: dual writes, eventual consistency between your source of truth and your search index, reconciliation jobs, and the 3am question of why a deleted record still shows up in RAG results. That problem is real work, and you took it on to save a latency difference you can't measure yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fyou-probably-dont-need-a-dedicated-vector-database%2Fone-system-vs-two.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fyou-probably-dont-need-a-dedicated-vector-database%2Fone-system-vs-two.avif" alt="Comparison: Postgres with pgvector holding documents, chunks, embeddings, tenants and row-level security in one box answered by a single query, versus Postgres plus a separate vector database kept in sync with dual writes" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two gotchas that actually bite
&lt;/h2&gt;

&lt;p&gt;pgvector is not magic, and pretending it has no sharp edges is how you end up back in the dedicated-database camp for the wrong reasons. There are exactly two things that bite people, and both have answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha one: the HNSW index has to fit in memory.&lt;/strong&gt; The single biggest factor in pgvector performance is whether the HNSW graph lives in RAM. When the index fits in &lt;code&gt;shared_buffers&lt;/code&gt; and stays there, queries are fast and boring. When it spills to disk because the index outgrew memory or got evicted under load, tail latency falls off a cliff. So capacity-planning pgvector is really memory-planning: know your vector count times your dimensions times the index overhead, and make sure it fits with headroom. A 1536-dimension vector is about 6KB raw; ten million of them plus HNSW overhead is a real but very ordinary amount of RAM for a database server in 2026. This is also where &lt;code&gt;halfvec&lt;/code&gt; earns its keep: store embeddings as 16-bit floats and you roughly halve the memory, which for 3072-dimension models is the difference between fitting and not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha two: filtered search used to quietly return too few rows.&lt;/strong&gt; This one burned people for years and is the single most common "pgvector is broken" complaint. Before pgvector 0.8.0, the HNSW index returned its candidate set first, and then your &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; filter ran on that set. If your filter was selective, you'd ask for 10 results and get 3, because 7 of the index's candidates belonged to other tenants and got dropped after the fact. It looked like a correctness bug and it was really an ordering-of-operations problem.&lt;/p&gt;

&lt;p&gt;pgvector 0.8.0 fixed it with iterative index scans: the planner keeps pulling more of the index until enough rows survive your filter. You turn it on per-session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- keep scanning until LIMIT is satisfied&lt;/span&gt;
&lt;span class="c1"&gt;-- strict_order preserves exact distance order; relaxed_order trades a little&lt;/span&gt;
&lt;span class="c1"&gt;-- ordering for better recall under selective filters.&lt;/span&gt;
&lt;span class="c1"&gt;-- Bounds: hnsw.max_scan_tuples (default 20000), hnsw.scan_mem_multiplier (default 1).&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you evaluated pgvector before 0.8.0, hit the filtered-search wall, and concluded you needed a "real" vector database, that conclusion is now out of date. Re-check it. The version number matters here more than almost anywhere else in the Postgres world.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you actually do need one
&lt;/h2&gt;

&lt;p&gt;The contrarian take isn't "never use a dedicated vector database." It's "know the line, and don't cross it before you get there." The line is real, and it's roughly this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fyou-probably-dont-need-a-dedicated-vector-database%2Fwhere-pgvector-stops-being-enough.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fyou-probably-dont-need-a-dedicated-vector-database%2Fwhere-pgvector-stops-being-enough.avif" alt="A vector-count scale with three zones: under 10 million where pgvector matches dedicated engines, 10 to 50 million with pgvectorscale, and 100 million-plus where dedicated engines like Pinecone, Qdrant and Milvus win" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Under roughly 10 million vectors, pgvector with a well-tuned HNSW index that fits in RAM matches or beats dedicated engines, and you keep all the free stuff above. From about 10 to 50 million, plain pgvector starts to strain, but you don't have to leave Postgres yet: the &lt;code&gt;pgvectorscale&lt;/code&gt; extension adds a StreamingDiskANN index that stays fast when the dataset is larger than RAM, plus statistical binary quantization to shrink memory and label-aware filtering. In one benchmark it hit 471 queries per second at 99% recall on 50 million vectors, about 11 times Qdrant's throughput at the same recall. So the "you'll outgrow Postgres" story has a whole extra chapter before it's even true.&lt;/p&gt;

&lt;p&gt;Past 50 to 100 million vectors, or when your workload is vector-search-first with brutal tail-latency SLAs and you want someone else to operate the scaling, the dedicated engines genuinely pull ahead. Pinecone is the fastest path to zero-ops scaling toward billions. Qdrant wins raw QPS and filtering if you want to stay open-source and run it yourself. Weaviate bundles embedding generation so you can hand it raw text. These are good products solving a real problem. They're just solving a problem that starts at a scale most applications will never reach, and they charge you the two-system tax the entire way there.&lt;/p&gt;

&lt;p&gt;The mistake almost nobody regrets avoiding is starting on pgvector and migrating later. Moving 100 million vectors to a dedicated store when you actually hit the wall is a known, boring data-migration project you'll have the revenue to staff. Standing up a second stateful system on day one to serve 200,000 vectors is how you spend your scaling budget before you have anything to scale. Put the embeddings in the database that already holds your data, add the &lt;code&gt;vector&lt;/code&gt; column, ship the feature, and add the dedicated engine the day the numbers, not the demo, tell you to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/you-probably-dont-need-a-dedicated-vector-database" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you found this helpful, follow me here and on &lt;a href="https://www.linkedin.com/in/andriiboyko/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>ai</category>
      <category>rag</category>
    </item>
    <item>
      <title>The Orchestrator-Subagent Pattern, Modeled as a Domain</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Mon, 27 Jul 2026 12:56:21 +0000</pubDate>
      <link>https://dev.to/andriiboyko/the-orchestrator-subagent-pattern-modeled-as-a-domain-36li</link>
      <guid>https://dev.to/andriiboyko/the-orchestrator-subagent-pattern-modeled-as-a-domain-36li</guid>
      <description>&lt;p&gt;Give a language model a slightly better prompt and you might squeeze out a few percent. Give it a second copy of itself to delegate to, and Anthropic measured a 90.2% jump on research tasks over the same model working alone. Here's the part that should bother you: the improvement had almost nothing to do with the models getting smarter. The lead agent and the workers ran the same family of models. What changed was how the work got divided.&lt;/p&gt;

&lt;p&gt;That's not a machine-learning result. It's a domain-modeling result. And it points at something most teams building agent systems get backwards: the hard parts of multi-agent orchestration aren't the prompts or the model choice. They're boundaries, invariants, and failure handling, which is to say they are exactly the problems domain-driven design was built for. This is the same lens the &lt;a href="https://andriiboyko.com/articles/cqrs-in-nestjs-when-its-worth-the-complexity" rel="noopener noreferrer"&gt;CQRS&lt;/a&gt; and &lt;a href="https://andriiboyko.com/articles/modeling-a-credentialing-workflow-as-a-domain" rel="noopener noreferrer"&gt;credentialing&lt;/a&gt; pieces used on ordinary backends, pointed at the agent stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern, Stated Plainly
&lt;/h2&gt;

&lt;p&gt;The orchestrator-subagent pattern (sometimes orchestrator-worker) is simple to describe. A lead agent takes a goal, plans an approach, and spins up a handful of subagents, typically three to five, each with a narrow mandate. The subagents run in parallel. When they finish, the lead agent synthesizes their results into an answer, often with a separate citation or verification pass at the end.&lt;/p&gt;

&lt;p&gt;The crucial detail, the one that makes the whole thing work and also makes it hard: each subagent runs in its own context window, with its own tools and its own exploration trajectory. The orchestrator hands out an assignment and gets back a result. It never sees the subagent's intermediate reasoning, the dead ends, the forty tool calls it took to get there. Only the finished product comes back.&lt;/p&gt;

&lt;p&gt;If that arrangement sounds familiar, it should. You have already been modeling systems where one component gives another an instruction and receives only a result, with the internals sealed off. That is an aggregate boundary, and treating it as one is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Context Window Is an Aggregate Boundary
&lt;/h2&gt;

&lt;p&gt;In DDD, an aggregate is a cluster of objects you treat as a single unit, with a root that is the only legal way in. You don't reach into an aggregate and read its internals. You send its root a message and it hands you back a result. The internal reasoning stays private.&lt;/p&gt;

&lt;p&gt;A subagent's context window is exactly this boundary, enforced by the runtime instead of by discipline. The orchestrator physically cannot see inside a subagent's window. It gets the published result and nothing else. Which means the thing coming back across that boundary deserves to be modeled as a proper value object, not a bag of loose strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The result is the ONLY thing that crosses the boundary.&lt;/span&gt;
&lt;span class="c1"&gt;// Model it as a value object with provenance, not a raw string.&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubagentResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;tokensSpent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;completedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="k"&gt;static&lt;/span&gt; &lt;span class="nf"&gt;fromCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SubagentCompletion&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SubagentResult&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// A "finding" with no sources is not a result. It's a claim.&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Subagent &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; returned findings with no sources`&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;new&lt;/span&gt; &lt;span class="nc"&gt;SubagentResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokensSpent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completedAt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same move as the credentialing article's &lt;code&gt;VerifiedCredential&lt;/code&gt;: a private constructor and a single factory that enforces an invariant at the boundary. There, a credential couldn't exist without primary-source verification. Here, a result can't exist without sources. The orchestrator's synthesis step then gets to assume every result it holds already cleared that bar, instead of re-checking each one defensively.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Orchestration Is the Aggregate Root
&lt;/h2&gt;

&lt;p&gt;So what owns the invariants? Not the subagents, they're isolated by design and don't know about each other. The orchestration run itself is the aggregate root. It's the thing that holds the goal, tracks which tasks are outstanding, and enforces the one rule that matters most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Forchestrator-subagent-pattern-modeled-as-a-domain%2Forchestration-aggregate.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Forchestrator-subagent-pattern-modeled-as-a-domain%2Forchestration-aggregate.avif" alt="Orchestration aggregate: one root box holding a goal and a task ledger sends one-way mandate arrows to three sealed, locked subagent boxes and receives one-way result arrows back; the subagents do not connect to each other" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That rule is a guard you have seen before: don't synthesize until every delegated task has resolved. In the credentialing domain it was "you can't send a file to committee review with a hole in it." Same shape here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Orchestration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrchestrationStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;OrchestrationStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Planning&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TaskState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SubagentResult&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Answer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isResolved&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="nx"&gt;unresolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`Cannot synthesize with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unresolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; task(s) still outstanding`&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="k"&gt;this&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="nx"&gt;OrchestrationStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Synthesizing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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;Nothing about this is AI-specific. It's an aggregate refusing to enter an invalid state. The fact that the "tasks" happen to be language models exploring the web is an implementation detail the invariant doesn't care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug Is a Missing Invariant
&lt;/h2&gt;

&lt;p&gt;Here's a real failure the Anthropic team reported: the orchestrator would sometimes get over-enthusiastic and spawn fifty subagents for a question that needed two. Others would spin in loops chasing sources that didn't exist.&lt;/p&gt;

&lt;p&gt;It's tempting to read that as the model being flaky. It isn't, or at least that's not the useful reading. Spawning fifty workers is what happens when delegation is an unbounded loop with no invariant guarding it. The domain has a rule, fan-out has a sane ceiling, and if that rule doesn't live anywhere in the model, the model has no way to honor it. So you put it where rules go, in a policy the aggregate consults, not a magic number buried in a prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FanOutPolicy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&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="nx"&gt;maxSubagents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Never exceed the ceiling. Return how many we can actually spawn.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSubagents&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&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 reframing matters because of where agent systems actually break. One study of failures across seven multi-agent frameworks, AG2 (formerly AutoGen), MetaGPT, and ChatDev among them, found that coordination failures, what the authors call inter-agent misalignment, accounted for 36.94% of everything that went wrong. Fold in the 41.77% they pinned on specification and system design, and more than three-quarters of failures trace to design and coordination, not to the model being dumb. The boundaries between agents are where the bugs live. That's a domain-design problem with a domain-design fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subagent Results Are External Events, Not Return Values
&lt;/h2&gt;

&lt;p&gt;Now the part that makes this genuinely harder than a credentialing case, and where you have to break the analogy.&lt;/p&gt;

&lt;p&gt;In an ordinary domain, transitions are deterministic. Approve a clean file and it becomes approved, every time. A subagent is not deterministic. Hand the same mandate to the same model twice and you can get two different trajectories, two different token costs, and occasionally a worker that never comes back at all. You cannot model a subagent call as a function that returns a value. You have to model it as something that might arrive, might fail, or might hang, arriving from outside your control.&lt;/p&gt;

&lt;p&gt;Which, again, is a shape the credentialing model already had: the sanction that could land at any time and knock an approved provider into suspension. An outside event the aggregate has to be ready for. Delegation is the same. Every subagent is a potential &lt;code&gt;SubagentFailed&lt;/code&gt; or &lt;code&gt;SubagentTimedOut&lt;/code&gt; waiting to happen, and the orchestration has to stay valid when it does:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Forchestrator-subagent-pattern-modeled-as-a-domain%2Forchestration-state-machine.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Forchestrator-subagent-pattern-modeled-as-a-domain%2Forchestration-state-machine.avif" alt="Orchestration state machine: Planning, Delegating, Awaiting Results, Synthesizing, Cited, Done, with a red Degraded branch off Awaiting Results for a timed-out subagent that rejoins Synthesizing on partial results, guarded by all tasks resolved" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onSubagentTimedOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DomainEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isResolved&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="c1"&gt;// A timeout resolves the task as failed, not as pending forever.&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// The whole run is degraded, but it is NOT dead. Partial answers beat none.&lt;/span&gt;
  &lt;span class="k"&gt;return&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;SubagentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&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;Marking the task failed is what keeps &lt;code&gt;synthesize()&lt;/code&gt;'s guard honest. A timed-out worker is a resolved task, so the run can still complete on the results it did get, degraded but alive, rather than blocking forever on a subagent that is never coming back. The rule "don't wait on the dead" is a domain rule, not a networking detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Is a Domain Rule
&lt;/h2&gt;

&lt;p&gt;There's one invariant this domain has that most don't, and it's expensive to ignore. Multi-agent runs are costly. Anthropic put the figure at roughly fifteen times the tokens of a normal chat, and found that token usage alone explained about 80% of the performance variance in one of their benchmarks. Spend is not a side effect here. It's close to the primary lever.&lt;/p&gt;

&lt;p&gt;That makes a token budget a first-class invariant, the same way the 36-month clock was in the credentialing model. The aggregate owns a budget and refuses to over-delegate past it, so a single runaway question can't quietly burn the month's spend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TaskMandate&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TokenBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FanOutPolicy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TaskMandate&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;affordable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;affordable&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Fan-out blocked: budget exhausted or ceiling reached&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;affordable&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;If you have ever wired FinOps guardrails into a platform, this is the same idea moved one layer up: enforce the budget at the moment of spend, not in a dashboard you read afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Boundary Is Wrong
&lt;/h2&gt;

&lt;p&gt;The honest limit, because this pattern is oversold right now. The isolation that makes orchestration powerful is also its hard constraint. Subagents can't see each other's reasoning, only the orchestrator can, and only the finished results at that. That's a fantastic fit for breadth-first work: research a topic from five angles at once, explore independent branches, gather in parallel. It's a terrible fit for tightly coupled work where each step depends on the intermediate state of the last, because the boundary you're relying on is precisely the thing blocking that state from flowing.&lt;/p&gt;

&lt;p&gt;In DDD terms, this is a diagnosis you already know how to make. If two aggregates constantly need to reach into each other's internals to do their jobs, they aren't two aggregates. You drew the boundary in the wrong place, and they should be one. Same rule here: if your subagents keep needing each other's half-finished thoughts, multi-agent isn't buying you anything, and the isolation is pure overhead on top of a 15x bill. Collapse it back into one agent with one context and move on.&lt;/p&gt;

&lt;p&gt;Which is the quiet punchline. The interesting questions in agent engineering right now are not "which model" or "what prompt." They're "where does this boundary go," "what invariant holds this together," and "what happens when a piece of it fails." We have had good answers to those questions for twenty years. They just never had this particular costume on before.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/orchestrator-subagent-pattern-modeled-as-a-domain" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>architecture</category>
      <category>domaindrivendesign</category>
    </item>
    <item>
      <title>Declarative Agent Workflows in Go: What Microsoft Shipped, and What You Build Yourself</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:11:04 +0000</pubDate>
      <link>https://dev.to/andriiboyko/declarative-agent-workflows-in-go-what-microsoft-shipped-and-what-you-build-yourself-47d6</link>
      <guid>https://dev.to/andriiboyko/declarative-agent-workflows-in-go-what-microsoft-shipped-and-what-you-build-yourself-47d6</guid>
      <description>&lt;p&gt;In June 2026 Microsoft shipped declarative workflows for its Agent Framework at 1.0. The pitch is good: you describe a multi-agent workflow in a YAML file instead of wiring it in code, so a workflow becomes a config artifact you can read, diff, version, and hand to someone who doesn't write code. The &lt;code&gt;agent-framework-declarative&lt;/code&gt; package went 1.0.0 on PyPI, joining the already-stable .NET &lt;code&gt;Microsoft.Agents.AI.Workflows.Declarative&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then you open the docs, flip the language pivot to Go, and the how-to isn't there. Declarative workflows are a Python and .NET feature. The Go SDK, &lt;code&gt;github.com/microsoft/agent-framework-go&lt;/code&gt;, is in public preview, and its own release notes are blunt about the gap: declarative agents, RAG, CodeAct, and functional workflows "are not yet available."&lt;/p&gt;

&lt;p&gt;So if you're building agent systems in Go, you're in an interesting spot. The concept Microsoft is selling is genuinely useful. The runtime that would give it to you exists in your language. The thin YAML-to-graph layer on top just hasn't been written for Go yet. That's a gap you can close in about a hundred lines, and by the end of this you'll have. But first, let's be precise about what "declarative" is actually buying you, because half the teams that reach for it don't need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "declarative" actually means here
&lt;/h2&gt;

&lt;p&gt;A programmatic workflow is code. You import a builder, instantiate executors, connect them, and call &lt;code&gt;Build()&lt;/code&gt;. A declarative workflow is the same graph expressed as data. Here's the Python-flavored version straight from the docs, a workflow that greets someone by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greeting-workflow&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A simple workflow that greets the user&lt;/span&gt;

&lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The name of the person to greet&lt;/span&gt;

&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SetVariable&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build_message&lt;/span&gt;
    &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Local.message&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;=Concat("Hello, ", Workflow.Inputs.name, "!")&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SendActivity&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;send_greeting&lt;/span&gt;
    &lt;span class="na"&gt;activity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;=Local.message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noticing. First, every step has a &lt;code&gt;kind&lt;/code&gt;. The framework ships a fixed vocabulary of them, and that vocabulary is the whole point: &lt;code&gt;SetVariable&lt;/code&gt;, &lt;code&gt;If&lt;/code&gt;, &lt;code&gt;ConditionGroup&lt;/code&gt;, &lt;code&gt;Foreach&lt;/code&gt;, &lt;code&gt;InvokeAzureAgent&lt;/code&gt;, &lt;code&gt;InvokeFunctionTool&lt;/code&gt;, &lt;code&gt;InvokeMcpTool&lt;/code&gt;, &lt;code&gt;HttpRequestAction&lt;/code&gt;, &lt;code&gt;Question&lt;/code&gt;, &lt;code&gt;SendActivity&lt;/code&gt;, and a dozen more. You're not writing behavior, you're picking from a menu of pre-approved building blocks. That constraint is a feature. It's what lets a non-developer safely edit the file.&lt;/p&gt;

&lt;p&gt;Second, look at the &lt;code&gt;value&lt;/code&gt; fields that start with &lt;code&gt;=&lt;/code&gt;. Those are expressions, evaluated at runtime by an embedded expression engine. Microsoft uses PowerFx here, the same formula language behind Power Apps, so &lt;code&gt;=Concat("Hello, ", Workflow.Inputs.name)&lt;/code&gt; is a real function call, not string templating. Variables live in namespaces: &lt;code&gt;Local.*&lt;/code&gt; for workflow-scoped state, &lt;code&gt;Workflow.Inputs.*&lt;/code&gt; and &lt;code&gt;Workflow.Outputs.*&lt;/code&gt; for the contract with the caller, and &lt;code&gt;System.*&lt;/code&gt; for framework-provided values like &lt;code&gt;System.ConversationId&lt;/code&gt; and &lt;code&gt;System.LastMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The docs include a decision table that's more honest than most vendor tables, so it's worth reproducing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Recommended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard orchestration patterns&lt;/td&gt;
&lt;td&gt;Declarative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflows that change frequently&lt;/td&gt;
&lt;td&gt;Declarative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-developers need to modify workflows&lt;/td&gt;
&lt;td&gt;Declarative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex custom logic&lt;/td&gt;
&lt;td&gt;Programmatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum flexibility and control&lt;/td&gt;
&lt;td&gt;Programmatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration with existing code&lt;/td&gt;
&lt;td&gt;Programmatic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the right column. The moment your workflow needs anything the action vocabulary doesn't cover, you're back in code. Declarative isn't a more powerful way to build workflows, it's a more constrained one, and the constraint is the value. Keep that in mind, it's the whole argument for when to bother.&lt;/p&gt;

&lt;p&gt;There's also a gotcha hiding in that expression engine. On Python the declarative package supports 3.10 through 3.13 and explicitly not 3.14, "due to PowerFx compatibility." When your workflow definition language is coupled to a .NET formula runtime, that runtime's portability becomes your portability. Hold that thought, it's part of why the Go story is what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: Go isn't invited to this party yet
&lt;/h2&gt;

&lt;p&gt;Here's the state of play as of mid-2026. Declarative workflows are 1.0 on Python and .NET. On Go, they don't exist. The Go SDK is public preview and the feature list that's shipped covers agents, providers, tools, and programmatic workflows. The declarative layer, the RAG helpers, CodeAct, and functional workflows are all on the "coming" side of the line.&lt;/p&gt;

&lt;p&gt;That could read as "Go is behind, come back later." I'd read it differently. The reason declarative is hard to port isn't the graph engine, Go already has that. It's PowerFx. Porting the declarative feature faithfully means either binding a .NET expression runtime into a Go process or reimplementing PowerFx, and neither is a weekend. The valuable part, turning a YAML graph into a running workflow, is the easy part. That's the part you can do yourself today.&lt;/p&gt;

&lt;p&gt;So the plan for the rest of this piece: see what the Go SDK's programmatic workflows actually look like, understand the engine underneath them (it's more interesting than you'd expect), and then build a small declarative layer that compiles YAML into that engine. Real Go, runs today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Go SDK gives you today
&lt;/h2&gt;

&lt;p&gt;The programmatic API is clean. You create executors, which are just processing units, and connect them with edges. Here's the canonical example from the Go docs, verbatim, a two-stage pipeline that uppercases a string then reverses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/microsoft/agent-framework-go/workflow"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/microsoft/agent-framework-go/workflow/inproc"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;uppercase&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UppercaseExecutor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToUpper&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="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;reverse&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ReverseExecutor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;runes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;rune&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;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;AddEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;WithOutputFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An executor is any function with a shape like &lt;code&gt;func(input) output&lt;/code&gt;, wrapped by &lt;code&gt;NewExecutor&lt;/code&gt; and registered with &lt;code&gt;Bind()&lt;/code&gt;. The builder takes a starting executor, you &lt;code&gt;AddEdge&lt;/code&gt; from one to the next, mark which executor's output is the workflow output with &lt;code&gt;WithOutputFrom&lt;/code&gt;, and &lt;code&gt;Build()&lt;/code&gt;. Then you run it, streaming events as they happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;inproc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RunStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Hello, World!"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WatchStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputEvent&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Workflow completed: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Output&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;Note the &lt;code&gt;for ... range&lt;/code&gt; over a function, that's a Go 1.23 range-over-func iterator. The SDK's &lt;code&gt;go.mod&lt;/code&gt; actually requires Go 1.25, so it was written for a very recent Go and leans hard into the new language features. There's a non-streaming &lt;code&gt;inproc.Default.Run&lt;/code&gt; too, which collects events so you can walk them after the fact with &lt;code&gt;run.NewEvents()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's a straight line. The interesting cases are branches. The builder exposes conditional routing through a switch, so you can send a message down different paths based on its content. In preview the shape looks like this (treat the exact method names as preview-era, the repo is the source of truth):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;wb&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSwitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;AddCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;AddCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"technical"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;technical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;WithDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;AddToBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithOutputFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;technical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's more in the same family: &lt;code&gt;AddFanOutEdge&lt;/code&gt; to broadcast to several executors, &lt;code&gt;AddFanInBarrierEdge&lt;/code&gt; to join them back, &lt;code&gt;AddChain&lt;/code&gt; for a straight sequence, and &lt;code&gt;RequestPort&lt;/code&gt; for human-in-the-loop, where the workflow pauses and raises a request event that you answer from outside. And &lt;code&gt;Build()&lt;/code&gt; isn't a rubber stamp. It validates the graph: type compatibility between connected executors, that every executor is reachable from the start, that bindings resolve, and that you haven't declared duplicate edges. You find a disconnected node at build time, not when a message silently goes nowhere in production.&lt;/p&gt;

&lt;p&gt;So the Go SDK has the full orchestration surface. What it doesn't have is a way to express that surface as a YAML file. Before we add one, we need to understand what we're compiling to, because the execution model has a sharp edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood: it's a superstep engine
&lt;/h2&gt;

&lt;p&gt;Here's the part most people skip and then get bitten by. The Agent Framework doesn't just walk your graph node by node. It runs a "modified Pregel execution model, a Bulk Synchronous Parallel (BSP) approach with superstep-based processing." Pregel is the graph-processing model Google published in 2010 for computations over massive graphs, and BSP is the decades-older parallel-computing discipline it's built on. Borrowing it for agent workflows is a genuinely good call, and it changes how you reason about the graph.&lt;/p&gt;

&lt;p&gt;Execution happens in discrete supersteps. Each one does the same five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collect every message pending from the previous superstep.&lt;/li&gt;
&lt;li&gt;Route each message to target executors based on the edges and their conditions.&lt;/li&gt;
&lt;li&gt;Run all targeted executors concurrently.&lt;/li&gt;
&lt;li&gt;Wait at a synchronization barrier for every one of them to finish.&lt;/li&gt;
&lt;li&gt;Queue whatever new messages they emitted, and start the next superstep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The barrier in step 4 is the whole game. Within a superstep, everything runs in parallel. Between supersteps, nothing does. The workflow will not advance until the slowest executor in the current step returns.&lt;/p&gt;

&lt;p&gt;That barrier buys you three things that matter for production agents. Execution is deterministic: same input, same order, every time. Checkpointing is reliable, because a superstep boundary is a globally consistent snapshot, which is exactly why the SDK can save state and resume on a different machine. And reasoning is simpler, because within a step there are no races, every executor sees the same frozen view of messages.&lt;/p&gt;

&lt;p&gt;Now the sharp edge. Say you fan out into two branches. One is a single long-running agent call, thirty seconds. The other is a quick three-step chain. You'd expect the quick chain to race ahead. It won't. Each step of that chain is its own superstep, and every superstep waits at the barrier for the thirty-second agent to finish. Your "fast" branch moves in lockstep with your slow one.&lt;/p&gt;

&lt;p&gt;The docs are upfront about the fix, and it's counterintuitive the first time you read it: if you need two branches to run truly independently, don't make one of them a chain. Collapse its three steps into a single executor. Then both branches complete inside one superstep and neither blocks the other on intermediate boundaries. In a superstep engine, more nodes is not more parallelism. Sometimes it's less.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fdeclarative-agent-workflows-in-go%2Fone-superstep-in-the-workflow-engine.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fdeclarative-agent-workflows-in-go%2Fone-superstep-in-the-workflow-engine.avif" alt="Flow diagram of one workflow superstep: collect pending messages, route by edge and condition, run all target executors in parallel, wait at a synchronization barrier on the slowest long-running agent, then queue new messages for the next superstep" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building your own declarative layer in Go
&lt;/h2&gt;

&lt;p&gt;Now the payoff. We want the declarative experience, a workflow described in YAML, running on Go. We're not going to reimplement PowerFx or bind .NET into our process. We'll build a small, honest engine: a fixed action vocabulary, simple &lt;code&gt;${var}&lt;/code&gt; interpolation instead of a formula language, and a pluggable agent interface so you can wire in &lt;code&gt;agent-framework-go&lt;/code&gt;, the OpenAI SDK, or a fake for tests. It's the shape of the real thing at a size you can read in one sitting.&lt;/p&gt;

&lt;p&gt;Start with the schema. A workflow is a name and a list of actions. Each action carries only the fields its &lt;code&gt;kind&lt;/code&gt; needs, which in Go means one struct with &lt;code&gt;omitempty&lt;/code&gt; fields and a &lt;code&gt;Kind&lt;/code&gt; discriminator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;

&lt;span class="c"&gt;// Spec is the whole workflow, parsed from a YAML file.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Actions&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="s"&gt;`yaml:"actions"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Action is one step. Only the fields relevant to its Kind are populated.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Kind&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"kind"`&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Var&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"var,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"value,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Cond&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;    &lt;span class="s"&gt;`yaml:"cond,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Then&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="s"&gt;`yaml:"then,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Else&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="s"&gt;`yaml:"else,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Agent&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"agent,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Input&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"input,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Text&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`yaml:"text,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Cond is a deliberately tiny condition: does a variable equal a literal.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Var&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`yaml:"var"`&lt;/span&gt;
    &lt;span class="n"&gt;Equals&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`yaml:"equals"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;*Cond&lt;/code&gt; being a pointer matters: a nil &lt;code&gt;Cond&lt;/code&gt; means the action isn't a conditional, which is cleaner than a zero-value struct you have to second-guess. This is the Go version of Microsoft's action vocabulary, trimmed to four kinds: &lt;code&gt;set&lt;/code&gt;, &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;agent&lt;/code&gt;, and &lt;code&gt;send&lt;/code&gt;. Enough to be real, small enough to follow.&lt;/p&gt;

&lt;p&gt;Next, state. Workflow variables and an output sink, with &lt;code&gt;${name}&lt;/code&gt; interpolation standing in for PowerFx expressions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"strings"&lt;/span&gt;

&lt;span class="c"&gt;// State holds workflow variables and where output goes.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Vars&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;string&lt;/span&gt;
    &lt;span class="n"&gt;Out&lt;/span&gt;  &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;vars&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Vars&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// interpolate expands ${var} references against the current variables.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tpl&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;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tpl&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vars&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"${"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&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;val&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="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent is an interface, and that single decision is what makes this useful in a real project. The engine doesn't know or care what an agent is, only that you can hand it a prompt and get text back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"context"&lt;/span&gt;

&lt;span class="c"&gt;// Agent is anything that turns a prompt into a reply. Wire in&lt;/span&gt;
&lt;span class="c"&gt;// agent-framework-go, the OpenAI SDK, an HTTP call, or a fake in tests.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;prompt&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&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;error&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;Now the engine. It's a recursive walk over the action list, with a &lt;code&gt;switch&lt;/code&gt; on &lt;code&gt;Kind&lt;/code&gt; that mirrors the vocabulary. Every branch wraps its errors with enough context to name the failing action, because when a workflow blows up you want the step ID in the message, not a bare &lt;code&gt;nil map&lt;/code&gt; panic three frames down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Engine runs a Spec against a set of named agents.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Engine&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Agents&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;spec&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;runActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;actions&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="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"action %q (kind %s): %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&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;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;runAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;a&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"set"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Var&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"send"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"if"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Var&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Then&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&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="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Else&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"agent"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"no agent registered named %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&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;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"agent %q failed: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Var&lt;/span&gt; &lt;span class="o"&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;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Var&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unknown action kind %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&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="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loading is the boring part, one &lt;code&gt;os.ReadFile&lt;/code&gt; and one &lt;code&gt;yaml.Unmarshal&lt;/code&gt;, both wrapped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;

    &lt;span class="s"&gt;"gopkg.in/yaml.v3"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read workflow %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse workflow %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's a workflow that engine runs, a support router that's a straight port of the docs' own conditional-routing example, just in our smaller dialect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;support-router&lt;/span&gt;

&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;set&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;capture_category&lt;/span&gt;
    &lt;span class="na"&gt;var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;category&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${incoming_category}"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;if&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;route&lt;/span&gt;
    &lt;span class="na"&gt;cond&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;category&lt;/span&gt;
      &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing&lt;/span&gt;
    &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing_agent&lt;/span&gt;
        &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resolve&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;question:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${message}"&lt;/span&gt;
        &lt;span class="na"&gt;var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reply&lt;/span&gt;
    &lt;span class="na"&gt;else&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;general_agent&lt;/span&gt;
        &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;general&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${message}"&lt;/span&gt;
        &lt;span class="na"&gt;var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reply&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;send&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reply_to_user&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${reply}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring it together is a &lt;code&gt;main&lt;/code&gt; that registers a couple of agents and runs the file. A fake agent stands in here so the example runs with zero credentials, and swapping it for a real one is a one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;

    &lt;span class="s"&gt;"example.com/flow"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// fakeAgent echoes a canned reply so the demo runs offline.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;fakeAgent&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="n"&gt;fakeAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;prompt&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&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;error&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[%s] handling: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"support-router.yaml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Agents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fakeAgent&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"general"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fakeAgent&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"general"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}}&lt;/span&gt;

    &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"incoming_category"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"I was double charged"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OUT:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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;Run it and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OUT: [billing] handling: Resolve this billing question: I was double charged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change &lt;code&gt;incoming_category&lt;/code&gt; to anything else and the same binary routes to the general agent, no recompile of the logic, because the logic lives in the YAML. That's the declarative property Microsoft is selling, running on Go, in code you fully own and can extend one &lt;code&gt;case&lt;/code&gt; at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fdeclarative-agent-workflows-in-go%2Ffrom-yaml-to-an-executor-graph.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fandriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fdeclarative-agent-workflows-in-go%2Ffrom-yaml-to-an-executor-graph.avif" alt="Diagram mapping a YAML action list (set, if, agent billing, agent general, send) into a directed executor graph where the if node branches to a billing node and a general node before both merge into a send node" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it onto the real engine
&lt;/h2&gt;

&lt;p&gt;The engine above walks the actions directly, which is perfect for getting started and fine for a lot of real workloads. But you lose what the superstep model gave you: deterministic parallelism, checkpointing, resumability, the built-in telemetry. When you want those, you don't throw the YAML away. You change the target. Instead of interpreting the actions, you compile them into an &lt;code&gt;agent-framework-go&lt;/code&gt; graph, once, and let its runtime execute.&lt;/p&gt;

&lt;p&gt;The mapping is direct. Each action becomes an executor. Sequential actions become &lt;code&gt;AddEdge&lt;/code&gt; calls between them. An &lt;code&gt;if&lt;/code&gt; becomes an &lt;code&gt;AddSwitch&lt;/code&gt; with a case for the &lt;code&gt;then&lt;/code&gt; branch and a default for the &lt;code&gt;else&lt;/code&gt;. An &lt;code&gt;agent&lt;/code&gt; action becomes an executor whose function calls your real provider. Sketched against the preview builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutorBinding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Actions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Actions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="c"&gt;// capture per iteration for the closure below&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"agent"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&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;error&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="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&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="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&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;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c"&gt;// set, if, send map to their own executors / switches...&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewBuilder&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="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&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="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEdge&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;i&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;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&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="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithOutputFrom&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="nb"&gt;len&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="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Build&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;That &lt;code&gt;a := a&lt;/code&gt; line is load-bearing. Before Go 1.22 changed loop-variable scoping, capturing &lt;code&gt;a&lt;/code&gt; in the closure without the shadow would give every executor the last action's values, the single most common bug when you build a slice of closures in a loop. On 1.22 and up it's redundant but harmless, and I'd still write it, because it says "this closure captures a per-iteration value" out loud to the next reader.&lt;/p&gt;

&lt;p&gt;The point of this section isn't the exact builder calls, which are preview and will move. It's the architecture: &lt;strong&gt;one YAML dialect, two backends.&lt;/strong&gt; A direct interpreter you own for the simple 80%, and a compile-to-&lt;code&gt;agent-framework-go&lt;/code&gt; path for when you need the superstep guarantees. Your workflow files don't change when you switch. That's the real payoff of a declarative layer, and it holds whether Microsoft ships Go support next quarter or not. If they do, you migrate the compiler backend and keep your files. If they don't, you already shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is actually worth building
&lt;/h2&gt;

&lt;p&gt;Now the part the vendor tables won't tell you. Don't build any of this to avoid an &lt;code&gt;if&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;A declarative layer earns its keep under a specific condition: the people who need to change the workflow aren't the people who deploy the binary, and the workflow changes often enough that a redeploy per change is real friction. A support-routing flow that ops tweaks weekly, a content pipeline a PM reorders, an onboarding sequence that shifts with every partner deal. That's where moving the graph out of code and into a versioned YAML file pays for the engine you had to write. The file becomes the interface between the people who change the flow and the runtime that executes it.&lt;/p&gt;

&lt;p&gt;If that's not your situation, a plain Go function is better than a YAML interpreter in every way that matters. It's type-checked, it's debuggable with a normal debugger, it's greppable, and it has no expression language to learn. Microsoft's own decision table says as much in its right-hand column: complex logic, maximum control, tight integration with existing code all point back to programmatic. The declarative version is worth it precisely and only when the constraint, a fixed vocabulary that a non-developer can safely edit, is the thing you want.&lt;/p&gt;

&lt;p&gt;The Go SDK not having declarative workflows yet turns out to be a clarifying accident. It forces the question most teams skip on Python: do we actually need this, or does it just look tidy in a demo? If you need it, it's a hundred lines and you've now seen them. If you don't, you were always one well-named function away from the same result, and Go quietly kept you honest about it. Either way, you didn't have to wait for a pivot to fill in.&lt;/p&gt;

&lt;p&gt;If you liked the "own your building blocks instead of importing a framework" flavor of this, the same instinct shows up in &lt;a href="https://andriiboyko.com/articles/building-a-rest-api-in-go-thats-pleasant-to-extend" rel="noopener noreferrer"&gt;building a REST API in Go that's pleasant to extend&lt;/a&gt;, where Go 1.22's standard library removed most reasons to reach for a router.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/declarative-agent-workflows-in-go" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>declarativeworkflows</category>
      <category>microsoftagentframework</category>
    </item>
    <item>
      <title>CQRS in NestJS: When It's Worth the Complexity</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:16:38 +0000</pubDate>
      <link>https://dev.to/andriiboyko/cqrs-in-nestjs-when-its-worth-the-complexity-2f5n</link>
      <guid>https://dev.to/andriiboyko/cqrs-in-nestjs-when-its-worth-the-complexity-2f5n</guid>
      <description>&lt;p&gt;The person most responsible for the industry-standard explanation of CQRS is also the person telling you not to use it. Martin Fowler's bliki entry on CQRS, the page half of the backend world links to when they explain the pattern, says plainly: "you should be very cautious about using CQRS." Not a hedge. Not a "consider the tradeoffs." A direct warning, from the guy whose writeup made the term mainstream.&lt;/p&gt;

&lt;p&gt;That's not a contradiction. It's the actual state of the pattern: genuinely useful in a narrow set of cases, and a productivity tax everywhere else. The hard part isn't understanding CQRS. It's telling which side of that line your service is on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CQRS Separates
&lt;/h2&gt;

&lt;p&gt;CQRS traces back further than most people assume. Bertrand Meyer's Command-Query Separation principle, from his work on Eiffel, said a method should either change state or return data, never both, at the level of a single function. CQRS takes that idea and moves it up a layer: instead of one function doing one thing, it's your write model and your read model that split. Udi Dahan was applying this to service-oriented systems as early as 2008, formalizing it as "Clarified CQRS" in 2009. Greg Young popularized the term and the modern shape of the pattern around 2010, usually alongside domain-driven design.&lt;/p&gt;

&lt;p&gt;The core move: commands change state and return nothing meaningful. Queries return data and change nothing. Once you accept that split, you're free to give commands and queries completely different models, different validation rules, even different storage, because nothing has to reconcile a single "the truth" schema that serves both jobs at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Read/Write Skew That Justifies It
&lt;/h2&gt;

&lt;p&gt;Fowler names two legitimate reasons to reach for CQRS: a domain complex enough that a unified model would need to compromise for both directions, or a real performance requirement to scale reads and writes independently. Everything else is optional complexity dressed up as architecture.&lt;/p&gt;

&lt;p&gt;The skew that matters isn't "we have more reads than writes." Almost every system does. It's a &lt;em&gt;structural&lt;/em&gt; mismatch: your write side needs rich validation, business rules, and multi-step invariants, while your read side needs to answer questions your write model was never shaped to answer efficiently. A booking system that validates availability, conflicts, and cancellation policy on write, but needs to answer "show me every booking across three time zones grouped by room" on read, has structural skew. A blog with a &lt;code&gt;posts&lt;/code&gt; table and a &lt;code&gt;title&lt;/code&gt;/&lt;code&gt;body&lt;/code&gt;/&lt;code&gt;published_at&lt;/code&gt; doesn't, no matter how many more reads than writes it serves.&lt;/p&gt;

&lt;p&gt;If your honest answer to "why can't the same model serve both" is "it could, we just don't want to write two queries," that's not skew. That's an excuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the NestJS CQRS Module Gives You
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@nestjs/cqrs&lt;/code&gt; gives you three buses: &lt;code&gt;CommandBus&lt;/code&gt;, &lt;code&gt;QueryBus&lt;/code&gt;, and &lt;code&gt;EventBus&lt;/code&gt;. All three are built on RxJS Observables, so you can subscribe to the whole stream of commands, queries, or events flowing through your application, not just the individual handler results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/scheduling/commands/create-swap-request.handler.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;CommandHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CreateSwapRequestCommand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateSwapRequestHandler&lt;/span&gt;
  &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;ICommandHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CreateSwapRequestCommand&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&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="na"&gt;shifts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShiftRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateSwapRequestCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shifts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestSwap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestedBy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shifts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;shift&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="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 command handler owns validation and business rules. It doesn't return the shift, a DTO, or anything the frontend would render, just enough to confirm the write happened. A matching query handler pulls from whatever shape is fastest to read, which does not have to be the same tables the command touched.&lt;/p&gt;

&lt;p&gt;One detail that catches people off guard: &lt;code&gt;CommandBus&lt;/code&gt;, &lt;code&gt;QueryBus&lt;/code&gt;, and &lt;code&gt;EventBus&lt;/code&gt; are singletons, so combining them with NestJS's request-scoped providers takes extra care. The library handles this by spinning up a new instance of a request-scoped handler for each command, query, or event it processes, rather than sharing one instance across requests. It works, but it's not obvious from the decorator syntax that anything special is happening underneath.&lt;/p&gt;

&lt;p&gt;Event handlers run asynchronously and are expected to handle their own exceptions. An event handler that throws doesn't crash the request that published the event, it gets caught, wrapped into an &lt;code&gt;UnhandledExceptionInfo&lt;/code&gt;, and pushed onto a separate &lt;code&gt;UnhandledExceptionBus&lt;/code&gt; stream.&lt;/p&gt;

&lt;p&gt;If nothing is listening to that stream, the failure disappears silently. First time I saw this, I assumed a failing event handler would at least log something by default. It doesn't. You have to wire that up yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  CQRS Is Not Event Sourcing
&lt;/h2&gt;

&lt;p&gt;These two get bundled together constantly, partly because they show up in the same tutorials and partly because event sourcing naturally produces a write side and a read side that already look like CQRS. But they're independent decisions.&lt;/p&gt;

&lt;p&gt;Event sourcing means your source of truth is an append-only log of events, and current state is a projection computed by replaying them. CQRS means your read and write models are separate. You can event-source without CQRS (replay events into the same model you write through). You can do CQRS without event sourcing (a normal relational write model, plus a denormalized read table kept in sync by a projector). NestJS's CQRS module happens to ship with &lt;code&gt;AggregateRoot&lt;/code&gt; and event-emitting building blocks that make it easy to slide into event sourcing, but nothing about &lt;code&gt;CommandBus&lt;/code&gt; and &lt;code&gt;QueryBus&lt;/code&gt; requires it.&lt;/p&gt;

&lt;p&gt;Conflating the two is how projects end up with the operational cost of an event store (replay logic, event versioning, snapshotting) when all they actually needed was a read-optimized table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Mode: Applying CQRS to Everything
&lt;/h2&gt;

&lt;p&gt;The most common way teams burn the investment is treating CQRS as a system-wide default instead of a targeted tool. Fowler's own framing: many systems fit a CRUD mental model just fine, and forcing CQRS onto them is "a significant mental leap for all concerned" with no matching payoff.&lt;/p&gt;

&lt;p&gt;The failure compounds in a microservices context. If you don't identify your aggregate boundaries correctly first, and then apply CQRS on top of that mistake, you end up splitting a single aggregate's data across services, because the read side "needs" a view that spans what should have been one consistency boundary. That's not CQRS creating the mess, it's CQRS making an existing modeling mistake more expensive to carry.&lt;/p&gt;

&lt;p&gt;The rule that scales: apply CQRS to the specific bounded contexts where the skew is real, never to a whole application by default. A platform can have three services where CQRS earns its keep and twelve where a repository and a DTO are all anyone needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eventual Consistency Tax You're Paying
&lt;/h2&gt;

&lt;p&gt;Splitting the models means the read side can lag the write side. How much depends entirely on your projection mechanism, synchronous in the same transaction, asynchronous via a message queue, or somewhere in between, but the lag is never exactly zero once the models are meaningfully separate.&lt;/p&gt;

&lt;p&gt;The concrete version of this problem: a user places an order, and it can take anywhere from a couple of seconds to a couple of minutes before that order shows up in their order history, depending on how the projection pipeline is built. That's not a bug. It's the actual cost of the pattern, and it has to be a product decision, not something the write side discovers by accident when a support ticket comes in asking why a booking "disappeared."&lt;/p&gt;

&lt;p&gt;If your UI can't tolerate that lag anywhere in the flow, either the projection needs to be synchronous for that specific path, defeating some of the scaling benefit, or CQRS is the wrong tool for that particular read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Reach for This
&lt;/h2&gt;

&lt;p&gt;On the healthcare platform I work on now, the scheduling side has exactly this shape: writing a shift change means checking coverage rules, skill-level requirements, and conflict windows across a rotation, real domain complexity on the command side, while the read side mostly needs to answer "show me the week" in a dozen different groupings (by provider, by department, by shift type) fast enough for a calendar UI that gets hit constantly. That's read/write skew, not a preference.&lt;/p&gt;

&lt;p&gt;Credentialing, on the same platform, mostly isn't. A verification record has a status, an expiration date, and an audit trail. The queries against it are close enough to the write shape that splitting the models would add a synchronization problem without buying anything back. One service, two very different answers to "should this use CQRS," and the difference is the skew, not the domain's importance.&lt;/p&gt;

&lt;p&gt;That's the actual decision procedure: not "is this domain important" or "are we doing microservices," but "would the read model and the write model genuinely want to be different shapes, and is the eventual-consistency cost something the product can absorb." If both answers are yes, the complexity Fowler warns about is the complexity you're supposed to be paying for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.andriiboyko.com/articles/cqrs-in-nestjs-when-its-worth-the-complexity" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ddd</category>
      <category>node</category>
      <category>cqrs</category>
      <category>nestjs</category>
    </item>
    <item>
      <title>Bounded Contexts in a Real Codebase, Not a Diagram</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:16:24 +0000</pubDate>
      <link>https://dev.to/andriiboyko/bounded-contexts-in-a-real-codebase-not-a-diagram-4cp4</link>
      <guid>https://dev.to/andriiboyko/bounded-contexts-in-a-real-codebase-not-a-diagram-4cp4</guid>
      <description>&lt;p&gt;Why the one-context-one-service rule breaks down once you map real domain boundaries onto a live platform, and what decides where the seams go instead.&lt;/p&gt;

&lt;p&gt;Everyone draws the same diagram. A box for each part of the domain, a straight line from each box to a service, one arrow per box. It's the tidiest picture you'll ever ship in an architecture review, and the moment you try to build it exactly as drawn, it starts to lie to you.&lt;/p&gt;

&lt;p&gt;I've now been on both sides of that diagram: the person who drew it with total confidence in year one, and the person who had to explain eighteen months later why the "Credentialing" box didn't match the credentialing service. The mismatch wasn't a mistake. It's what happens when you take a modeling concept and treat it like a deployment topology.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Bounded Context Is
&lt;/h2&gt;

&lt;p&gt;The term comes from Eric Evans's 2003 book &lt;em&gt;Domain-Driven Design: Tackling Complexity in the Heart of Software&lt;/em&gt;. A bounded context is the boundary inside which a particular model, and the language that describes it, stays consistent. Outside that boundary, the same word can mean something else entirely, and that's fine, because you're no longer inside the model where it was defined.&lt;/p&gt;

&lt;p&gt;The canonical example: "Customer" in a sales context means someone who can place an order. "Customer" in a support context means someone with a ticket history and an SLA. Same word, two different models, two different sets of rules about what's valid. A bounded context is where you stop pretending those are the same thing and give each one its own vocabulary, its own invariants, and its own boundary.&lt;/p&gt;

&lt;p&gt;That's a modeling concept. It says nothing about how many services you run, how you deploy them, or who's on call for which one. Which is exactly where the diagram goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One-to-One Assumption, and Why It Breaks
&lt;/h2&gt;

&lt;p&gt;The popular shorthand is: one bounded context, one microservice. It's easy to teach, easy to draw, and easy to sell in a slide deck. DDD writer Vlad Khononov put the actual relationship more precisely: "A Microservice is a Bounded Context, but not vice versa. Not every Bounded Context is a Microservice." A bounded context defines the largest boundary inside which a model can stay consistent. A microservice is a deployment decision made inside that boundary, and sometimes below it, not a mirror of it.&lt;/p&gt;

&lt;p&gt;Here's the failure mode in practice. A team draws contexts too small, one per entity, and ends up with a dozen services that all have to call each other synchronously to do anything useful. That's not microservices. That's a monolith with the process boundaries moved into the network, which is strictly worse: same coupling, new latency, new failure modes.&lt;/p&gt;

&lt;p&gt;The industry has a name for it now: the distributed monolith. It shows up most often when a team splits a system along its existing code layers (controllers, services, repositories) instead of along real domain boundaries, then calls the result "microservices" because there are multiple deployables.&lt;/p&gt;

&lt;p&gt;The opposite mistake is just as common and less talked about: drawing one context for the entire domain, then building a single service with DDD vocabulary sprinkled over what is, underneath, still a big ball of mud. You get the ceremony of bounded contexts (aggregates, repositories, a ubiquitous language doc nobody updates) without the actual isolation that makes any of it worth doing.&lt;/p&gt;

&lt;p&gt;The honest answer is that a bounded context and a service are two different kinds of boundary, and they only sometimes line up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One bounded context can span multiple services, when the context is large enough that splitting it makes operational sense (different scaling needs, different release cadence, different team).&lt;/li&gt;
&lt;li&gt;Multiple small, tightly related contexts can live inside one service, when splitting them would only add network calls without adding independence.&lt;/li&gt;
&lt;li&gt;A context can be split across a synchronous API and an asynchronous event stream, which is not "two contexts," it's one context with two integration surfaces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deciding which of these is true for a given part of your domain depends on things a bounded context diagram can't tell you: team size, deployment cadence, scaling profile, and how often the boundary needs to move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a Real Split Came From
&lt;/h2&gt;

&lt;p&gt;I've spent the last year leading the technical direction of a healthcare workforce platform built around two products: a staff scheduling system and a credentialing system. On paper, that's two bounded contexts and, if you followed the popular rule, two services. In practice, the platform runs scheduling, credentialing, and integrations as separate services, and that third one is the interesting part.&lt;/p&gt;

&lt;p&gt;Integrations wasn't its own bounded context in the domain sense. It doesn't own a piece of business vocabulary the way scheduling ("shift," "coverage," "swap request") or credentialing ("primary source verification," "expiration," "attestation") do. What it owns is a boundary problem: every external system we talk to (identity providers, document verification vendors, notification services) has its own model, its own failure modes, and its own pace of change, and none of that should leak into the scheduling or credentialing models.&lt;/p&gt;

&lt;p&gt;That's the anti-corruption layer pattern from context mapping, given its own service instead of a library everyone imports. Once external integrations lived behind their own boundary, the scheduling and credentialing services stopped needing to know anything about a specific vendor's API shape, retry semantics, or auth flow. They call an internal contract; the integrations service is the only thing that has to change when a vendor changes theirs.&lt;/p&gt;

&lt;p&gt;That's a service boundary that exists for operational reasons (isolate volatility, isolate failure, let one team own vendor relationships) layered on top of a modeling boundary that exists for domain reasons (keep "shift" and "verification" from ever meaning two things at once). Stabilizing the platform's architecture meant getting that distinction right, not drawing a cleaner box diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  When One Service Should Hold Multiple Contexts
&lt;/h2&gt;

&lt;p&gt;The reverse case matters just as much, and it's the one people are more reluctant to admit out loud: sometimes the right call is to keep two related contexts in the same service, at least for a while.&lt;/p&gt;

&lt;p&gt;Two sub-domains that change together, get deployed together, and are owned by the same two-person team don't need a network boundary between them. Splitting them early buys you nothing except a second CI pipeline, a second on-call rotation to staff, and a synchronous call where a function call used to be. The tradeoff to weigh isn't "is this technically a separate context," it's "does separating this reduce coordination cost, or just add infrastructure?"&lt;/p&gt;

&lt;p&gt;Signals that it's still too early to split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The two contexts are always deployed in the same release.&lt;/li&gt;
&lt;li&gt;One team owns both, and that's not changing soon.&lt;/li&gt;
&lt;li&gt;The only reason for the split is "microservices are the right architecture," not a concrete scaling or ownership problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signals that it's worth the network hop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The contexts have genuinely different scaling profiles (one is read-heavy and cacheable, the other is write-heavy and transactional).&lt;/li&gt;
&lt;li&gt;Different teams need to own them, and shared deployment is actively slowing both down.&lt;/li&gt;
&lt;li&gt;One context's failure shouldn't be able to take the other down, and today it can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are visible on a static bounded-context diagram. They're operational facts about the system you're running right now, and they change over time, which is why context boundaries and service boundaries are allowed to diverge and then re-converge later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.andriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fbounded-contexts-in-a-real-codebase-not-a-diagram%2Fcontext-map-scheduling-credentialing-integrations.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.andriiboyko.com%2Fassets%2Fimgs%2Farticles%2Fbounded-contexts-in-a-real-codebase-not-a-diagram%2Fcontext-map-scheduling-credentialing-integrations.svg" alt="Diagram showing Scheduling and Credentialing services connecting through an internal contract to an Integrations anti-corruption layer, which connects via vendor-specific contracts to an identity provider, verification vendor, and notification service" width="1600" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Seams: Context Mapping Patterns Worth Knowing
&lt;/h2&gt;

&lt;p&gt;Once you accept that contexts and services don't automatically line up, the interesting question becomes: what sits at the seam? DDD's context mapping patterns answer that, and three of them cover most real cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared kernel&lt;/strong&gt; is when two contexts agree to share a small, explicitly maintained subset of the model, usually because splitting it would cost more than the coordination overhead of keeping it in sync. It's the pattern to reach for rarely and deliberately, not the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer/supplier&lt;/strong&gt; is when one context's team can influence the other's roadmap because they depend on it, and the supplier team treats that dependency as a real constraint on what they ship. This is a people pattern as much as a code pattern: it only works if the supplier team honors it in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anti-corruption layer&lt;/strong&gt; is the one that shows up the most in practice, and it's the pattern the integrations service above is built on. A translation boundary sits between two models so that neither one has to bend to accommodate the other. A small TypeScript sketch of the shape:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/integrations/verification/anti-corruption-layer.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The vendor's shape. We don't control this and it can change without warning.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VendorVerificationResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;verification_status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;V1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;V2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;V3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;subject_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;checked_at_utc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Our domain's shape. This is the only thing Credentialing ever sees.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PrimarySourceVerification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;providerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vendorResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VendorVerificationResponse&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PrimarySourceVerification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;VendorVerificationResponse&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verification_status_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;PrimarySourceVerification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;V1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;V2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;V3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;statusMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;vendorResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification_status_code&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;providerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vendorResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vendorResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification_status_code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;V2&lt;/span&gt;&lt;span class="dl"&gt;"&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;vendorResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checked_at_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&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;Nothing clever is happening here on purpose. That's the point: the translation function is the entire cost of keeping the vendor's model out of the domain. When the vendor renames a field or adds a fourth status code, this function is the only place that changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Boundaries Before You Build Anything
&lt;/h2&gt;

&lt;p&gt;If you're doing this for the first time, don't start by drawing boxes. Start with event storming: get the people who understand the domain in a room (or a shared board), and map out the significant business events on a timeline, in the language the business uses for them, not the language your database schema uses. Boundaries tend to reveal themselves as clusters: events that trigger each other tightly belong together, and gaps where one cluster hands off to another, often with a change in vocabulary, are your candidate context boundaries.&lt;/p&gt;

&lt;p&gt;It's slower than opening a whiteboard and drawing four boxes from memory. It's also the difference between a boundary that reflects how the business actually works and a boundary that reflects how the code happened to be organized when you looked at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Boundary Should Have Been
&lt;/h2&gt;

&lt;p&gt;If I were drawing that diagram again for the platform I'm working on now, I wouldn't add a fourth box for "Integrations" as if it were a peer domain concept next to scheduling and credentialing. I'd draw it as what it is: an anti-corruption layer that earned its own deployable because of operational concerns, not because the domain model demanded a third bounded context. The distinction matters, because the next time someone asks "should this be its own service," the domain diagram won't answer the question. The operational tradeoffs will.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.andriiboyko.com/articles/bounded-contexts-in-a-real-codebase-not-a-diagram" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>ddd</category>
      <category>bounded</category>
      <category>contexts</category>
    </item>
  </channel>
</rss>
