<?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>What a Store Project Really Costs: The Problems Drive It, Not the Platform</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 24 Sep 2026 22:27:00 +0000</pubDate>
      <link>https://dev.to/zelpex/what-a-store-project-really-costs-the-problems-drive-it-not-the-platform-hmf</link>
      <guid>https://dev.to/zelpex/what-a-store-project-really-costs-the-problems-drive-it-not-the-platform-hmf</guid>
      <description>&lt;p&gt;A common first question from store owners is what a project costs on Shopify compared with Adobe Commerce. It is a reasonable question with a disappointing answer: the platform is rarely what decides the bill. We compared the licences in &lt;a href="https://zelpex.com/blog/exploring-the-cost-of-ownership-magento-shopify-and-bigcommerce-compared" rel="noopener noreferrer"&gt;what Adobe Commerce, Shopify and BigCommerce actually cost&lt;/a&gt;, and even there the licence was usually the smallest number in the total.&lt;/p&gt;

&lt;p&gt;What decides the bill is the set of problems the store already has, most of which sit outside the storefront. That is why we won't quote a figure on the first call, and why the quote we do write is split into phases, each with its assumptions written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a price list can't answer the question
&lt;/h2&gt;

&lt;p&gt;Two stores on the same platform, with the same number of products, can need very different projects. One keeps its stock in Shopify and ships from one warehouse. The other has stock mastered in an ERP, a courier with its own API, a finance team that needs every invoice to match, and a marketplace feed with its own format. The storefront work is similar. Everything around it isn't.&lt;/p&gt;

&lt;p&gt;So our honest answer to "what does it cost" is the shape rather than a number: an integration or a focused piece of work is usually measured in weeks, a platform build in months. What moves a project from one end of that range to the other is what the rest of this piece is about.&lt;/p&gt;

&lt;p&gt;For a sense of scale: a first integration phase usually lands between 1 and 3 weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problems that actually move the number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Two systems that both think they own the truth.&lt;/strong&gt; Most integration bugs are not code failures. They are two systems both believing they own the price, or the stock level, or the customer record. Until someone decides which system is the record for each field, every connector built on top inherits the argument. Deciding it costs a workshop. Not deciding it costs a campaign weekend of oversold products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stock that lives somewhere else.&lt;/strong&gt; If inventory is mastered in an ERP or a warehouse system, the store only knows what the last sync told it. Whether that sync needs to be near real time or can run nightly is a business decision, and it is worth making per data type: stock usually needs to be fresh because being wrong means an oversell, while financial postings are often fine overnight. Making everything real time "to be safe" is one of the quietest ways to inflate a budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product data nobody has cleaned.&lt;/strong&gt; Catalogues accumulate attributes in the wrong places: sizes in tags, materials in titles, variants split into separate products. Every migration, search project or AI channel has to deal with it. It also costs sales directly: Baymard's usability research found that 10% of the largest e-commerce sites don't keep a consistently high level of detail in their product descriptions, and test users abandoned products when they couldn't find what they needed. Cleaning data is unglamorous work that no platform does for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business rules that don't fit a cart.&lt;/strong&gt; Rentals with availability windows, marketplaces with per-vendor payouts, configurators where the price depends on a dozen inputs. These bend a platform until the workarounds cost more than building the part that is genuinely yours. The test we use is simple: list the five things a platform would force you to work around. If they are cosmetic or only affect the admin, stay on the platform. If they touch how money is calculated, how inventory behaves over time or who is party to a transaction, it is time to look at custom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integrations someone else built.&lt;/strong&gt; Fixing those is a good share of what we do. We start by instrumenting the connector to find where records actually go missing, which is frequently somewhere other than where the team suspects, and the fix is sometimes small. Sometimes the honest answer is that a rewrite is cheaper than another patch. Either way, you can't price it before you look.&lt;/p&gt;

&lt;p&gt;One example from our own work: a client came to us for a redesign. Discovery showed that half their product attributes lived in tags and titles, and cleaning the catalogue took longer than the theme work, close to two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we turn problems into a quote
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery first, when it isn't obvious.&lt;/strong&gt; A short discovery maps the systems, the constraints and what is genuinely in the way, and ends with a written recommendation. Sometimes that recommendation is that you don't need a build at all, which is a cheaper answer than finding out six months in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phases, with the assumptions written down.&lt;/strong&gt; We quote per phase so you can see what moves the number before you commit to the whole thing. If an assumption turns out to be wrong, it is visible which part of the price it affects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed price where the scope is precise.&lt;/strong&gt; A migration, a connector or a defined feature set can be fixed-priced. Ongoing product work is better on a monthly team, because pretending an evolving roadmap can be fixed-priced usually ends in an argument about what was in scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with something small.&lt;/strong&gt; A first phase that ships something real tells you more about working with us than any reference call, and it tells us more about your systems than any discovery document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes the same project cheaper
&lt;/h2&gt;

&lt;p&gt;A few decisions reliably take cost out before any code is written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick one system of record per field&lt;/strong&gt; and write it down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide batch or real time per flow&lt;/strong&gt;, not globally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the risky part first.&lt;/strong&gt; Whatever is most likely to be wrong, the pricing engine, the availability calendar, the payout maths, gets built and tested against real data early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the platform-shaped answer before the custom one.&lt;/strong&gt; If Shopify, BigCommerce, Adobe Commerce or Medusa can carry the model with reasonable extension, that is the cheaper project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to bring to the first call
&lt;/h2&gt;

&lt;p&gt;You don't need a specification. These five things are enough to turn a conversation into an estimate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The list of systems around the store&lt;/strong&gt;: ERP, warehouse, PIM, couriers, marketplaces, finance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which system is the truth for stock and price today&lt;/strong&gt;, even if the answer is "it depends".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three things that currently go wrong&lt;/strong&gt;, in the words the operations team uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rules that make your business unusual&lt;/strong&gt;: how price is derived, how availability works, who gets paid what.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What a good first phase would change for you&lt;/strong&gt;, in one sentence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you would rather work through your own list with us, &lt;a href="https://zelpex.com/contact-us" rel="noopener noreferrer"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Product data figures from Baymard Institute's research on &lt;a href="https://baymard.com/blog/product-descriptions" rel="noopener noreferrer"&gt;product descriptions&lt;/a&gt;. Zelpex's approach as described on our &lt;a href="https://zelpex.com/services/custom-ecommerce-development" rel="noopener noreferrer"&gt;custom e-commerce&lt;/a&gt; and &lt;a href="https://zelpex.com/services/custom-ecommerce-integration" rel="noopener noreferrer"&gt;integration&lt;/a&gt; pages and in our FAQ.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://zelpex.com/blog/store-project-cost-problems-not-platform?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=store-project-cost-problems-not-platform" rel="noopener noreferrer"&gt;zelpex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>management</category>
      <category>productivity</category>
      <category>commerce</category>
    </item>
    <item>
      <title>You Don't Need Adobe Commerce Cloud to Survive Black Friday</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:55:17 +0000</pubDate>
      <link>https://dev.to/andriiboyko/you-dont-need-adobe-commerce-cloud-to-survive-black-friday-2jcf</link>
      <guid>https://dev.to/andriiboyko/you-dont-need-adobe-commerce-cloud-to-survive-black-friday-2jcf</guid>
      <description>&lt;p&gt;Everyone treats Adobe Commerce Cloud like it's the only thing standing between their store and a Black Friday meltdown. It's the platform that survives the spike, the managed fortress you can't possibly replicate, the reason the six-figure invoice is worth it. So let's pull the curtain back and look at what a Pro production environment actually is.&lt;/p&gt;

&lt;p&gt;It's three virtual machines behind a load balancer, replicated across three AWS availability zones. That's it. That's the fortress. Adobe's own architecture docs describe the Pro production environment as having "three virtual machines (VMs) behind an Elastic Load Balancer," and each project "replicated on three separate AWS or Azure Availability Zones." Once you've seen that, the Black Friday question changes shape completely. It stops being "how do I buy the platform that survives" and becomes "can I build a three-node, multi-AZ cluster on AWS?" And you already know the answer to that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Adobe Commerce Cloud Pro actually is
&lt;/h2&gt;

&lt;p&gt;Strip the branding and Pro is a managed, dedicated cluster on AWS (Azure is an option, but AWS is the common case). Three nodes, and here's the part people get wrong: it's not a primary with two hot spares. It's &lt;strong&gt;active-active&lt;/strong&gt;. Adobe runs a Galera cluster where "all three instances accept reads and writes," with an auto-increment setting of three so IDs stay unique across every node. They explicitly contrast this with "a traditional active-passive master or master-slave setup." All three nodes take traffic, all three take writes, and losing one AZ doesn't take the store down.&lt;/p&gt;

&lt;p&gt;Each of those three nodes carries the full stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fastly&lt;/strong&gt; in front for CDN, WAF, and image optimization (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NGINX with PHP-FPM&lt;/strong&gt; as the web tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Galera / MariaDB&lt;/strong&gt; as the active-active database, one instance per node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; (Valkey on newer releases), one server per VM, "with only one active and the other two as replicas."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elasticsearch or OpenSearch&lt;/strong&gt; for catalog search, and which one depends on your version. Elasticsearch on older Commerce releases, OpenSearch from 2.4.4 onward. Never say just "Elasticsearch" without checking the version, because on a current store it's almost certainly OpenSearch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GlusterFS&lt;/strong&gt; as the shared file store, keeping media and static assets synced across the three nodes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, the "six nodes" number you may have heard is real, but it is not the baseline. Adobe's scaled architecture splits the stack into a tiered layout: "a minimum of six nodes: three nodes for the core database and services and three nodes for the web server." That only kicks in on "the Pro 48 cluster or greater." So the default Pro store surviving your Black Friday is three nodes, and the six-node split is a bigger-plan upgrade, not the standard. If someone tells you Adobe Cloud is inherently a six-node monster, they're describing a tier most stores never buy.&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-dont-need-adobe-commerce-cloud-to-survive-black-friday%2Fadobe-commerce-cloud-pro-real-topology.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-dont-need-adobe-commerce-cloud-to-survive-black-friday%2Fadobe-commerce-cloud-pro-real-topology.avif" alt="Adobe Commerce Cloud Pro topology: Fastly CDN, WAF and image optimization over an ELB feeding three identical nodes, each running NGINX + PHP-FPM, Galera/MariaDB, Redis, OpenSearch and GlusterFS, with the Galera layers linked active-active across three availability zones" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually survives the spike
&lt;/h2&gt;

&lt;p&gt;Here's the thing worth internalizing before you write a check: none of what makes this cluster survive Black Friday is proprietary Adobe magic. It's a handful of well-understood architecture decisions, and every one of them is a pattern you can name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full-page caching at the edge.&lt;/strong&gt; Fastly is a Varnish-based service that caches "site pages, assets, CSS, and more" at global edge nodes. On Black Friday, the overwhelming majority of your traffic is people looking at the same category and product pages. If those are served from cache, they never touch PHP or the database at all. This is the single biggest reason the cluster holds: the origin only sees the long tail of uncacheable, personalized, add-to-cart requests. Fastly also runs the WAF, though note it's "available on Pro and Starter Production environments only," so your staging traffic isn't behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An active-active database with no single writer.&lt;/strong&gt; Because all three Galera nodes accept writes, there's no lone primary whose death stops checkout, and no failover dance while the replicas get promoted. Don't read that as three times the write throughput, though. Every write is still certified and applied on every node, so Galera buys you availability and spread-out connections, not linear write scaling. For a Black Friday store, availability is the part that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A web tier you can throw hardware at.&lt;/strong&gt; NGINX and PHP-FPM are stateless request handlers. The session and cart state live in Redis, the catalog in the database and search index. That statelessness is what makes horizontal scaling possible at all: nothing about node two knows or cares what node one just served.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-AZ as the failure story.&lt;/strong&gt; Three availability zones means a whole datacenter can have a bad day and your store stays up on the other two. This isn't a performance feature, it's a survival feature, and it's the one people most often skip when they roll their own and then regret at the worst possible moment.&lt;/p&gt;

&lt;p&gt;Four patterns. Edge cache, active-active writes, stateless autoscaling web tier, multi-AZ redundancy. Hold onto those, because rebuilding Adobe Cloud is really just rebuilding those four things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilding it on AWS
&lt;/h2&gt;

&lt;p&gt;You don't have to guess at the mapping, because AWS published it. Their whitepaper on migrating Magento Open Source or Adobe Commerce to AWS lays out a reference architecture that mirrors the Adobe Cloud stack service-for-service. AWS has since stamped it "for historical reference only," and the software versions in it have aged, but the service mapping hasn't. The pieces line up like 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-dont-need-adobe-commerce-cloud-to-survive-black-friday%2Fadobe-commerce-cloud-self-hosted-aws.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-dont-need-adobe-commerce-cloud-to-survive-black-friday%2Fadobe-commerce-cloud-self-hosted-aws.avif" alt="Two-column table mapping Adobe Cloud Pro components to AWS equivalents: Fastly to CloudFront + AWS WAF, Varnish to Varnish on EC2, ELB to ALB, PHP-FPM nodes to an EC2 Auto Scaling group, Galera to Aurora or RDS MySQL Multi-AZ, Redis to ElastiCache, OpenSearch to OpenSearch Service, RabbitMQ to Amazon MQ, GlusterFS to S3 or EFS" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The mappings, straight from AWS's reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDN&lt;/strong&gt;: Amazon CloudFront (or keep Fastly in front of AWS, plenty of stores do).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-page cache&lt;/strong&gt;: Varnish on EC2 in an Auto Scaling group. AWS's own reference keeps Varnish rather than leaning entirely on the CDN, which mirrors Magento's built-in Varnish integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing&lt;/strong&gt;: Application Load Balancer across multiple AZs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web / PHP tier&lt;/strong&gt;: EC2 instances in an Auto Scaling group across multiple availability zones. ECS or EKS is a common modern variant, but the AWS whitepaper itself uses plain EC2 plus autoscaling, so don't feel you need containers to be legitimate here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: Amazon RDS for MySQL or Aurora, and if you pick Multi-AZ, AWS deploys "a synchronously replicated secondary database" with "automated failover from the primary." That's not literally Galera active-active, and that's an honest difference worth sitting with: Aurora Multi-AZ gives you synchronous replication and automatic failover, not three simultaneously-writable nodes. For the vast majority of stores, failover-in-seconds is what you actually needed anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache&lt;/strong&gt;: Amazon ElastiCache with the Redis engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search&lt;/strong&gt;: Amazon OpenSearch Service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message queue&lt;/strong&gt;: Amazon MQ, the managed AMQP broker that stands in for RabbitMQ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared media&lt;/strong&gt;: AWS's reference uses Amazon S3 via the remote-storage module. If you want the closer analogue to GlusterFS's POSIX shared mount, EFS does that, at a different cost profile. Both are legitimate; S3 is what AWS reached for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The load-bearing piece, the database, is a few lines of Terraform. Multi-AZ with automatic failover, plus a subnet group that spans three zones so the standby always lands somewhere other than the primary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Plain RDS for MySQL. Aurora is a different resource shape&lt;/span&gt;
&lt;span class="c1"&gt;# (aws_rds_cluster + aws_rds_cluster_instance), same idea.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"commerce"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mysql"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.r6g.2xlarge"&lt;/span&gt;
  &lt;span class="nx"&gt;allocated_storage&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
  &lt;span class="nx"&gt;multi_az&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;          &lt;span class="c1"&gt;# synchronous standby in a second AZ, automatic failover&lt;/span&gt;
  &lt;span class="nx"&gt;backup_retention_period&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;storage_encrypted&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="c1"&gt;# subnet group spanning three AZs so the standby lands in a different zone&lt;/span&gt;
  &lt;span class="nx"&gt;db_subnet_group_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_subnet_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;three_az&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_subnet_group"&lt;/span&gt; &lt;span class="s2"&gt;"three_az"&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;"commerce-three-az"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_a&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;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_b&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;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_c&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the web tier is an Auto Scaling group that adds PHP-FPM instances when CPU climbs, which is exactly the behavior you want when the 9am doorbuster hits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group"&lt;/span&gt; &lt;span class="s2"&gt;"web"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;min_size&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;                &lt;span class="c1"&gt;# one warm instance per AZ&lt;/span&gt;
  &lt;span class="nx"&gt;max_size&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;              &lt;span class="c1"&gt;# room to absorb the spike&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_zone_identifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_a&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;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_b&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;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;az_c&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;target_group_arns&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;launch_template&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;aws_launch_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;php_fpm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="c1"&gt;# NGINX + PHP-FPM baked into the AMI&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"$Latest"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# scale out on sustained CPU - the doorbuster signal&lt;/span&gt;
  &lt;span class="c1"&gt;# (a target-tracking policy at ~60% CPU is the boring, correct default)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this is exotic. It's the standard AWS reference architecture, and it produces the same four survival properties the Adobe cluster has.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually give up (and what you gain)
&lt;/h2&gt;

&lt;p&gt;So if the architecture is reproducible, what is Adobe's invoice buying? Be honest about this, because it's the whole decision.&lt;/p&gt;

&lt;p&gt;You give up &lt;strong&gt;operations&lt;/strong&gt;. Adobe patches the OS, manages the Galera cluster, runs the Fastly relationship, bundles New Relic, mirrors your production topology in a Staging environment, and answers the phone when something breaks at 2am. That last one is not nothing. When you self-host, the pager is yours. The person who wakes up when RDS fails over is on your team, not Adobe's. For a lot of merchants, "we don't want to be the ones holding the pager on Black Friday" is a completely rational reason to pay.&lt;/p&gt;

&lt;p&gt;You also give up some &lt;strong&gt;conveniences that are genuinely bundled&lt;/strong&gt;: Fastly at no additional cost, a Staging environment that mirrors Production, the managed WAF, the support SLA. Rebuild it yourself and each of those is a line item and a responsibility.&lt;/p&gt;

&lt;p&gt;What you gain is &lt;strong&gt;control and economics&lt;/strong&gt;. You pick your AWS region instead of taking whatever Adobe provisions. You get root on your own boxes. You're not gated behind a Pro 48 plan to split your tiers, you scale exactly the component that's hot. And past a certain size, the raw AWS bill for this architecture is a fraction of the Adobe Cloud license, because you're paying for compute instead of for compute-plus-platform-plus-support.&lt;/p&gt;

&lt;h2&gt;
  
  
  So do you need it?
&lt;/h2&gt;

&lt;p&gt;Here's the verdict, and it's a fork, not a slogan. Surviving Black Friday is an &lt;strong&gt;architecture problem&lt;/strong&gt;, and the architecture is a three-node, multi-AZ, edge-cached, autoscaling cluster that you can absolutely build on AWS. Adobe Commerce Cloud does not sell you a secret that makes traffic spikes survivable. It sells you the operational burden lifted off your team, on infrastructure that is, underneath the branding, a standard AWS deployment.&lt;/p&gt;

&lt;p&gt;So the real question was never "can my store survive Black Friday without Adobe Cloud." It obviously can. The real question is "do I want to run this myself, or pay someone to run it for me." If you have a platform team that's comfortable with Terraform, RDS failover, and an on-call rotation, self-hosting gets you the same survival properties with more control and a smaller bill. If you don't, and you'd rather your engineers ship features than babysit a Galera cluster at midnight, then paying Adobe to hold the pager is a defensible call, as long as you're paying for the operations and not because you believed the infrastructure was something you couldn't build.&lt;/p&gt;

&lt;p&gt;Just don't let anyone tell you it's a fortress you couldn't have built. It's three nodes behind a load balancer, across three zones. You've built bigger.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/you-dont-need-adobe-commerce-cloud-to-survive-black-friday" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>magento</category>
      <category>ecommerce</category>
      <category>aws</category>
      <category>architecture</category>
    </item>
    <item>
      <title>UCP, Storefront MCP and Your Catalog: Getting a Shopify Store Agent-Ready</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:47:49 +0000</pubDate>
      <link>https://dev.to/zelpex/ucp-storefront-mcp-and-your-catalog-getting-a-shopify-store-agent-ready-310o</link>
      <guid>https://dev.to/zelpex/ucp-storefront-mcp-and-your-catalog-getting-a-shopify-store-agent-ready-310o</guid>
      <description>&lt;p&gt;In April 2026, developers on Shopify's community forum started reporting that their shopping assistants had stopped finding products. Calls to &lt;code&gt;search_shop_catalog&lt;/code&gt;, the Storefront MCP tool for searching a store's products, began returning &lt;code&gt;Tool not found&lt;/code&gt;. It had been replaced by &lt;code&gt;search_catalog&lt;/code&gt;, on a different endpoint, with a new input schema.&lt;/p&gt;

&lt;p&gt;That is a small incident and a useful one. The layer AI agents use to shop on Shopify is still moving, and getting a store agent-ready isn't a setting you flip. It means knowing which of three layers you depend on, and which parts of your own catalogue decide whether an agent can sell anything at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, one catalogue
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shopify Catalog&lt;/strong&gt; is the syndication layer. Eligible products are made discoverable to AI channels, which Shopify currently lists as ChatGPT, Google AI Mode and Gemini, Microsoft Copilot and Meta, with their title, description, options, images, price and availability. Shopify says it keeps inventory and pricing updated continuously across those channels. For most merchants this is the whole integration: nothing to build, but nothing to tune either, except the data you feed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storefront MCP&lt;/strong&gt; is the per-store interface. A Shopify store exposes an MCP server that an agent can call directly, without authentication, using JSON-RPC 2.0 over HTTP POST. This is what a custom assistant, or any agent that talks to your store rather than to a marketplace, actually uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Universal Commerce Protocol (UCP)&lt;/strong&gt; is the contract underneath. Shopify and Google co-developed it as an open standard for how agents discover products, negotiate what a merchant supports and run checkout. Merchants and agents publish profiles at &lt;code&gt;/.well-known/ucp&lt;/code&gt;, each side declares its capabilities, and the session runs on the intersection. Checkout, orders and catalogue are separate capabilities, each versioned on its own.&lt;/p&gt;

&lt;p&gt;The practical point for a technical lead: the first layer is Shopify's job, the second and third are contracts you may be coding against, and contracts at this stage change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an agent actually calls
&lt;/h2&gt;

&lt;p&gt;Storefront MCP is now split across two endpoints on your store's domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;https://{shop}.myshopify.com/api/ucp/mcp&lt;/code&gt; carries the UCP catalogue tools: &lt;code&gt;search_catalog&lt;/code&gt;, &lt;code&gt;lookup_catalog&lt;/code&gt; (up to 10 ids per call) and &lt;code&gt;get_product&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://{shop}.myshopify.com/api/mcp&lt;/code&gt; carries the rest: &lt;code&gt;search_shop_policies_and_faqs&lt;/code&gt;, &lt;code&gt;get_cart&lt;/code&gt; and &lt;code&gt;update_cart&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UCP tools expect an agent profile in every request and wrap their arguments in a &lt;code&gt;catalog&lt;/code&gt; object. This is the shape from Shopify's documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_catalog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ucp-agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://shopify.dev/ucp/agent-profiles/examples/2026-08-25/valid-with-capabilities.json"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"catalog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organic coffee beans"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address_country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer prefers fair trade products"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you wrote an agent against the older flat &lt;code&gt;search_shop_catalog&lt;/code&gt; call, the April change is exactly what broke it. The fix is small; the lesson is to treat the tool list as something you discover at runtime (&lt;code&gt;tools/list&lt;/code&gt; is part of MCP), pin behaviour behind your own adapter, and alert on &lt;code&gt;Tool not found&lt;/code&gt; rather than on empty results.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ord7lq9w20itypl0fir.jpg" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ord7lq9w20itypl0fir.jpg" alt="Architecture diagram of an AI channel reaching a Shopify store through Shopify Catalog and the two Storefront MCP endpoints, with checkout handing over by continue_url, all resting on the store's product data." width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkout is a state machine, not a redirect
&lt;/h2&gt;

&lt;p&gt;UCP models checkout as a session that moves through states, including &lt;code&gt;incomplete&lt;/code&gt;, &lt;code&gt;requires_escalation&lt;/code&gt; and &lt;code&gt;ready_for_complete&lt;/code&gt;. When the merchant needs something only a person can provide, the session escalates and returns a &lt;code&gt;continue_url&lt;/code&gt;; the buyer follows it and, in Shopify's words, "picks up exactly where the agent left off" in an embedded checkout.&lt;/p&gt;

&lt;p&gt;That changes what you test. The question is not whether an agent can reach your checkout, but which of your checkout rules push a session into escalation: custom validation, required fields your storefront adds, logic that lives in a theme rather than in the platform. Every escalation is a hand-off from a conversation to a web page, and some buyers won't make it across. Walk your real checkout through the agent path and list what escalates before you promise anyone "buy inside ChatGPT".&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalogue work that decides whether agents can sell
&lt;/h2&gt;

&lt;p&gt;Most of the effort sits here, and none of it is new code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eligibility.&lt;/strong&gt; Shopify Catalog only includes products with a title, at least one image and a price above zero, published to the online store, Hydrogen or a headless channel. Agentic storefronts also need an external product URL for each product, stored in the external URL standard metafield. A product that fails any of these is invisible to agents, however good it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structure.&lt;/strong&gt; Shopify's own guidance is to group genuine variants under one parent, so an agent understands one product with options rather than twelve near-duplicates, and to use the most specific category available: "men's insulated winter boots", not "footwear". It also asks for literal descriptions. Marketing copy that works on a landing page gives a language model very little to match against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data that lives in the wrong place.&lt;/strong&gt; Plenty of mature stores keep real attributes in tag prefixes, metafields, metaobjects or split titles. Shopify Catalog Mapping exists for exactly this: it points the catalogue at where your data actually lives. If your size, material or compatibility data is in a tag like &lt;code&gt;size:42&lt;/code&gt;, map it before assuming agents can see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B2B separation.&lt;/strong&gt; Agentic storefronts are for direct-to-consumer sales. Shopify excludes B2B-only products it can identify through B2B catalogues, customer account requirements or password-protected storefronts, but a custom B2B implementation may need products hidden by hand, by setting them to Unlisted. A store that mixes trade and retail pricing through its own logic should check what agents are actually offered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stock you can promise.&lt;/strong&gt; Shopify's line is that price and inventory "need to be accurate at the moment of the shopper's query". If stock is mastered in an ERP or WMS and synced to Shopify on a schedule, the agent sells from the last sync. On a busy day that is the difference between an order and a cancellation, and an agent-driven cancellation is a worse experience than a normal one, because the buyer never saw your store.&lt;/p&gt;

&lt;h2&gt;
  
  
  A readiness pass, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run an eligibility report&lt;/strong&gt; over the catalogue: no image, zero price, missing external URL, unpublished to the right channel. Fix these first; they are binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map your data.&lt;/strong&gt; List every attribute that lives in tags or metafields and set up Catalog Mapping for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collapse false variants&lt;/strong&gt; into single products with options, and tighten categories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what agents must not sell&lt;/strong&gt;, especially trade-only items, and set them to Unlisted if Shopify can't detect them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your stock lag.&lt;/strong&gt; How long between a change in the system that owns inventory and the same change in Shopify? If the answer is minutes, fix the sync before you invite agents in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk checkout through the agent path&lt;/strong&gt; and list every rule that escalates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you run your own agent&lt;/strong&gt;, wrap the MCP calls behind an adapter, discover tools at runtime and alert on renamed or missing tools.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Shopify reports that AI-referred orders grew nearly 13x year over year. An agent buying on a shopper's behalf never sees your theme. What it sees is your catalogue data, so that is where the work goes.&lt;/p&gt;

&lt;p&gt;If you would rather go through this against your own catalogue and integrations, &lt;a href="https://zelpex.com/contact-us" rel="noopener noreferrer"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against Shopify's &lt;a href="https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront" rel="noopener noreferrer"&gt;Storefront MCP documentation&lt;/a&gt;, the &lt;a href="https://shopify.engineering/UCP" rel="noopener noreferrer"&gt;Shopify Engineering post on UCP&lt;/a&gt;, the Help Center pages on &lt;a href="https://help.shopify.com/en/manual/online-sales-channels/agentic-storefronts" rel="noopener noreferrer"&gt;agentic storefronts&lt;/a&gt; and &lt;a href="https://help.shopify.com/en/manual/online-sales-channels/agentic-storefronts/products" rel="noopener noreferrer"&gt;catalogue product discovery&lt;/a&gt;, Shopify's guide to &lt;a href="https://www.shopify.com/enterprise/blog/agentic-ready-product-data" rel="noopener noreferrer"&gt;agentic-ready product data&lt;/a&gt;, and the &lt;a href="https://community.shopify.dev/t/storefront-mcp-search-shop-catalog-returning-tool-not-found-renamed-to-search-catalog/33256" rel="noopener noreferrer"&gt;community thread on the tool rename&lt;/a&gt;, in September 2026.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://zelpex.com/blog/shopify-agent-ready-ucp-storefront-mcp-catalog?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=shopify-agent-ready-ucp-storefront-mcp-catalog" rel="noopener noreferrer"&gt;zelpex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Magento 2.4.6 Lost Support in August: What Waiting Actually Costs</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:19:09 +0000</pubDate>
      <link>https://dev.to/zelpex/magento-246-lost-support-in-august-what-waiting-actually-costs-bie</link>
      <guid>https://dev.to/zelpex/magento-246-lost-support-in-august-what-waiting-actually-costs-bie</guid>
      <description>&lt;p&gt;If your store runs Magento 2.4.6, it stopped receiving security patches on 11 August 2026. The one exception is Adobe Commerce, the paid edition, which gets one more year. Magento Open Source, the free edition, does not.&lt;/p&gt;

&lt;p&gt;So the decision in front of you is about exposure, not features: how long you are willing to run a checkout that nobody will fix when the next hole is found, and what it costs to stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is out of support, and until when
&lt;/h2&gt;

&lt;p&gt;Adobe gives every 2.4 release three years of standard support, then stops shipping patches for it. For 2.4.6 and 2.4.7 it adds one extra year, but its lifecycle policy grants that year to "Adobe Commerce customers" only.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Standard support ended / ends&lt;/th&gt;
&lt;th&gt;Adobe Commerce only, extended to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.4.6&lt;/td&gt;
&lt;td&gt;11 August 2026&lt;/td&gt;
&lt;td&gt;31 August 2027&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.4.7&lt;/td&gt;
&lt;td&gt;31 May 2027&lt;/td&gt;
&lt;td&gt;31 May 2028&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.4.8&lt;/td&gt;
&lt;td&gt;31 May 2028&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.4.9&lt;/td&gt;
&lt;td&gt;31 May 2029&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is a second clock under the first, and it catches Adobe Commerce customers too. Magento 2.4.6 runs on PHP 8.1 or 8.2. PHP 8.1 stopped receiving security fixes on 25 November 2025, and PHP 8.2 stops on 31 December 2026. So even a licence holder using the extra year will spend most of it on a language version that no longer gets security fixes of its own. The extension buys time to plan the upgrade. It is not a place to stay.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwj260a0lglxh63y4fkd.jpg" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwj260a0lglxh63y4fkd.jpg" alt="Timeline showing PHP 8.1 and 8.2 losing security support around the end of Magento 2.4.6 standard support in August 2026, with 2.4.9 supported until May 2029." width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What waiting has cost other stores
&lt;/h2&gt;

&lt;p&gt;The usual argument for waiting is that nothing has gone wrong yet. The last two years of Magento security history say otherwise.&lt;/p&gt;

&lt;p&gt;In June 2024 Adobe patched CosmicSting (CVE-2024-34102), a flaw that let attackers read files on the server, including the store's secret encryption key. Sansec, which monitors Magento stores, reported that by that autumn 5% of all Adobe Commerce and Magento stores had a payment skimmer on their checkout page: 4,275 stores, attacked by seven competing groups. Some of them had installed the patch. Patching did not invalidate keys that had already been stolen, so stores that skipped rotating the key stayed open.&lt;/p&gt;

&lt;p&gt;In September 2025 Adobe shipped an emergency fix for SessionReaper (CVE-2025-54236), a critical flaw that could lead to remote code execution. Six weeks later, when attacks began, Sansec found 62% of Magento stores still unpatched.&lt;/p&gt;

&lt;p&gt;Two things follow for a store deciding what to do about 2.4.6. The gap between a fix and mass exploitation is measured in weeks, not years. And on an unsupported version there is no fix to install when the next one arrives: you are in the 62% by default, with no way out except the upgrade you postponed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compliance side
&lt;/h2&gt;

&lt;p&gt;If you take card payments, PCI DSS requirement 6.3.3 expects critical security patches to be installed within 30 days of release. Version 4.0.1 of the standard narrowed that to critical vulnerabilities only, but kept the deadline.&lt;/p&gt;

&lt;p&gt;A store on an unsupported version cannot meet that requirement the ordinary way, because no patch will be released. That doesn't make you non-compliant overnight, and it isn't legal advice. It does mean that at your next assessment you will need to explain which compensating controls you rely on instead, and that conversation gets harder every month the version stays in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the upgrade actually involves
&lt;/h2&gt;

&lt;p&gt;The upgrade itself is rarely the expensive part. What makes it expensive is everything around the Magento code.&lt;/p&gt;

&lt;p&gt;The infrastructure moves first. Magento 2.4.8 runs on PHP 8.3 or 8.4, and 2.4.9 only on PHP 8.5 with OpenSearch 3, so the hosting stack has to change along with the application. Then come extensions and integrations: Adobe's own guidance notes that Marketplace extensions and third-party integrations may be affected, and each one has to be checked, updated or replaced. A store with a handful of well-maintained extensions and a stock theme is a very different project from one with years of custom modules and a hand-built ERP sync.&lt;/p&gt;

&lt;p&gt;That is also why the cost of waiting compounds. Adobe says plainly that regular updates take less overall effort than infrequent ones. A store that moves every release does a small upgrade each time. A store that skips two or three does all of them at once, under pressure, usually after something has already gone wrong.&lt;/p&gt;

&lt;p&gt;Our view on the target: go to the newest release your extensions support. If everything you rely on runs on 2.4.9, that buys support until May 2029. If a critical extension isn't ready, 2.4.8 is a sound stop until May 2028, and the next step from there is smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options, honestly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upgrade on Magento.&lt;/strong&gt; For most stores this is the answer. It keeps your catalogue, customers, order history and integrations where they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move to Mage-OS.&lt;/strong&gt; Mage-OS is a community-maintained distribution of Magento Open Source that ships its own releases and security patches, with the aim of patching faster. It is currently based on Magento 2.4.9, so switching is itself an upgrade, not a way to stay on 2.4.6. It is worth considering if you would rather not depend on Adobe's release schedule for the free edition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replatform.&lt;/strong&gt; If the upgrade estimate comes back close to the cost of moving, it is fair to ask whether Magento is still the right platform. That is a bigger decision with its own hidden costs; we compared the platforms in &lt;a href="https://zelpex.com/blog/exploring-the-cost-of-ownership-magento-shopify-and-bigcommerce-compared" rel="noopener noreferrer"&gt;what Adobe Commerce, Shopify and BigCommerce actually cost&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do nothing.&lt;/strong&gt; A web application firewall and closer monitoring reduce risk while you plan. They buy time. They don't replace patches, and they don't help with the PHP version underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you get an estimate
&lt;/h2&gt;

&lt;p&gt;Four questions decide how big this job is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which edition are you on?&lt;/strong&gt; Adobe Commerce gives you until 31 August 2027. Open Source gave you until 11 August 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which PHP version does the server run?&lt;/strong&gt; If it is 8.1, the language itself is already out of security support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many extensions and custom modules does the store use, and who maintains them?&lt;/strong&gt; This list drives the estimate more than anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What sits around the store?&lt;/strong&gt; ERP, PIM, payment and shipping integrations each need testing against the new version.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you would rather work through this against your own store's extension list and integrations, &lt;a href="https://zelpex.com/contact-us" rel="noopener noreferrer"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Support dates verified against Adobe's &lt;a href="https://experienceleague.adobe.com/en/docs/commerce-operations/release/planning/lifecycle-policy" rel="noopener noreferrer"&gt;software lifecycle policy&lt;/a&gt; and &lt;a href="https://experienceleague.adobe.com/en/docs/commerce-operations/installation-guide/system-requirements" rel="noopener noreferrer"&gt;system requirements&lt;/a&gt;, and PHP dates against &lt;a href="https://www.php.net/supported-versions.php" rel="noopener noreferrer"&gt;php.net&lt;/a&gt;, in September 2026. Incident figures from Sansec's reports on &lt;a href="https://sansec.io/research/cosmicsting-fallout" rel="noopener noreferrer"&gt;CosmicSting&lt;/a&gt; and &lt;a href="https://sansec.io/research/sessionreaper-exploitation" rel="noopener noreferrer"&gt;SessionReaper&lt;/a&gt;; PCI DSS changes from the &lt;a href="https://blog.pcisecuritystandards.org/just-published-pci-dss-v4-0-1" rel="noopener noreferrer"&gt;PCI Security Standards Council&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://zelpex.com/blog/magento-2-4-6-end-of-support-cost-of-waiting" rel="noopener noreferrer"&gt;zelpex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>magento</category>
      <category>ecommerce</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>When a Manufacturer's CRM Should Be Built, Not Bought</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Wed, 23 Sep 2026 17:50:28 +0000</pubDate>
      <link>https://dev.to/zelpex/when-a-manufacturers-crm-should-be-built-not-bought-1imn</link>
      <guid>https://dev.to/zelpex/when-a-manufacturers-crm-should-be-built-not-bought-1imn</guid>
      <description>&lt;p&gt;Most manufacturers who ask whether they should build their own CRM are asking about the wrong piece of software. The part their sales team uses every day is close to a commodity, and buying it is usually right. The part their dealers, distributors and service technicians touch is where the business is genuinely different, and it is also where the licence bill grows fastest.&lt;/p&gt;

&lt;p&gt;So the useful question is narrower: which part of the CRM should you build, and which part would you be foolish to build?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why manufacturing sales breaks generic CRM
&lt;/h2&gt;

&lt;p&gt;A CRM is designed around a contact, a deal and a pipeline. Manufacturing sales rarely looks like that. The customer is a distributor with negotiated contract pricing. The order is a spare part identified by a code that was superseded two revisions ago. The delivery date depends on a production slot the ERP knows about and the CRM does not. And the person asking is often a technician on a plant floor, not a buyer in an office.&lt;/p&gt;

&lt;p&gt;None of that data belongs to the CRM. Price, availability and lead time live in the ERP, and they have to stay there. Every CRM project for a manufacturer therefore turns into the same work: showing people outside the ERP an honest answer from inside it, without exposing how complicated it is to produce. That is true whether you buy the CRM or build it, and it is the part of the budget people tend to underestimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What buying actually costs
&lt;/h2&gt;

&lt;p&gt;The vendors built for this are not cheap, and they price per person. Salesforce's industry product for manufacturers, Manufacturing Cloud, lists at &lt;strong&gt;$275 per user per month&lt;/strong&gt; for its Sales or Service "Core" editions, $425 for the Advanced edition, and $650 to $700 for the "Max" editions that bundle its AI agents, all on annual contracts. HubSpot's Sales Hub is the lighter alternative at &lt;strong&gt;$90 per seat per month&lt;/strong&gt; on Professional (billed annually) and $150 on Enterprise, plus a mandatory one-off onboarding fee of $1,500 or $3,500.&lt;/p&gt;

&lt;p&gt;Seats for your own staff are only half the bill. The other half is everyone outside the company who needs to log in.&lt;/p&gt;

&lt;p&gt;Salesforce prices its partner portal, Partner Relationship Management, at &lt;strong&gt;$25 per member per month&lt;/strong&gt;, with a more capable tier at $50 and login-based pricing available for users who rarely sign in. Run the list prices for a hypothetical mid-size manufacturer: 15 internal users on Manufacturing Cloud Sales Core and 300 dealer accounts on the partner portal.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Per year, list price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal users&lt;/td&gt;
&lt;td&gt;15 × $275 × 12&lt;/td&gt;
&lt;td&gt;$49,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dealer portal members&lt;/td&gt;
&lt;td&gt;300 × $25 × 12&lt;/td&gt;
&lt;td&gt;$90,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total licences&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$139,500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dealers cost almost twice as much as the sales team, before a single integration is built. List prices get discounted and login-based licences cut the portal line for dealers who sign in rarely, so treat the table as the shape of the bill rather than a quote. The shape is the point: in manufacturing, the external audience is usually larger than the internal one, and per-member pricing scales with it every year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quoting trap
&lt;/h2&gt;

&lt;p&gt;Quoting is usually where a packaged CRM starts to fit badly. Prices come from rules, not a table: the customer's contract, the volume, the configuration, which options are compatible with which, and what the ERP says a variant actually costs to make.&lt;/p&gt;

&lt;p&gt;The standard answer used to be Salesforce CPQ, and it is no longer sold to new customers. Existing customers keep it, with support and renewals but no new features, while new buyers are pointed at its successor, Revenue Cloud Advanced. Salesforce is careful to call this end of sale, not end of life. For a manufacturer choosing today, though, it means the configure-price-quote layer most comparison articles describe is not the one you would be buying, and a quoting project is a separate licence and a separate implementation on top of the CRM.&lt;/p&gt;

&lt;p&gt;This is the first place building starts to make sense. If your pricing logic is yours alone, encoding it in a quoting tool you own is often cheaper and clearer than bending a generic rules engine around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where building earns its place
&lt;/h2&gt;

&lt;p&gt;We would split the system in three and treat each part differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The internal CRM: buy it.&lt;/strong&gt; Accounts, pipeline, activity history, service cases, reporting. This is well-solved software. Building it means rebuilding permissions, audit trails, email sync and mobile access that a vendor already maintains, and your team would be maintaining them for ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ERP: leave it alone.&lt;/strong&gt; It is the system of record for price, stock and lead time. Neither the CRM nor anything you build should hold a second copy of those numbers that can drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dealer portal and quoting rules: build them when they are where you compete.&lt;/strong&gt; This is the software your customers actually use. It carries your contract pricing, your supersession chains for spare parts, your serial-number history for service, and your availability promises. A custom portal is priced once to build and then maintained, not charged per dealer per month, and it can read the ERP directly instead of through a CRM in the middle.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79lberq2b1fqno2gipuh.jpg" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79lberq2b1fqno2gipuh.jpg" alt="Diagram of an ERP kept as the system of record, a bought CRM for the sales team and a custom-built dealer portal reading from both." width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The split is not free. A custom portal needs an owner, a hosting bill, security updates and someone who understands it when the person who built it has moved on. If you have no one to own software, the per-member licence is the price of not needing them, and it can be worth paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Before deciding, answer four questions honestly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How many people outside the company need to log in?&lt;/strong&gt; If it is a handful, a vendor portal is fine. If it is hundreds of dealers or technicians, price the per-member line over five years before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is your pricing a table or a set of rules?&lt;/strong&gt; A price list fits any CRM. Contract pricing, configuration rules and ERP-derived costs are where packaged quoting gets expensive and awkward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the true answer on availability live?&lt;/strong&gt; If it is the ERP, whatever customers see has to read from it, and that integration is the real project either way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who will own the software in three years?&lt;/strong&gt; Building only works if the answer is a named person or a partner, not "IT".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers are "few", "a table", "the ERP" and "nobody", buy the whole thing and keep the configuration shallow. If they are "hundreds", "rules", "the ERP" and "we can", buy the CRM, build the portal, and let the ERP stay the source of truth.&lt;/p&gt;

&lt;p&gt;If you would rather work this through against your own dealer network and pricing rules, &lt;a href="https://zelpex.com/contact-us" rel="noopener noreferrer"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Prices verified against &lt;a href="https://www.salesforce.com/manufacturing/cloud/pricing/" rel="noopener noreferrer"&gt;Salesforce Manufacturing Cloud&lt;/a&gt;, &lt;a href="https://www.salesforce.com/sales/partner-relationship-management/pricing/" rel="noopener noreferrer"&gt;Salesforce Partner Relationship Management&lt;/a&gt; and &lt;a href="https://www.hubspot.com/pricing/sales" rel="noopener noreferrer"&gt;HubSpot Sales Hub&lt;/a&gt; pricing pages, and Salesforce's &lt;a href="https://www.salesforce.com/sales/cpq/end-of-life/" rel="noopener noreferrer"&gt;CPQ end-of-sale page&lt;/a&gt;, in September 2026. All figures are list prices in US dollars.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://zelpex.com/blog/manufacturer-crm-build-vs-buy" rel="noopener noreferrer"&gt;zelpex.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>crm</category>
      <category>salesforce</category>
      <category>manufacturing</category>
      <category>saas</category>
    </item>
    <item>
      <title>MedusaJS Dropped the Foreign Keys Between Its Modules: The defineLink Gamble</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Tue, 22 Sep 2026 18:57:18 +0000</pubDate>
      <link>https://dev.to/andriiboyko/medusajs-dropped-the-foreign-keys-between-its-modules-the-definelink-gamble-51mh</link>
      <guid>https://dev.to/andriiboyko/medusajs-dropped-the-foreign-keys-between-its-modules-the-definelink-gamble-51mh</guid>
      <description>&lt;p&gt;MedusaJS 2.0 did something that would get a junior engineer's pull request rejected on sight: it deleted the foreign keys between its own tables. On purpose. Not by accident, not as tech debt, but as the load-bearing decision of the whole architecture. The release notes say it in one flat sentence, and it's worth reading twice: "We've also eliminated all database-level dependencies, removing foreign keys between data models in different modules."&lt;/p&gt;

&lt;p&gt;If you grew up on Rails, Django, or a decade of Magento's EAV tables, that line should make you flinch. Foreign keys are how the database protects you from yourself. They stop orphaned rows, they cascade deletes, they turn "these two things are related" into a rule the storage engine enforces whether your application code remembers to or not. MedusaJS looked at all of that and decided the cost was too high. This is the defineLink gamble, and once you understand what it buys and what it takes away, you'll either love it or quietly plan your reads around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What module isolation actually buys
&lt;/h2&gt;

&lt;p&gt;Start with the why, because the no-foreign-keys thing sounds reckless until you see the constraint it's serving.&lt;/p&gt;

&lt;p&gt;MedusaJS 2.0 ships its commerce logic as separate modules, twenty-plus of them, from Product and Pricing to Cart, Order, Inventory, and Fulfillment. Each one is a self-contained package: its own data models, its own service, its own migrations. The rule that makes them modules rather than just folders is isolation. The docs are blunt about it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A module is unaware of any resources other than its own, such as services or data models.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the Product module can't reach into the Pricing module's service, and the Pricing module's tables can't hold a relationship to the Product module's tables. There's no &lt;code&gt;product_id&lt;/code&gt; foreign key sitting on a price row pointing back across the boundary. The release blog frames it as a rewrite from the ground up: "all business domains (services and data models) have been rewritten from scratch to eliminate interdependencies between them."&lt;/p&gt;

&lt;p&gt;The payoff is portability. Because a module knows nothing about its neighbors, you can swap MedusaJS's built-in Pricing module for your own, run a module against a different datastore, or lift one out and use it somewhere else. A foreign key is the enemy of that goal. The moment the Pricing table references the Product table at the database level, the two are welded together forever, and you can never move one without the other. MedusaJS's bet is that in a commerce platform people actually customize, replaceable parts are worth more than referential integrity across those parts.&lt;/p&gt;

&lt;p&gt;One myth to kill early: isolation does not mean every module gets its own database. By default they all share one Postgres. Isolation is enforced in the code and the schema, not by physically scattering your data. You can point a module at a separate datastore if you want, but you don't have to, and most people don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The link table, not the foreign key
&lt;/h2&gt;

&lt;p&gt;So if a price can't point at a product with a foreign key, how do you say "this price belongs to this product"? You define a link.&lt;/p&gt;

&lt;p&gt;A link lives in its own file under &lt;code&gt;src/links&lt;/code&gt;, and you declare it with &lt;code&gt;defineLink&lt;/code&gt;, wiring together the &lt;code&gt;linkable&lt;/code&gt; handles that each module exposes for its data models:&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;// src/links/product-post.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;BlogModule&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;../modules/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ProductModule&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;@medusajs/medusa/product&lt;/span&gt;&lt;span class="dl"&gt;"&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;defineLink&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="s2"&gt;@medusajs/framework/utils&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ProductModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;BlogModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole declaration for a link between a product and a blog post. Every module hangs a &lt;code&gt;linkable&lt;/code&gt; property off its service holding these configs for its models, so you never touch the other module's internals, you just reference the handle it published.&lt;/p&gt;

&lt;p&gt;Now here's the part that matters. When you sync this link, MedusaJS doesn't add a column to the product table or the post table. It creates a brand new table that sits between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx medusa db:sync-links
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command (or &lt;code&gt;db:migrate&lt;/code&gt;, which runs migrations and syncs links together) creates a link table named after both sides, something like &lt;code&gt;product_product_blog_post&lt;/code&gt;. It has two columns: the product's id and the post's id. And the crucial detail, straight from the docs, is what those columns are not: they "store only the IDs of the linked records and do not hold a foreign key constraint."&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%2Fmedusajs-definelink-no-foreign-keys%2Flink-table-not-foreign-key.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%2Fmedusajs-definelink-no-foreign-keys%2Flink-table-not-foreign-key.avif" alt="Two isolated MedusaJS module tables, Product and Blog, joined by a separate link table holding only the product and post IDs with no foreign key constraint" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at what just happened. The relationship exists, but it lives in a third table, and the database has no opinion about whether those IDs point at anything real. Delete a product and its row in the link table just dangles, pointing at a ghost. There's no &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; to clean it up, because there's no foreign key to hang the cascade on. That responsibility moved out of the database and into MedusaJS's application layer, where the framework manages links as records commit and delete. Whether you trust that trade is the entire question of the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading across the gap with Query
&lt;/h2&gt;

&lt;p&gt;Fine, the relationship is in a link table. How do you actually read a product and its linked blog post in one go, without hand-writing joins across three tables? You use Query.&lt;/p&gt;

&lt;p&gt;Query is MedusaJS's cross-module read tool. You resolve it from the container and call &lt;code&gt;graph&lt;/code&gt;, describing what you want in a shape that looks a lot like GraphQL:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ContainerRegistrationKeys&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="s2"&gt;@medusajs/framework/utils&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ContainerRegistrationKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUERY&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product.*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post_123&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;The magic is in &lt;code&gt;"product.*"&lt;/code&gt;. You ask for the post's own fields, then you reach across the link and pull the whole linked product too, all in one call. If the link is a one-to-many, you use the plural form, &lt;code&gt;"products.*"&lt;/code&gt;, and get an array back. Query knows about every module's models and every link between them, so it stitches the result together for you.&lt;/p&gt;

&lt;p&gt;But read that word carefully: stitches. Query does not run a SQL join across the module tables, because it can't, there are no foreign keys to join on. Instead it builds an internal graph of your modules and their links, fetches from each module separately, and aggregates the pieces into the final result. The docs describe exactly this: "Medusa aggregates the data coming from different modules to create the end result."&lt;/p&gt;

&lt;p&gt;For the common case, this is genuinely nice. Your module boundaries stay clean, you never write cross-module SQL by hand, and adding a new link is one &lt;code&gt;defineLink&lt;/code&gt; file plus a sync. The gamble is paying off. And then you hit the wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The join you can't write
&lt;/h2&gt;

&lt;p&gt;Here's the scenario that finds the edge. You want products in a specific sales channel, sorted by price, cheapest first. Product lives in the Product module. The sales-channel association is a link. Price lives in the Pricing module, another link. In a foreign-key world this is a two-join query with an &lt;code&gt;ORDER BY&lt;/code&gt;, the kind of thing you'd write half-asleep.&lt;/p&gt;

&lt;p&gt;In MedusaJS's aggregate-don't-join world, you can't. Because Query pulls from each module separately and merges afterward, it has no single result set to filter or sort by a linked module's field. The docs name the limitation directly: "This approach limits your ability to filter data by linked modules." Filtering by the post's own &lt;code&gt;id&lt;/code&gt; is fine. Filtering products by a linked sales channel's id, or sorting them by a linked price, is not something plain &lt;code&gt;query.graph&lt;/code&gt; can do, because at the moment it would need to filter, the linked data hasn't been joined in, it's still sitting in another module waiting to be aggregated.&lt;/p&gt;

&lt;p&gt;This is the "what breaks the first time you need a real join" moment, and it's not a bug. It's the direct, unavoidable cost of deleting the foreign keys. You removed the mechanism that makes cross-entity filtering cheap, so cross-entity filtering got expensive. Nobody who chooses this architecture gets to be surprised by it. You either design your reads so the filter and sort always live inside a single module, or you reach for the escape hatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escape hatch: the Index module
&lt;/h2&gt;

&lt;p&gt;The escape hatch is the Index module, and it's MedusaJS's honest answer to the problem its own isolation created.&lt;/p&gt;

&lt;p&gt;The idea is a central, read-optimized index. On startup and as data changes, MedusaJS ingests the modules' data into one relational store that does know how everything connects. Then you query that store, and because it's a single joined-up representation, you finally can filter and sort across module boundaries. The API is deliberately identical to Query, so switching is nearly free, you swap &lt;code&gt;graph&lt;/code&gt; for &lt;code&gt;index&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales_channels.*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sales_channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sc_123&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;filters.sales_channels.id&lt;/code&gt; is the thing plain &lt;code&gt;query.graph&lt;/code&gt; couldn't do: filtering products by a linked module's field. The docs put the division of labor plainly: "The Index Module adds a new &lt;code&gt;index&lt;/code&gt; method to Query and it has the same API as the &lt;code&gt;graph&lt;/code&gt; method," and it exists "to filter linked modules."&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%2Fmedusajs-definelink-no-foreign-keys%2Faggregate-vs-index.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%2Fmedusajs-definelink-no-foreign-keys%2Faggregate-vs-index.avif" alt="Left, query.graph aggregating separate module results and unable to filter by a linked field; right, query.index reading one central store that filters and sorts across modules" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the catch, because there's always a catch. The Index module is experimental. The docs say so in as many words: "The Index Module is experimental and still in development, so it is subject to change." It's gated behind a feature flag and, as the team wrote in September 2025, it "is not used by default in the core APIs and workflows." It has been maturing steadily, more entities became ingestible in v2.10.2, caching arrived in v2.11.0, admin routes to inspect and resync the index landed in v2.11.2 at the end of October 2025, but it is not yet the default read path, and you shouldn't present it to yourself as one.&lt;/p&gt;

&lt;p&gt;So the real state of the gamble is this: MedusaJS gave you a clean, isolated, foreign-key-free architecture today, and the tool that makes cross-module querying feel normal again is still growing up. If your product needs heavy cross-module filtering right now, you're either enabling an experimental module or shaping your data model so you don't need it. That's the tax, stated honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing links at runtime
&lt;/h2&gt;

&lt;p&gt;One more piece, because defining a link type is not the same as saying "this specific product is linked to this specific post." For that you use the Link service at runtime:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ContainerRegistrationKeys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Modules&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="s2"&gt;@medusajs/framework/utils&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ContainerRegistrationKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PRODUCT&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;blog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post_123&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;&lt;code&gt;link.create&lt;/code&gt; writes the row into that link table, and &lt;code&gt;link.dismiss&lt;/code&gt; removes it, same shape. This is the application-layer bookkeeping that stands in for what a foreign key and a cascade used to do automatically. You create the association, you're responsible for tearing it down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
If you're reading an older tutorial that resolves &lt;code&gt;remoteLink&lt;/code&gt; and calls it the current API, stop. As of MedusaJS v2.2.0, Remote Link was deprecated in favor of Link. Same usage, you just resolve &lt;code&gt;LINK&lt;/code&gt; from the container instead. The one place the old name lingers is workflow steps, which still ship as &lt;code&gt;createRemoteLinkStep&lt;/code&gt;, &lt;code&gt;dismissRemoteLinkStep&lt;/code&gt;, and friends, so don't be surprised to see both spellings in the same codebase.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That split, a renamed service but legacy step names, is a small tell about how fast this part of MedusaJS is still moving. Which brings us back to whether the whole bet was worth making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the gamble worth it?
&lt;/h2&gt;

&lt;p&gt;Here's my read, coming from years of both Magento's foreign-key-everything schema and the microservices tax on the other extreme.&lt;/p&gt;

&lt;p&gt;What MedusaJS built is a modular monolith with unusually honest boundaries. In a typical monolith, "modules" are a naming convention that a single &lt;code&gt;JOIN&lt;/code&gt; quietly violates the first time someone's in a hurry. MedusaJS made that violation impossible at the database level. You physically cannot couple two modules through the schema, which means the boundaries you drew on the whiteboard are the boundaries you actually have in production a year later. Anyone who's watched a "modular" codebase rot into a big ball of cross-table joins knows how rare and valuable that is. You get the clean domain separation you chased with microservices, without paying for a network hop and a separate deploy per module.&lt;/p&gt;

&lt;p&gt;The price is real and you should say it out loud: you gave up the database's referential integrity and its cheap cross-entity queries. Orphaned link rows are now your problem, not Postgres's. Cross-module filtering and sorting, the bread and butter of any admin grid or storefront facet, is either an experimental module or a data-modeling constraint you design around. That's not a small tax on a commerce platform, where "show me products in this channel sorted by margin" is a Tuesday.&lt;/p&gt;

&lt;p&gt;So the honest verdict: the defineLink gamble is the right call when you're going to customize and replace modules, when clean boundaries matter more to you than referential integrity, and when your heavy filtering happens to live inside single modules. It's the wrong call if your product is one giant cross-module reporting surface and you need those joins today, because the tool that makes them painless is still wearing an experimental flag. MedusaJS bet that portability beats foreign keys for the kind of teams that outgrow Shopify and would otherwise fork Magento. For a lot of those teams, it's the right bet. Just go in knowing which half of the trade you're standing on.&lt;br&gt;
&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/medusajs-definelink-no-foreign-keys" 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>node</category>
      <category>ecommerce</category>
      <category>database</category>
      <category>typescript</category>
    </item>
    <item>
      <title>AWS Step Functions vs Camunda for Sagas: Two Orchestrators, Different Blast Radii</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Sun, 20 Sep 2026 10:38:46 +0000</pubDate>
      <link>https://dev.to/andriiboyko/aws-step-functions-vs-camunda-for-sagas-two-orchestrators-different-blast-radii-4l7l</link>
      <guid>https://dev.to/andriiboyko/aws-step-functions-vs-camunda-for-sagas-two-orchestrators-different-blast-radii-4l7l</guid>
      <description>&lt;p&gt;Here's a trap I've watched smart teams walk into. They pick a saga orchestrator by comparing the two engines feature-for-feature, decide they both "do sagas," flip a coin weighted by whatever's already in their cloud bill, and move on. Then a year later one team is tuning Zeebe partitions at 2am and the other is staring at a Step Functions bill that grew faster than their traffic, both wondering how the "same pattern" turned into such different jobs.&lt;/p&gt;

&lt;p&gt;They do run the same pattern. A saga is a sequence of local transactions where, if step four fails, you run compensating actions to undo steps one through three, because you don't have a distributed transaction to roll back for you. Both AWS Step Functions and Camunda 8 are orchestration-style saga coordinators: a central brain that knows the steps, drives them in order, and triggers the rollback when something breaks. That part is genuinely the same.&lt;/p&gt;

&lt;p&gt;What's not the same is what you're on the hook for. We run Camunda 8 in production for credentialing and scheduling workflows, so I've lived one of these; the other I've read the docs on and priced out for real proposals. The honest comparison isn't "which has more features." It's "which failure is yours to own." Let me lay both out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The saga both of them run
&lt;/h2&gt;

&lt;p&gt;Orchestration sagas need a coordinator because someone has to remember where the process is. Choreography (services reacting to each other's events with no central brain) works until you need to answer "why is order 8842 stuck," and then nobody can, because the state lives smeared across six services' logs. An orchestrator centralizes that: one place holds the process state, drives the next step, and owns the compensation logic.&lt;/p&gt;

&lt;p&gt;So the shape is identical on both. Take a classic order saga: reserve inventory, charge the card, ship. If the charge fails, release the inventory. If shipping fails, refund the charge and release the inventory. Three forward steps, a compensation for each, run in reverse. The pattern is old and boring, which is the point. The interesting part is what each engine makes you do to express it, and what each one does when the wheels come off.&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%2Faws-step-functions-vs-camunda-for-sagas%2Fsame-saga-two-orchestrators.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%2Faws-step-functions-vs-camunda-for-sagas%2Fsame-saga-two-orchestrators.avif" alt="The same order saga (reserve inventory, charge payment, ship) modeled two ways: an AWS Step Functions JSON state machine wiring each Catch to a compensation task, beside a Camunda BPMN flow with compensation boundary events" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step Functions: the orchestrator AWS operates for you
&lt;/h2&gt;

&lt;p&gt;Step Functions is a managed state machine. You describe your saga in Amazon States Language, which is JSON, and AWS runs the thing. There are no brokers to operate, no cluster to size, no exporter to babysit. For a lot of teams that single sentence is the whole pitch, and it's a good one.&lt;/p&gt;

&lt;p&gt;You get retries and error routing as declarative config. Here's the reserve-then-charge slice, with a retry on the flaky step and a &lt;code&gt;Catch&lt;/code&gt; that routes a failed charge to the compensating action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"StartAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ReserveInventory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"States"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ReserveInventory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:states:::lambda:invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"FunctionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserveInventory"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Retry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ErrorEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"States.TaskFailed"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"MaxAttempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"IntervalSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"BackoffRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ChargePayment"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ChargePayment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:states:::lambda:invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"FunctionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chargePayment"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Catch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ErrorEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"States.ALL"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ReleaseInventory"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ship"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ReleaseInventory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:states:::lambda:invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"FunctionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"releaseInventory"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SagaFailed"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SagaFailed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SagaFailed"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what compensation actually is here: it's you, by hand, pointing each step's &lt;code&gt;Catch&lt;/code&gt; at the right cleanup state and remembering to undo everything that already succeeded. There's no "compensate the whole saga" primitive. If &lt;code&gt;Ship&lt;/code&gt; fails, its &lt;code&gt;Catch&lt;/code&gt; has to route to a state that refunds the payment AND then to a state that releases the inventory, in the right order. Miss one and you've got a half-rolled-back order and no error to tell you. The state machine is honest and explicit, which is a nice way of saying verbose and entirely your responsibility.&lt;/p&gt;

&lt;p&gt;For long-running sagas you use Standard Workflows, and this is where the surprise lives. Standard Workflows bill per &lt;strong&gt;state transition&lt;/strong&gt;. Every step your machine takes is a transition: a task, a wait, a choice. The first 4,000 a month are free, then it's $0.000025 each. Cheap, until you read the next sentence: &lt;strong&gt;every retry attempt is its own billed state transition.&lt;/strong&gt; So the exact moment your dependencies are flaky and your saga is retrying and compensating, which is the moment you most need it to work, is also the moment it's transitioning the most and billing the most.&lt;/p&gt;

&lt;p&gt;Do the math on a bad month. Say the order saga is six states on the happy path. Add a payment step that burns its three retries and then compensates two prior steps: call it eleven transitions for that execution instead of six. Now imagine a million sagas in a month running hot like that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000,000 sagas x ~11 transitions = ~11,000,000 state transitions
minus 4,000 free
x $0.000025
= ~$275 / month, in Step Functions transitions alone
(Lambda invocations, and everything the tasks actually do, billed separately)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That number is small until it isn't. The point isn't the dollar figure, it's the shape of the curve: your orchestration cost scales with steps and with failures, not with business value. A retry storm is a billing event. For high-volume, short-lived sagas AWS pushes you to Express Workflows, which bill on requests plus duration plus memory instead, but Express caps how long a saga can run and changes the durability guarantees, so it's a different tool, not a cheaper Standard.&lt;/p&gt;

&lt;p&gt;Human steps and long waits work through the callback pattern: a task pauses with a token and resumes when something calls back with that token. It's a clean primitive. It is also, again, plumbing you assemble. There's no built-in inbox, no task list, no assignment or escalation. If your saga has a "a human approves this" step, Step Functions gives you the pause and the resume and leaves the actual human workflow to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Camunda 8: the orchestrator you operate
&lt;/h2&gt;

&lt;p&gt;Camunda flips almost every one of those tradeoffs. You model the saga in BPMN, and compensation is a first-class construct: you attach a compensation boundary event to each activity, define its handler (the undo), and when something throws, one compensation throw event fires all the relevant handlers, in reverse, automatically. You don't hand-route rollbacks. You declare "charging the card is compensated by refunding it" once, on the task, and the engine does the reverse walk. Camunda's own framing is that Zeebe acts as the saga coordinator that solves the transaction without two-phase commit, and the compensation modeling is the part that makes that pleasant instead of a pile of &lt;code&gt;Catch&lt;/code&gt; states.&lt;/p&gt;

&lt;p&gt;The workers are your code. A Zeebe worker subscribes to a task type and does the real work; throwing a BPMN error is what triggers the compensation path:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ZBClient&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="s2"&gt;zeebe-node&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="nx"&gt;zbc&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;ZBClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;zbc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge-payment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;taskHandler&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="nx"&gt;job&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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 BPMN error, not a crash: the modeled compensation boundary&lt;/span&gt;
      &lt;span class="c1"&gt;// events upstream fire and unwind the saga for us.&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PAYMENT_FAILED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;card declined&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="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;Two things Step Functions makes hard, Camunda makes boring. First, human tasks are native: user tasks are part of the model, and since 8.5 you drive them through the Zeebe REST API, with a task list, assignment, and forms as real product surface rather than something you glue on. For our credentialing flows, where a human genuinely has to review and approve, that's not a nice-to-have, it's the reason the engine earns its keep.&lt;/p&gt;

&lt;p&gt;Second, and this is the one people underestimate until it bites: in-flight process versioning. When you fix a bug in a saga that has thousands of instances mid-flight, deploying version two doesn't disturb the running ones; they finish on the version they started on. When you actually need to move them, you migrate in-flight instances, through Operate or the Zeebe API, and you can canary a new version against real traffic. A saga that spans days or weeks will outlive several deploys of its own definition. Having a real answer to "we changed the process while ten thousand of them were running" is the difference between a workflow engine and a fancy job queue.&lt;/p&gt;

&lt;p&gt;The bill for all this is that you operate it. Even on Camunda's SaaS you're closer to the machine; self-managed, you're running Zeebe brokers, choosing a partition count, and feeding an exporter into Elasticsearch so Operate has something to show. Zeebe is a distributed, log-based engine, every state change is an append to a replicated log, which is exactly why it recovers cleanly and exactly why it's a real distributed system with your name on the pager. Nobody at AWS gets paged when your exporter falls behind.&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%2Faws-step-functions-vs-camunda-for-sagas%2Fwhat-youre-on-the-hook-for.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%2Faws-step-functions-vs-camunda-for-sagas%2Fwhat-youre-on-the-hook-for.avif" alt="Operational-ownership comparison: Step Functions is mostly operated by AWS with cost and lock-in as your blast radius, while self-managed Camunda 8 is mostly operated by you (Zeebe brokers, partitions, Elasticsearch exporter)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the blast radii actually differ
&lt;/h2&gt;

&lt;p&gt;Both engines can lose you a saga. What differs is which failure domain is yours.&lt;/p&gt;

&lt;p&gt;With Step Functions, the engine's blast radius is AWS's problem. If a broker equivalent falls over, that's their incident, their multi-AZ replication, their 3am. Your blast radius moved somewhere else: into cost, which grows with every retry, and into lock-in, because Amazon States Language and the deep &lt;code&gt;.sync&lt;/code&gt; and callback integrations don't travel. The day you want to run the same saga off AWS, you're not porting a config, you're rewriting the orchestrator. That's a real blast radius, it's just a slow one that shows up in a migration quarter instead of a pager.&lt;/p&gt;

&lt;p&gt;With Camunda, the engine's blast radius is yours. A misconfigured partition count, an exporter that can't keep up, a botched broker upgrade, those are your incident. In exchange, you own the model. The saga is BPMN you can read, version, and migrate; the compensation is declared, not wired; the engine runs wherever you put it, on any cloud or none. Your blast radius is operational and immediate, but it's also fully in your hands, and it doesn't have a per-transition meter running.&lt;/p&gt;

&lt;p&gt;Neither of these is the "safe" choice. They're different bets on which kind of failure you'd rather own: someone else's engine with your cost and lock-in exposure, or your own engine with your operational exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions that actually decide it
&lt;/h2&gt;

&lt;p&gt;Skip the feature matrix. Four questions settle it in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How gnarly is your compensation?&lt;/strong&gt; If your rollback is "undo the last thing, maybe two," Step Functions' hand-wired &lt;code&gt;Catch&lt;/code&gt; states are fine and the managed simplicity wins. If your saga has real compensation depth, conditional undo, partial rollbacks, compensation that itself can fail, Camunda's first-class compensation modeling stops being a nicety and starts being the thing that keeps the logic legible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are there humans in the saga?&lt;/strong&gt; A step where a person reviews, approves, or fills in a form tilts hard toward Camunda. Step Functions can pause for a callback, but you're building the entire human side. Camunda ships it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long do instances live, and will the definition change under them?&lt;/strong&gt; A saga that completes in seconds rarely outlives its own code. One that runs for days or weeks will, and in-flight versioning goes from "feature I skimmed" to "the reason we didn't have an outage on deploy." That's Camunda's home turf.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who operates it, and what happens to the bill at scale?&lt;/strong&gt; If you have no appetite to run a distributed engine and you're already all-in on AWS, Step Functions' managed model is a genuine gift, right up until the transition bill on a high-retry, high-volume workload makes someone ask why orchestration costs more than the work it orchestrates. If you have the operational muscle and you care about portability or predictable cost, running Camunda is a real option rather than a burden.&lt;/p&gt;

&lt;p&gt;Here's my actual position, from the side I've lived: for the workflows we run, credentialing and scheduling, with human approvals, multi-day instances, and compensation that has to be right, Camunda was never a close call, and the operational tax has been worth every cent of not owning the model. But I've also priced Step Functions for a stateless, short-lived, no-humans, all-on-AWS saga and recommended it without flinching, because there the managed simplicity is the whole point and the transition bill stays small. The mistake isn't picking either one. The mistake is picking it because it "does sagas," and finding out a year later which blast radius you actually signed up for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/aws-step-functions-vs-camunda-for-sagas" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;br&gt;
&lt;em&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;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>architecture</category>
      <category>microservices</category>
      <category>saga</category>
    </item>
    <item>
      <title>Nomad vs Kubernetes: When You Don't Need the Cluster</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Thu, 03 Sep 2026 11:47:34 +0000</pubDate>
      <link>https://dev.to/andriiboyko/nomad-vs-kubernetes-when-you-dont-need-the-cluster-410n</link>
      <guid>https://dev.to/andriiboyko/nomad-vs-kubernetes-when-you-dont-need-the-cluster-410n</guid>
      <description>&lt;p&gt;Somewhere along the way, "we need to run some containers" quietly became "so we need Kubernetes," and the second half stopped getting questioned. You stand up a cluster, or pay for a managed one, and now you own a distributed control plane: etcd, an API server, a scheduler, a controller manager, plus a kubelet and kube-proxy on every node, and that's before the CNI plugin, the ingress controller, and the operators. For a fleet at Spotify scale, that machine earns its keep. For a team running twenty services, it's a lot of orchestrator to babysit for a job that was never that hard.&lt;/p&gt;

&lt;p&gt;There's a quieter answer that's been in production at Cloudflare, Roblox, Trivago and CircleCI for years: HashiCorp Nomad. It's a single binary. The same executable is your server and your client; it stores its own state with built-in Raft and needs no etcd, no separate datastore, nothing external to coordinate. And to head off the obvious "toy" assumption: Nomad scheduled two million containers across 6,000 hosts in ten AWS regions in 22 minutes in HashiCorp's C2M benchmark, roughly 1,500 containers a second. Small to run is not the same as small in what it can do.&lt;/p&gt;

&lt;p&gt;So the honest question isn't "Nomad or Kubernetes." It's "do you actually need the cluster, or did you reach for it out of reflex?" And in 2026 the answer turns on two axes most comparisons skip: what your workloads actually are, and who you're willing to bet your platform on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually signing up for with Kubernetes
&lt;/h2&gt;

&lt;p&gt;Kubernetes is not one thing you install. Its control plane is &lt;strong&gt;etcd&lt;/strong&gt; (the distributed key-value store that holds all cluster state), the &lt;strong&gt;kube-apiserver&lt;/strong&gt;, the &lt;strong&gt;kube-scheduler&lt;/strong&gt;, and the &lt;strong&gt;kube-controller-manager&lt;/strong&gt;; every worker node then runs a &lt;strong&gt;kubelet&lt;/strong&gt; and &lt;strong&gt;kube-proxy&lt;/strong&gt;. On top of that base you almost always add a CNI network plugin, an ingress controller, a certificate manager, a metrics stack, and a pile of operators, because the bare cluster doesn't do much until you assemble the ecosystem around it.&lt;/p&gt;

&lt;p&gt;None of that is a criticism, exactly. It's the price of the most powerful, most extensible orchestrator ever built, and if you need that power the price is fair. But be honest about what it is: a distributed system with its own failure modes, its own upgrade dance every few months, and enough surface area that "Kubernetes engineer" is a full job title. Managed control planes (EKS, GKE, AKS) rent you the hard parts, but you still own the nodes, the add-ons, the YAML, and the upgrades. The complexity didn't leave; someone else runs a third of it.&lt;/p&gt;

&lt;p&gt;The tell that you might be over-buying is simple: if you can't name a concrete thing your workloads need that requires this machine, you're paying for a distributed control plane to run what a much smaller tool would schedule fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Nomad is, and the part people miss
&lt;/h2&gt;

&lt;p&gt;Nomad's architecture is almost aggressively boring, and that's the pitch. One binary runs in one of two modes, server or client. Servers form a small Raft cluster (usually three or five) and hold state themselves; clients run the actual work. There is no etcd, no separate API tier, no per-node proxy to operate. You run the same binary everywhere and configure what it is. The operational surface is a rounding error next to a Kubernetes control plane.&lt;/p&gt;

&lt;p&gt;But the differentiator that actually matters isn't "simpler Kubernetes." It's that &lt;strong&gt;Nomad is a workload orchestrator, not a container orchestrator.&lt;/strong&gt; Kubernetes schedules pods, and a pod is containers. Nomad schedules through pluggable &lt;strong&gt;task drivers&lt;/strong&gt;: &lt;code&gt;docker&lt;/code&gt; for containers, but also &lt;code&gt;exec&lt;/code&gt; for a raw Linux binary, &lt;code&gt;java&lt;/code&gt; for a JAR, &lt;code&gt;qemu&lt;/code&gt; for a full virtual machine. So the same scheduler that runs your new Go services also runs the legacy Java app nobody wants to containerize, a Windows service, and a batch process that's just an executable, side by side, on the same fleet. That's not a thing Kubernetes does gracefully, and for a shop with a mixed or legacy estate it's the whole ballgame.&lt;/p&gt;

&lt;p&gt;Here's the same "run three copies of a web service" job, and the difference in machinery is the point. Nomad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"web"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;driver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt;
      &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"myapp:1.4.0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&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;Kubernetes wants a Deployment and, to make it reachable, a Service:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&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;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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="s"&gt;server&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp:1.4.0&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;500m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&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;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;80&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is hard. But multiply that difference across every service, every environment, and every engineer who has to hold it in their head, and "a bit less machine per thing" compounds into a materially smaller platform to run.&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%2Fnomad-vs-kubernetes-when-you-dont-need-the-cluster%2Fwhat-runs-the-control-plane.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%2Fnomad-vs-kubernetes-when-you-dont-need-the-cluster%2Fwhat-runs-the-control-plane.avif" alt="What runs the control plane: on the left, Nomad is one binary that splits into a server (Raft) and a client, with no etcd and no external datastore; on the right, Kubernetes has a control plane of etcd, kube-apiserver, kube-scheduler and kube-controller-manager, a node running kubelet and kube-proxy, plus add-ons that vary by setup (CNI, ingress, operators)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The assemble-it-yourself tradeoff
&lt;/h2&gt;

&lt;p&gt;Nomad's small size comes from doing one job: scheduling and resource management. It deliberately does not bundle service discovery, secrets, or a service mesh. Instead it leans on the rest of the HashiCorp stack: &lt;strong&gt;Consul&lt;/strong&gt; for service discovery and mesh (a Nomad task registers itself in Consul automatically when it starts), and &lt;strong&gt;Vault&lt;/strong&gt; for secrets. That's a genuine tradeoff with two honest sides.&lt;/p&gt;

&lt;p&gt;The upside: each piece is separable and you run only what you need. A small service that just needs to be scheduled doesn't drag a mesh and a policy engine along for the ride. The downside: "the platform" is now a few HashiCorp tools you wire together yourself, and if you want the equivalent of what a mature Kubernetes install gives you out of the ecosystem, you're the one assembling and operating Consul and Vault next to Nomad. For a small platform team that's often a feature, fewer moving parts, each understandable. For a team that wanted batteries included, it can feel like more decisions than they signed up for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ecosystem, and the elephant in the room
&lt;/h2&gt;

&lt;p&gt;Here is where the comparison stops being about architecture, because this is the part that actually decides it in 2026.&lt;/p&gt;

&lt;p&gt;Kubernetes is a &lt;strong&gt;CNCF&lt;/strong&gt; project. It's vendor-neutral, every cloud sells a managed control plane, and its ecosystem is the largest in infrastructure: an operator or a Helm chart for nearly everything, a service mesh menu, and, not trivially, a hiring pool where "we run Kubernetes" is a sentence every candidate understands. When you pick Kubernetes you are buying that gravity as much as the scheduler.&lt;/p&gt;

&lt;p&gt;Nomad's ecosystem is a fraction of that, and, more importantly, its ownership story changed. In August 2023 HashiCorp relicensed &lt;strong&gt;all&lt;/strong&gt; its products, Nomad included, from the open-source MPL to the &lt;strong&gt;Business Source License (BSL)&lt;/strong&gt; 1.1, which is source-available, not OSI open source. Then, in a deal that &lt;strong&gt;closed on February 27, 2025&lt;/strong&gt;, IBM acquired HashiCorp for $6.4 billion. So betting your platform on Nomad today is betting on a source-available license and on IBM's roadmap for it, whereas Kubernetes remains under neutral CNCF governance that no single vendor controls.&lt;/p&gt;

&lt;p&gt;That is not a reason to avoid Nomad. It's a reason to make the choice with open eyes: the lighter orchestrator's real cost isn't technical, it's strategic. You're trading a giant vendor-neutral ecosystem for a smaller, simpler tool whose future now runs through IBM. For some teams that's fine, plenty of critical infrastructure is vendor-owned. But it belongs in the decision, and most "Nomad is simpler" takes leave it out.&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%2Fnomad-vs-kubernetes-when-you-dont-need-the-cluster%2Fthe-2026-decision-axis.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%2Fnomad-vs-kubernetes-when-you-dont-need-the-cluster%2Fthe-2026-decision-axis.avif" alt="The 2026 decision axis: Nomad offers one binary with a tiny ops surface, schedules containers plus VMs plus raw binaries, expects you to compose Consul and Vault yourself, is BSL source-available and IBM-owned since 2025, and has a smaller ecosystem and hiring pool; Kubernetes is a distributed control plane to operate, runs containers (pods) only, is batteries-heavy with a huge ecosystem, has CNCF vendor-neutral governance, and is managed everywhere with a deep talent pool. The real trade isn't simplicity versus power, it's a small IBM-owned tool versus a neutral ecosystem" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So, do you need the cluster?
&lt;/h2&gt;

&lt;p&gt;Strip it down to who should pick what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for Nomad when&lt;/strong&gt; your platform team is small and you'd rather operate one binary than a control plane; when your workloads are &lt;strong&gt;mixed&lt;/strong&gt;, containers next to VMs, JARs, and raw executables that would be miserable to force into pods; when you're running on-prem or at the edge where a full managed cluster is overkill; and when operational simplicity is worth more to you than ecosystem breadth. If you can't point at a specific Kubernetes feature your system depends on, this is very likely you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Kubernetes when&lt;/strong&gt; you're actually buying the ecosystem, the operators, the Helm charts, the service-mesh options, the managed control planes on every cloud; when you're hiring at a scale where a universal skill set matters; when your workloads are cloud-native containers and you want the network effects of the industry standard; or when vendor-neutral governance is itself a requirement you can't compromise. "Everyone else runs it" is a weak reason on its own, but "we depend on ten things from its ecosystem" is a strong one.&lt;/p&gt;

&lt;p&gt;The mistake in both directions is the same: choosing the orchestrator for a reason that isn't about your workload. Picking Kubernetes because it's the default, when you run twenty services and no ecosystem dependencies, buys you a distributed control plane you'll spend real time feeding. Picking Nomad to be contrarian, when you actually lean on half the CNCF landscape, trades a thriving ecosystem for a simplicity you didn't need and a license question you now own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest version
&lt;/h2&gt;

&lt;p&gt;"You don't need the cluster" is true far more often than the industry admits. A large share of Kubernetes adoptions are buying the reflex and the resume line, not solving a control-plane problem, and for those teams a single Nomad binary would run the same workloads with a fraction of the operational weight, and orchestrate the legacy stuff Kubernetes never wanted to touch besides.&lt;/p&gt;

&lt;p&gt;But the reason to think hard in 2026 isn't only the ops surface. It's that the decision has quietly become a bet on governance: a neutral, vendor-owned-by-nobody Kubernetes with the biggest ecosystem in the field, against a lighter, cleaner Nomad that is now source-available and owned by IBM. Choose the scheduler your workloads and your team actually fit, weigh the ecosystem and the ownership honestly, and ignore the conference-talk gravity entirely. The right answer is a lot more often "not the cluster" than most teams are willing to say out loud.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/nomad-vs-kubernetes-when-you-dont-need-the-cluster" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;br&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;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>architecture</category>
      <category>hashicorp</category>
    </item>
    <item>
      <title>REST, GraphQL, or gRPC: Picking the Right One Per Boundary, Not Per Company</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:09:00 +0000</pubDate>
      <link>https://dev.to/andriiboyko/rest-graphql-or-grpc-picking-the-right-one-per-boundary-not-per-company-4idd</link>
      <guid>https://dev.to/andriiboyko/rest-graphql-or-grpc-picking-the-right-one-per-boundary-not-per-company-4idd</guid>
      <description>&lt;p&gt;Somewhere in most engineering orgs there's a sentence that quietly did a lot of damage: "we're a GraphQL shop." Or a gRPC shop, or a REST shop. It sounds like discipline, like a team that made a decision and stuck to it. It's actually a category error, because the protocol was never a property of your &lt;em&gt;company&lt;/em&gt;. It's a property of each &lt;em&gt;boundary&lt;/em&gt;, and a single healthy system will rightly speak all three, one at each edge, for reasons that have nothing to do with taste.&lt;/p&gt;

&lt;p&gt;REST, GraphQL, and gRPC are not three answers to one question. They're good answers to three different questions, and the questions are set by who's standing on the other side of the wire. Pick per boundary and each edge gets the thing that fits. Pick per company and you spend the next three years paying your chosen protocol's weakness at every edge where it was the wrong call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol is a property of the boundary
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that makes the whole decision easy. Before you argue about protocols, look at the boundary and answer four questions about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who's on the other side?&lt;/strong&gt; The public internet? A browser or mobile app you also build? Another team's service inside your own network? A third party you'll never meet? Each of these wants something different, and "other team's internal service" and "some developer integrating your public API" are about as different as two consumers get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you own both ends?&lt;/strong&gt; This is the big one. If you control the client &lt;em&gt;and&lt;/em&gt; the server, you can use a strict binary contract and change both sides together. If you don't, you need something forgiving, debuggable, and standard, because the other end is out of your reach and will break in ways you can't fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What shape is the traffic?&lt;/strong&gt; One request, one response? A client that needs many different slices of related data? A long-lived stream of updates? The shape of the conversation rules some protocols in and others out before you've written a line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you need from the transport itself?&lt;/strong&gt; Cacheability at the HTTP layer? The ability to &lt;code&gt;curl&lt;/code&gt; it at 3am and read the response with your eyes? Raw throughput? A schema the compiler enforces? These aren't nice-to-haves; each one points at a different protocol.&lt;/p&gt;

&lt;p&gt;Answer those four about a specific edge and the choice mostly makes itself. Answer them once for the whole company and you've guaranteed a bad fit somewhere.&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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fpick-by-whos-on-the-other-side.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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fpick-by-whos-on-the-other-side.avif" alt="A decision matrix keyed on who's on the other side, do you own both ends, traffic shape, and what you need from the transport, mapping each answer to REST (public/third party, forgiving, request-response, HTTP caching and curl-debuggable), GraphQL (your own varied web/mobile clients, exact fields in one round trip), or gRPC (internal service you own on both ends, high-volume plus streaming, raw speed and a strict contract)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three, honestly
&lt;/h2&gt;

&lt;p&gt;Skip the feature matrices. Here's what each one is actually best at, and the bill that comes with it, because the bill is where teams get surprised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REST is the language of the open web, and its superpower is that everyone already speaks it.&lt;/strong&gt; It rides plain HTTP, so you get caching, proxies, CDNs, browser support, and &lt;code&gt;curl&lt;/code&gt; for free, and any developer on earth can integrate it without a toolchain. That's exactly why it's the right default for public and third-party APIs: the other side is a stranger, and REST asks nothing of them. The bill is that REST has no opinion about your data shape, so clients over-fetch (you return the whole user when they wanted a name) or under-fetch (they call three endpoints to assemble one screen), and without something like OpenAPI bolted on, there's no contract the compiler checks. For a public API those costs are worth paying. Between two of your own services, they're just friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GraphQL's superpower is letting many different clients ask for exactly the data they need from one schema.&lt;/strong&gt; When you own a web app, an iOS app, and an Android app, and all three want overlapping-but-different slices of the same graph, GraphQL kills the over/under-fetching problem dead: each client sends one query for precisely its fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="c"&gt;# One round trip, exactly the fields this screen needs. No more, no less.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;user&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u_42"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a genuinely great fit for the client-to-backend edge, the classic backend-for-frontend. The bill is real, though: HTTP caching mostly goes out the window (one endpoint, POST bodies), you invite the N+1 problem and have to solve it with dataloaders, and you take on a schema-and-resolver layer plus query-cost and abuse concerns. Between two internal services that always want the same fixed payload, that whole apparatus is machinery you're maintaining for flexibility nobody's using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gRPC's superpower is speed and a contract the compiler enforces, over a connection you control on both ends.&lt;/strong&gt; It serializes with Protocol Buffers (compact binary, not text) over HTTP/2, and in common benchmarks it runs several times faster than JSON-over-HTTP while giving you real streaming, server, client, and bidirectional, plus a &lt;code&gt;.proto&lt;/code&gt; file that generates typed clients and servers in every language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The contract IS the source of truth. Both sides generate from it.&lt;/span&gt;
&lt;span class="kd"&gt;service&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;rpc&lt;/span&gt; &lt;span class="n"&gt;GetOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetOrderRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;rpc&lt;/span&gt; &lt;span class="n"&gt;WatchOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WatchRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// server streaming&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the right call for internal service-to-service traffic where you own both ends, care about latency and throughput, and want a strict schema. The bill: it isn't native to browsers (they can't speak raw gRPC, so you need gRPC-Web or Connect, and true bidirectional streaming still doesn't work from a browser as of 2026), the binary payloads are harder to eyeball, and it adds proxy and infra friction. Point it at the public internet and you've handed every integrator a toolchain problem to solve your performance problem.&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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fbest-at-costs.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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fbest-at-costs.avif" alt="What each protocol is best at and the bill it comes with: REST is best at public APIs, HTTP caching, universality and curl-debuggability, and the bill is over/under-fetching and no built-in contract; GraphQL is best at many varied clients getting exact fields in one round trip, and the bill is that caching is hard, N+1, and schema-plus-resolver overhead; gRPC is best at internal service-to-service, fast binary, streaming and a strict .proto contract, and the bill is that it is not browser-native (needs gRPC-Web or Connect), binary is opaque, and it adds infra friction" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The map, drawn once
&lt;/h2&gt;

&lt;p&gt;Put the four questions and the three bills together and the boundary map is almost boring in how clearly it falls out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public or third-party API&lt;/strong&gt; (strangers, you don't own their end): &lt;strong&gt;REST&lt;/strong&gt;. Reach for GraphQL here only if your public consumers genuinely need to shape their own queries over a rich graph, which is rarer than it sounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own browser and mobile clients talking to your edge&lt;/strong&gt; (you own both, many different slices): &lt;strong&gt;GraphQL&lt;/strong&gt;, as a backend-for-frontend. Plain REST is a perfectly good answer too when the clients are simple; don't add a graph you don't need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal service-to-service, you own both ends, latency and contracts matter&lt;/strong&gt;: &lt;strong&gt;gRPC&lt;/strong&gt;. This is its home turf and nothing else comes close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming updates&lt;/strong&gt;: gRPC streaming if it's inside your walls; for browser-facing streams, server-sent events or websockets, or a messaging system if it's really events rather than calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one modern wrinkle worth knowing: the &lt;strong&gt;Connect&lt;/strong&gt; protocol (from Buf) was built specifically to stop forcing this into either-or at the client edge. It's a POST-based protocol that works over HTTP/1.1 or HTTP/2, speaks JSON or binary, runs in browsers &lt;em&gt;and&lt;/em&gt; between services, and stays compatible with gRPC. If your pain is "I want one Protobuf schema from the browser all the way down to the services," Connect is the thing that lets a single contract span that whole path without a gRPC-Web translation layer bolted on the side.&lt;/p&gt;

&lt;h2&gt;
  
  
  One request, three protocols
&lt;/h2&gt;

&lt;p&gt;Here's what "per boundary" looks like in a single trace, and why it isn't chaos. A user opens your web app. The browser sends one &lt;strong&gt;GraphQL&lt;/strong&gt; query to your backend-for-frontend, asking for exactly the fields that screen needs. The BFF fans that out to three internal services over &lt;strong&gt;gRPC&lt;/strong&gt;, because those are your services, on your network, and you want them fast and strictly typed. One of those services needs a shipping quote, so it calls a logistics partner's public &lt;strong&gt;REST&lt;/strong&gt; API, because that partner is a stranger and REST is the only thing you can assume they expose.&lt;/p&gt;

&lt;p&gt;Three protocols, one user action, and every hop used the right one for its edge. Nobody had to "win." A gRPC absolutist would be fighting the browser at the top of that trace; a GraphQL absolutist would be running a schema-and-resolver layer between services that only ever exchange fixed payloads; a REST absolutist would be making chatty, uncached, contract-less internal calls where gRPC would have been faster and safer. The polyglot version isn't a compromise. It's each boundary getting what it actually needs.&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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fone-request-three-protocols.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%2Frest-graphql-grpc-per-boundary-not-per-company%2Fone-request-three-protocols.avif" alt="A single user action crossing three protocols: the browser sends a GraphQL query for exact fields to a backend-for-frontend, which fans out over gRPC to inventory, order and payment services, and the order service calls an external logistics partner over REST. GraphQL is the client edge, gRPC the internal edge, REST the external edge" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "per company" is the actual anti-pattern
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about why standardizing one protocol everywhere goes wrong, because it always sounds so responsible in the meeting.&lt;/p&gt;

&lt;p&gt;When you mandate &lt;strong&gt;gRPC everywhere&lt;/strong&gt;, you spend real effort dragging it somewhere it doesn't belong: the browser. Now every frontend needs gRPC-Web or a proxy, your public integrators need a toolchain to call you, and you're maintaining translation layers to paper over the fact that browsers don't speak your chosen protocol.&lt;/p&gt;

&lt;p&gt;When you mandate &lt;strong&gt;GraphQL everywhere&lt;/strong&gt;, you install a schema-and-resolver layer between internal services that exchange fixed, well-known payloads and never needed query flexibility. You gave up HTTP caching, invited N+1 into paths that were simple request-response, and added query-cost policing to service-to-service calls that could have been a typed gRPC method.&lt;/p&gt;

&lt;p&gt;When you mandate &lt;strong&gt;REST everywhere&lt;/strong&gt;, your internal calls are chatty and uncached, you have no compiler-checked contract between services that deploy independently, and you're hand-rolling streaming over a protocol that would rather you didn't.&lt;/p&gt;

&lt;p&gt;Every one of those is the same mistake: taking a tool that's excellent at one kind of boundary and forcing it onto boundaries it was never good at, in the name of a consistency that only helps the org chart, not the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The senior move
&lt;/h2&gt;

&lt;p&gt;The instinct to standardize comes from a good place, fewer things to learn, one way to do it, less sprawl. And inside a single boundary, that instinct is right: pick one protocol for your public API and hold the line; pick one for your service mesh and don't have three. Consistency &lt;em&gt;within&lt;/em&gt; a boundary is a real virtue.&lt;/p&gt;

&lt;p&gt;The mistake is stretching that consistency &lt;em&gt;across&lt;/em&gt; boundaries that have nothing in common except that they're yours. The public internet and your internal service mesh are not the same problem, and the same protocol cannot be the best answer to both. The senior move isn't picking a winner. It's refusing to, and instead asking, at each edge, the four questions that actually decide it: who's on the other side, do I own both ends, what shape is the traffic, what do I need from the transport. Answer those honestly per boundary, let your system be polyglot on purpose, and the protocol war just stops being a war you have to fight.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/rest-graphql-grpc-per-boundary-not-per-company" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>graphql</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
    <item>
      <title>A2A vs MCP: What Agent-to-Agent Actually Adds Over Tool Calling</title>
      <dc:creator>Andrii B.</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:50:54 +0000</pubDate>
      <link>https://dev.to/andriiboyko/a2a-vs-mcp-what-agent-to-agent-actually-adds-over-tool-calling-176m</link>
      <guid>https://dev.to/andriiboyko/a2a-vs-mcp-what-agent-to-agent-actually-adds-over-tool-calling-176m</guid>
      <description>&lt;p&gt;The question "should I use A2A or MCP" is the wrong question, and asking it out loud is a decent early sign you don't need A2A yet. They aren't two options on the same shelf. One connects your agent &lt;em&gt;down&lt;/em&gt; to its tools; the other connects your agent &lt;em&gt;sideways&lt;/em&gt; to other agents. You can run both, neither, or one without the other, and for most teams the honest answer is "MCP, and not A2A, at least not this quarter."&lt;/p&gt;

&lt;p&gt;But that's a boring answer, and it hides the interesting part. Because when A2A does earn its place, it's not adding "collaboration" in some fuzzy marketing sense. It's adding three specific, mechanical things that a tool call cannot give you, and all three come from the same root: A2A is built to talk to an agent you &lt;em&gt;don't own and can't see inside&lt;/em&gt;. Let's pin down what that actually buys you, and when it's worth the protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different axes, not two options
&lt;/h2&gt;

&lt;p&gt;Start with where each one sits, because the whole confusion is a category error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP (the Model Context Protocol)&lt;/strong&gt; is Anthropic's standard, released in late 2024, for how a single agent reaches its tools and context: databases, APIs, files, a search index. It defines a few primitives (tools, resources, prompts), speaks JSON-RPC, and its mental model is vertical. Your agent is at the top; the things it can call are underneath it. Crucially, your agent can &lt;em&gt;enumerate&lt;/em&gt; those tools. It knows their names, their input schemas, their output shapes. A tool is, in A2A's own words, a "primitive with well-defined, structured inputs and outputs" doing a "specific, often stateless" job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A2A (Agent2Agent)&lt;/strong&gt; is a protocol Google introduced in April 2025 and handed to the Linux Foundation a couple of months later, where it now lives as a vendor-neutral project. Its mental model is horizontal. Your agent talks to &lt;em&gt;other agents&lt;/em&gt; as peers. And the defining word in A2A's own framing is "opaque": the other agent is an autonomous system that "reasons, plans, uses multiple tools, maintains state over longer interactions," and it does all of that &lt;em&gt;without showing you any of it&lt;/em&gt;. You don't see its tools. You don't see its prompt. You don't see its memory. You hand it a task and deal with it as a black box.&lt;/p&gt;

&lt;p&gt;So the picture isn't "A2A vs MCP." It's an agent with a vertical line down to its own tools (MCP) and horizontal lines out to other agents (A2A). A serious multi-agent deployment often runs both: A2A to reach other agents, MCP inside each agent to reach its own tools.&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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Ftwo-axes-not-two-options.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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Ftwo-axes-not-two-options.avif" alt="Two axes, not two options: your agent connects down via MCP to tools you own and can enumerate (Postgres, REST API, search index, each with in/out ports) and sideways via A2A to opaque peers you delegate to (another team's agent, a vendor's agent), each padlocked" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline: what a tool call already gives you
&lt;/h2&gt;

&lt;p&gt;Before naming what A2A adds, be honest about how much a plain tool call already does, because it's a lot, and it's the thing you should reach for first.&lt;/p&gt;

&lt;p&gt;When your agent calls a tool (whether you wired it by hand or through MCP), you get a clean contract. There's a name and a JSON schema. The call is usually synchronous: you ask, you block, you get a structured result or an error. You own both sides, so you can change the schema, add a field, fix a bug, and redeploy. The type system is right there. If the tool returns the wrong shape, you find out immediately, in your own codebase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;An&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;MCP-style&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tool:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;contract&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;own&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;can&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;enumerate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fetch an order by id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the baseline. It's simple, it's typed, it's synchronous, and it's yours. Ninety percent of "let my agent do X" is exactly this, and adding a second agent protocol on top would be pure overhead. So the bar for A2A isn't "does it help agents work together." The bar is "does my problem have a shape that a tool call genuinely can't hold." Here's when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What A2A actually adds
&lt;/h2&gt;

&lt;p&gt;Three things, and they're a package deal because they all fall out of the same design goal: talking to an agent you can't see into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Opacity, on purpose.&lt;/strong&gt; With a tool, you see the whole contract. With A2A, you don't, and that's the feature, not a gap. The remote agent advertises &lt;em&gt;what it can do&lt;/em&gt; through an &lt;strong&gt;Agent Card&lt;/strong&gt;, a small JSON document describing its identity, skills, endpoint, and auth, but nothing about &lt;em&gt;how&lt;/em&gt;. You never learn its internal tools or reasoning. Why would you want less visibility? Because the other agent belongs to another team, another company, or another trust boundary, and coupling to its internals would be exactly the mistake you spent years learning not to make with microservices. A2A makes the black box the unit of integration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;An&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;A&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Card:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;capability&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;advertisement,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;internals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;leaked&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fraud Review Agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reviews a transaction and returns a risk decision"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://risk.acme.com/a2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.4.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"streaming"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pushNotifications"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"review_transaction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Assess a transaction for fraud risk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Long-running, stateful tasks instead of a blocking call.&lt;/strong&gt; A tool call is a function: it returns, and it's over. A lot of real agent work isn't shaped like that. It runs for minutes, hours, or across a human approval step. A2A models this as a &lt;strong&gt;Task&lt;/strong&gt; with a lifecycle: it moves through states like &lt;code&gt;submitted&lt;/code&gt;, &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;input-required&lt;/code&gt;, and finally &lt;code&gt;completed&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. You send a message, the call returns &lt;em&gt;immediately&lt;/em&gt; with a Task that's still &lt;code&gt;working&lt;/code&gt;, and the agent keeps going in the background. You get progress three ways: synchronous polling, a Server-Sent Events stream for live updates, or &lt;strong&gt;push notifications&lt;/strong&gt; to a webhook when the whole thing is disconnected and long. Try expressing "start this, it'll take an hour, ping my webhook when the human approves" as a single tool call. You can't, cleanly. That's a Task.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;message/send&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;returns&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;still&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;running,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;final&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;answer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message/send"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Review transaction tx_88213"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"t_9f2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"working"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;later:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;poll&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tasks/get,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;receive&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;push&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;notification,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;read&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stream&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Discovery, so you don't hardwire the other side.&lt;/strong&gt; With tools, you know at build time exactly what's available; you wrote them in. A2A agents publish Agent Cards, so a client can &lt;em&gt;discover&lt;/em&gt; a capable peer and negotiate with it, including which content types and modalities both sides speak. It's the difference between calling a function you imported and finding a service that advertises it can do the job. That only matters across a boundary where you don't control, or don't want to control, the full catalogue up front.&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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Fa2a-task-lifecycle.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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Fa2a-task-lifecycle.avif" alt="The A2A Task lifecycle as a state machine that a synchronous tool call cannot express: submitted, then working (where the call returns immediately), branching to input-required (needs human input) then completed, or to failed; results reach the client later via polling (tasks/get), an SSE stream, or a push webhook" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is the whole point
&lt;/h2&gt;

&lt;p&gt;Read those three back and notice they're the same idea wearing three hats. Opacity, long-running tasks, and discovery are all answers to one situation: &lt;strong&gt;the other agent is not yours.&lt;/strong&gt; It's another team's service, a vendor's product, a partner's system. You can't import its functions, you shouldn't see its internals, and it doesn't finish on your request's timeline.&lt;/p&gt;

&lt;p&gt;That's the real test, and it's a lot sharper than "do I have multiple agents." You can absolutely have five agents in one codebase, all yours, and the right way to connect them is still boring function calls or MCP, because you own them, you can enumerate them, and a typed call beats a network protocol every time you can have one. Putting A2A between two agents you control is the classic over-engineering move: you've traded a compile-time-checked call for an opaque JSON-RPC round trip and gotten nothing back, because the opacity you're paying for is protecting you from a boundary that doesn't exist.&lt;/p&gt;

&lt;p&gt;A2A earns its keep exactly at the org chart's edges. Your agent needs a risk decision from the fraud team's agent, and that team ships on their own cadence and won't hand you their internals. You integrate a vendor's research agent you literally cannot see inside. A partner exposes a booking agent and you negotiate over A2A. In every case the thing you actually needed wasn't "collaboration." It was a stable seam between systems that don't, and shouldn't, know each other's guts.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which do you reach for?
&lt;/h2&gt;

&lt;p&gt;Here's the decision, stripped down.&lt;/p&gt;

&lt;p&gt;Default to a &lt;strong&gt;tool call&lt;/strong&gt;, and use &lt;strong&gt;MCP&lt;/strong&gt; to standardize it when you have more than a couple of tools or want them reusable across agents. This covers the overwhelming majority of "make my agent able to do X." It's typed, synchronous, and yours.&lt;/p&gt;

&lt;p&gt;Reach for &lt;strong&gt;A2A&lt;/strong&gt; when the other side is an agent you don't own or can't see into, when the work is long-running or needs a human in the loop across time, or when you need to discover and negotiate with a capable peer rather than hardwire it. If none of those is true, you're adding a protocol to solve a problem you don't have.&lt;/p&gt;

&lt;p&gt;And know that they compose, because that's the setup you'll actually run once you're past one agent. A2A's own docs use an auto-repair-shop example: the customer talks to the shop's manager agent over A2A, the manager delegates to a mechanic agent over A2A, and the mechanic reaches its diagnostic tools over MCP. Outward across boundaries, A2A. Inward to your own tools, MCP. The agent is the same thing on both sides; only the axis changes.&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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Fis-the-other-agent-yours.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%2Fa2a-vs-mcp-agent-to-agent-vs-tool-calling%2Fis-the-other-agent-yours.avif" alt="A decision diagram: is the thing you're calling an agent you own and can see inside? Yes leads to a tool call or MCP (typed, synchronous, enumerable); no leads to A2A (opaque peer, long-running Task, discovery), with a note that long-running or human-in-the-loop work also fits an async Task. Caption: the number of agents doesn't decide this, the boundary does" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one question
&lt;/h2&gt;

&lt;p&gt;Strip away the protocol names and the layer diagrams and you're left with a single question that decides everything: &lt;strong&gt;is the agent I'm calling mine?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it is, keep it simple. Call the function, wire the tool, standardize with MCP when the toolset grows. You keep your types, your synchronous flow, and the ability to fix both sides at once. If it isn't, that's the moment A2A stops being overhead and starts being the thing that lets two systems you don't jointly control actually work together, without either one reaching into the other.&lt;/p&gt;

&lt;p&gt;"A2A vs MCP" was never the real question. The real one is where your boundaries are, and whether the agent on the other side of one is a black box by accident or by design. Get that right and the protocol picks itself.&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;br&gt;
&lt;em&gt;Originally published at &lt;a href="https://andriiboyko.com/articles/a2a-vs-mcp-agent-to-agent-vs-tool-calling" rel="noopener noreferrer"&gt;andriiboyko.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
      <category>architecture</category>
    </item>
    <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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction note (September 2026):&lt;/strong&gt; Maxim Fateev, co-founder of Temporal, pointed out several factual errors in the original version. I have corrected the sections on Cassandra, TypeScript determinism, and Restate's replay model. His full comment is in the discussion below, and I recommend reading it. Many thanks to him for taking the time to read the article this closely and write up these corrections.&lt;/p&gt;
&lt;/blockquote&gt;

&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). Cassandra is still supported as a persistence store. What was deprecated in Server v1.21 and removed in v1.24 was standard Visibility, which was the only Visibility mode Cassandra ever supported. So Cassandra works for persistence, just not for the current Visibility model. 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 how much you feel it depends on the SDK. In Go and Java you design around it by hand. The TypeScript SDK runs each workflow in a V8 isolate sandbox where &lt;code&gt;Date&lt;/code&gt;, &lt;code&gt;Math.random()&lt;/code&gt; and &lt;code&gt;setTimeout()&lt;/code&gt; are replaced with deterministic versions, so in the code above you can call them directly and replay stays correct. The contract is still there, the sandbox just enforces it for you. 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. The replay mechanism is the same idea: Restate re-runs the handler from the start and skips steps already in the journal, so code outside those steps has to be deterministic here too. Restate's own docs list &lt;code&gt;Math.random()&lt;/code&gt;, &lt;code&gt;new Date()&lt;/code&gt; and iterating unordered collections as causes of journal mismatch errors. The difference is ergonomic, not contractual: a side effect goes inline through &lt;code&gt;ctx.run&lt;/code&gt; in the same function instead of into an activity in a separate deployable. Both position themselves as durable execution platforms rather than workflow engines, and both point at use cases well beyond orchestration.&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 lives in a Virtual Object instead of a workflow-scoped variable. 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 things 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>
  </channel>
</rss>
