<?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: René Knierim</title>
    <description>The latest articles on DEV Community by René Knierim (@oblitus).</description>
    <link>https://dev.to/oblitus</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%2F3987897%2F478c030f-4c47-4de1-82ad-74815f1d4f09.png</url>
      <title>DEV Community: René Knierim</title>
      <link>https://dev.to/oblitus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oblitus"/>
    <language>en</language>
    <item>
      <title>Design Salesforce Like a Distributed System</title>
      <dc:creator>René Knierim</dc:creator>
      <pubDate>Mon, 22 Jun 2026 11:06:02 +0000</pubDate>
      <link>https://dev.to/oblitus/design-salesforce-like-a-distributed-system-57i0</link>
      <guid>https://dev.to/oblitus/design-salesforce-like-a-distributed-system-57i0</guid>
      <description>&lt;p&gt;Distributed systems engineers don't get to reach across service boundaries whenever it's convenient. The network stops them. A microservice that needs data owned by another service can't just query it. It has to ask, wait, and handle failure. That friction is annoying. It's also the thing that enforces discipline: every team knows exactly where its responsibility ends, because the boundary is physical.&lt;/p&gt;

&lt;p&gt;Salesforce gives you no such friction. Every object in your org, every &lt;code&gt;Account&lt;/code&gt;, every &lt;code&gt;Order&lt;/code&gt;, every custom object owned by another team, is one SOQL query away. Any Apex class can read it. Any trigger can write to it. Any Flow can fire on it. There is no network. There is no boundary. Nothing stops one domain from reaching into another's territory, and nothing makes that reach-in visible until something breaks.&lt;/p&gt;

&lt;p&gt;That's the core problem this article addresses: &lt;strong&gt;how to design a Salesforce org with the discipline distributed systems engineers are forced to have, in an environment that will never force it on you.&lt;/strong&gt; Platform Events and a principled trigger architecture are the tools. Bounded contexts, facts vs commands, and choreography or orchestration are the concepts. The prize is the same thing distributed systems get from their hard boundaries: teams that can ship independently without breaking each other.&lt;/p&gt;

&lt;p&gt;This article is opinionated, grounded in real patterns from a production org, and the goal is that it shifts how you think about who's &lt;em&gt;allowed&lt;/em&gt; to touch what, not just how to get work done async.&lt;br&gt;
It is also not an argument that every Salesforce org should adopt a distributed systems pattern. Rather, this is a thought experiment exploring what changes when we analyze Salesforce from a distributed systems lens.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. What Salesforce doesn't force on you (and why that's dangerous)
&lt;/h2&gt;

&lt;p&gt;In a distributed system, the boundary between services is enforced by physics. Service B cannot read Service A's database directly. If it needs data, it calls an API, subscribes to an event stream, or maintains its own projection. These patterns (APIs, events, read models) didn't emerge from clever design thinking. They emerged because there was literally no other option.&lt;/p&gt;

&lt;p&gt;The result is that distributed systems engineers develop a reflex: before touching anything, ask "do I own this?" If the answer is no, the question becomes "what's the sanctioned channel for getting what I need?"&lt;/p&gt;

&lt;p&gt;Salesforce developers rarely develop this reflex, because the platform never punishes them for not having it. The consequence plays out slowly. In year one, one team writes a trigger on &lt;code&gt;Contact&lt;/code&gt; and it works fine. In year two, three teams have logic in the same trigger handler. In year five, no one team can change the &lt;code&gt;Contact&lt;/code&gt; object without coordinating with four others, running a full regression suite, and hoping nothing breaks in production. The org has become a &lt;strong&gt;non-distributed monolith&lt;/strong&gt;: all the coupling of a distributed system with none of the boundaries, and no network to make the coupling visible.&lt;/p&gt;

&lt;p&gt;Distributed systems thinking is the antidote. Not the mechanics (you don't need service meshes or gRPC), but the &lt;em&gt;mindset&lt;/em&gt;: treat domain boundaries as if they were network boundaries, even though they aren't. Refuse cross-domain reach-ins the same way a service refuses direct database access from a neighbour. Use events as the only sanctioned channel for cross-domain communication.&lt;/p&gt;

&lt;p&gt;The difference from the distributed world is that on Salesforce you have one significant advantage: &lt;strong&gt;the data is already local.&lt;/strong&gt; You don't need to copy data across service boundaries, because there are no service boundaries at the database level. Every subscriber can re-query the source data directly. It's a SOQL call away. This changes the shape of event design considerably, as we'll get to in §7.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. Domain bleed: what coupling without a boundary looks like
&lt;/h2&gt;

&lt;p&gt;Distributed systems literature warns about the "distributed monolith": services so entangled they must deploy together, with all the pain of distribution and none of the independence. In Salesforce, you get a mirror-image problem: a &lt;strong&gt;non-distributed monolith&lt;/strong&gt;. One schema, one automation surface, every team in everyone else's business, and no network boundary to make the coupling visible or enforce a fix.&lt;/p&gt;

&lt;p&gt;Domain bleed is when one domain's logic executes inside another domain's territory. In Salesforce it looks 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpvwmwwmsvvb2dyesr6u.png" 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%2Fvpvwmwwmsvvb2dyesr6u.png" alt=" " width="799" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Concrete symptoms, all of which you have seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A trigger nobody owns.&lt;/strong&gt; &lt;code&gt;AccountTrigger&lt;/code&gt; (or the one handler class) has commits from five teams. Touching it for a Billing change risks a Sales regression. Code review needs three squads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation firing across domains.&lt;/strong&gt; A Billing flow triggered by a field change on &lt;code&gt;Opportunity&lt;/code&gt;, which Sales owns. Sales refactors a picklist; Billing breaks in production; nobody connected the two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SOQL reaching three domains deep.&lt;/strong&gt; A Support class queries &lt;code&gt;Case → Contact → Account → Opportunity → OpportunityLineItem&lt;/code&gt;, encoding assumptions about four domains' models. Any of them refactoring breaks the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation rules encoding someone else's invariants.&lt;/strong&gt; A rule on &lt;code&gt;Contact&lt;/code&gt; enforcing a Marketing constraint that fires for every team that ever inserts a contact, including integration users that know nothing about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared fields with implicit contracts.&lt;/strong&gt; A field added "just for Marketing" that automation, reports, and tests across the org now silently depend on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is the same coupling a distributed system avoids by construction. Things that should be independent become one fragile thing, concentrated in a single org with a single deploy. You can't ship one domain without regression-testing the others. There is no slice any team fully owns. Velocity collapses as the org ages, and everyone blames "Salesforce technical debt" when the real diagnosis is &lt;em&gt;missing boundaries&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here's the critical distinction: &lt;strong&gt;the shared database is not the bleed.&lt;/strong&gt; Co-located data is your advantage, not your problem. Distributed systems would kill for it. The bleed is &lt;em&gt;control flow and ownership&lt;/em&gt; crossing boundaries: one domain reaching in to query, fire on, or write to another's objects. A distributed system can't do that because the network stops it. In Salesforce, you have to stop it yourself. Events are how you do it.&lt;/p&gt;


&lt;h2&gt;
  
  
  3. There are two kinds of event-driven Salesforce, and triggers are one of them
&lt;/h2&gt;

&lt;p&gt;Before Platform Events: Salesforce already had an event-driven primitive that every Apex developer uses every day. &lt;strong&gt;Triggers are synchronous events.&lt;/strong&gt; A record changes; the platform fires a before/after trigger; your code runs. The pattern is event-driven at its core.&lt;/p&gt;

&lt;p&gt;The problem is ownership. And this is where the two models diverge sharply.&lt;/p&gt;
&lt;h3&gt;
  
  
  The trigger ownership problem
&lt;/h3&gt;

&lt;p&gt;When a trigger is owned by &lt;em&gt;everyone&lt;/em&gt;, it's owned by &lt;em&gt;nobody&lt;/em&gt;. The classic symptom: a single &lt;code&gt;AccountTrigger.cls&lt;/code&gt; (or one handler class dispatching to a dozen unrelated features) with commits from Sales, Fulfillment, Finance, and Support teams. The result:&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%2F0p9c5xyhdzrelpnk2y07.png" 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%2F0p9c5xyhdzrelpnk2y07.png" alt=" " width="799" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every team coupling to one file means nobody can deploy safely alone. The trigger becomes a bottleneck: a coordination tax for every change, regardless of which domain is touching it.&lt;/p&gt;

&lt;p&gt;The old pattern, a &lt;code&gt;TriggerFactory&lt;/code&gt; loading handlers from custom settings metadata, improved things by registering handlers per-object. But the fundamental problem remained: multiple teams could still register multiple handlers for the same object, and the execution order and interactions between them were implicit.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trigger Actions: single responsibility, metadata-driven composition
&lt;/h3&gt;

&lt;p&gt;A prime example of a trigger frame work that solves this issue at the architecture level is Mitch Spano's Trigger Actions framework. Each action is a &lt;strong&gt;focused, single-responsibility class&lt;/strong&gt; that implements one interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One class, one concern, one owner&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TA_Order_Placed&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;TriggerAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;AfterInsert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;afterInsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;triggerNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// only Fulfillment domain logic here&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;Actions are composed via metadata (&lt;code&gt;Trigger_Action__mdt&lt;/code&gt;), not code. The trigger file itself becomes trivial, a one-liner that hands off to the framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;OrderTrigger&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="k"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MetadataTriggerHandler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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;What this buys you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Legacy &lt;code&gt;TriggerFactory&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Trigger Actions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ownership&lt;/td&gt;
&lt;td&gt;Multiple handlers, shared registration&lt;/td&gt;
&lt;td&gt;One class per concern, clear owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composition&lt;/td&gt;
&lt;td&gt;Code changes to add/remove&lt;/td&gt;
&lt;td&gt;Metadata record, no deploy to wire up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Handler class tests mixed concerns&lt;/td&gt;
&lt;td&gt;Each action tested in isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bypass&lt;/td&gt;
&lt;td&gt;Shared disable switch&lt;/td&gt;
&lt;td&gt;Per-action bypass via custom permission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order of execution&lt;/td&gt;
&lt;td&gt;Implicit / registration order&lt;/td&gt;
&lt;td&gt;Explicit order field on metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;metadata-driven composition&lt;/strong&gt; is the key unlock. Sales can add a &lt;code&gt;TA_Order_Placed&lt;/code&gt; action for Order without touching any file owned by Fulfillment. Fulfillment can add its own Order action independently. The trigger file never changes; it's infrastructure, not logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Async events: Platform Events
&lt;/h3&gt;

&lt;p&gt;Platform Events are the &lt;em&gt;asynchronous&lt;/em&gt; counterpart. Where trigger actions fire synchronously in the DML transaction, Platform Events cross transaction boundaries: publish now, process later, survive failures independently.&lt;/p&gt;

&lt;p&gt;The right mental model for on-platform work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Synchronous (trigger actions):&lt;/strong&gt; "This just happened in my domain. Process it now, in this transaction."&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Asynchronous (Platform Events):&lt;/strong&gt; "This happened in my domain. Other domains should know about it, at their own pace, in their own transaction, on their own failure boundary."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both are event-driven. The difference is timing, ownership, and what happens when something fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The property that makes it all work: the producer doesn't know its consumers
&lt;/h3&gt;

&lt;p&gt;Here is the single most important thing about messaging, and the reason it cuts coupling where a direct call cannot. It's worth stating on its own because everything downstream depends on it.&lt;/p&gt;

&lt;p&gt;When you call something (an Apex method, a &lt;code&gt;@future&lt;/code&gt;, a queueable, a REST callout), &lt;strong&gt;the caller has to name the callee.&lt;/strong&gt; The dependency points from producer to consumer. If a second consumer needs to react, you go back and edit the producer to call it too. The producer accumulates knowledge of everyone who depends on it, and every new reaction is a change to the thing that fired the event. That's true even when the call is &lt;em&gt;asynchronous&lt;/em&gt;. A &lt;code&gt;@future&lt;/code&gt; method is still point-to-point; async timing doesn't decouple anything, it just moves the same hard-wired call off the synchronous path.&lt;/p&gt;

&lt;p&gt;Publish/subscribe inverts that arrow. &lt;strong&gt;The producer names an event, not a consumer.&lt;/strong&gt; It announces "this happened" into the bus and is done. It does not know who is listening, and must not care. Consumers register their &lt;em&gt;own&lt;/em&gt; interest by subscribing. The dependency now points from consumer to event contract, not from producer to consumer:&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%2Fm2arjucbqs7vsxz371x6.png" 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%2Fm2arjucbqs7vsxz371x6.png" alt=" " width="799" height="381"&gt;&lt;/a&gt;&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%2Flxsr7rldx6qd5baolhnf.png" 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%2Flxsr7rldx6qd5baolhnf.png" alt=" " width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two consequences fall out of this, and both are things a direct call (synchronous or asynchronous) simply cannot give you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;N consumers, zero producer changes.&lt;/strong&gt; One published fact can fan out to one subscriber or fifty. Adding the fifty-first means &lt;em&gt;deploying the new subscriber&lt;/em&gt;. The producer never learns it exists, never recompiles, never gets re-tested. In a point-to-point world, every new reaction is a change to the producer. In pub/sub, new reactions are purely additive and live entirely in the consumer's domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dependency direction is reversed, and that's the whole game.&lt;/strong&gt; "Who depends on whom" is the question that decides whether you can deploy independently. With a direct call, Sales depends on Fulfillment (it has to know Fulfillment exists to call it). With an event, Fulfillment depends on Sales's &lt;em&gt;event contract&lt;/em&gt; (a stable, published fact) and Sales depends on nobody. The domain that owns the data is now ignorant of, and immune to, everyone reacting to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why "fire an async job" is &lt;em&gt;not&lt;/em&gt; the same as "publish an event," even though both run later. The async job still couples the producer to a named consumer. The event doesn't. Decoupling comes from the inverted dependency, not from the asynchrony.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The cure: notify, don't reach in
&lt;/h2&gt;

&lt;p&gt;Replace every cross-domain reach-in with a published fact. The domain that owns the data publishes "this happened." Domains that care subscribe and react &lt;strong&gt;in their own territory, on their own automation, on their own deploy cadence.&lt;/strong&gt;&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%2F8vwmlt5unpanv6hu2du3.png" 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%2F8vwmlt5unpanv6hu2du3.png" alt=" " width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing crosses a boundary except a fact. Fulfillment no longer fires on &lt;code&gt;Order&lt;/code&gt;; it reacts to &lt;code&gt;OrderPlaced__e&lt;/code&gt; and writes to its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;Shipment__c&lt;/code&gt;. Finance no longer reaches into Sales's object graph; it subscribes to the same fact and manages its own &lt;code&gt;Invoice__c&lt;/code&gt;. Sales can refactor everything behind &lt;code&gt;Order&lt;/code&gt; as long as the published fact stays stable. You have drawn a bounded-context border &lt;strong&gt;inside one org&lt;/strong&gt;, using events as the only sanctioned channel across it.&lt;/p&gt;

&lt;p&gt;This is the reframe the generic posts miss. The bus isn't a data pipe here. It's a &lt;strong&gt;membrane&lt;/strong&gt;: facts pass through, reach-ins don't. And the data stays exactly where it was, one query away, for whoever legitimately owns the context that needs it.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. A cautionary tale: the order-handoff anti-pattern
&lt;/h2&gt;

&lt;p&gt;Here is what domain bleed looks like when it wears an event-driven costume.&lt;/p&gt;

&lt;p&gt;Imagine an order-to-fulfillment flow that uses platform events to chain processing steps: &lt;code&gt;OrderHandoff_STEP1__e&lt;/code&gt; → &lt;code&gt;OrderHandoff_STEP2__e&lt;/code&gt; → &lt;code&gt;OrderHandoff_SHIPMENT__e&lt;/code&gt; → &lt;code&gt;OrderHandoff_STEP4__e&lt;/code&gt; → ... Each step publishes the next event from its &lt;code&gt;finally&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// OrderHandoff_SHIPMENT handler (in the Sales domain)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;triggerNext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;publishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;OrderHandoff_STEP4__e'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;hasError&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;And in the shipment step, the Sales domain directly inserts &lt;code&gt;Shipment__c&lt;/code&gt; records, which belong to Fulfillment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sales reaching into Fulfillment's territory&lt;/span&gt;
&lt;span class="n"&gt;orderToShipmentMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Shipment__c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Order__c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CarrierCode__c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;CarrierCode__c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ServiceLevel__c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;ServiceLevel__c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ... a dozen more fields from Sales's data model&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderToShipmentMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform events look modern. But look at what Sales is actually doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It knows &lt;code&gt;Shipment__c&lt;/code&gt; exists in the Fulfillment domain.&lt;/li&gt;
&lt;li&gt;It knows the exact field-level schema of that object.&lt;/li&gt;
&lt;li&gt;It writes directly to it.&lt;/li&gt;
&lt;li&gt;It orchestrates the entire sequence. Fulfillment has no say in when or whether its own records get created.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Sales is reaching into Fulfillment's territory.&lt;/strong&gt; The events just move the coupling off a shared trigger file and onto the data model. This is "domain bleed in a costume." If Fulfillment adds a required validation rule to &lt;code&gt;Shipment__c&lt;/code&gt;, Sales's handoff breaks. If Fulfillment renames a field, Sales breaks. The dependency arrow points the wrong way.&lt;/p&gt;

&lt;p&gt;The correct design: Sales publishes &lt;code&gt;OrderPlaced__e&lt;/code&gt; carrying just the record ID and the timestamp. Fulfillment subscribes, queries the data it needs (same database, one SOQL call away), and creates its own &lt;code&gt;Shipment__c&lt;/code&gt; in its own transaction, on its own schedule, under its own validation rules. Fulfillment &lt;em&gt;owns&lt;/em&gt; shipment creation. Sales just announces that an order was placed.&lt;/p&gt;

&lt;p&gt;This is the "notify, don't reach in" principle from §4. The event should state a fact in the publisher's past tense. &lt;code&gt;OrderHandoff_SHIPMENT__e&lt;/code&gt; is not a fact; it's a command telling a later step to create something specific in another domain. If you can name the one subscriber that's supposed to handle it, you've written a command, and the coupling is still there.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. A well-designed example: the Support domain
&lt;/h2&gt;

&lt;p&gt;By contrast, the Support domain shows what the pattern looks like when it's right.&lt;/p&gt;

&lt;p&gt;When a case is escalated, a Trigger Action publishes a &lt;code&gt;CaseEscalated&lt;/code&gt; event outbound. Notice what it &lt;em&gt;doesn't&lt;/em&gt; do: it doesn't reach into any other domain, it doesn't write to any other object, and it doesn't know who the subscribers are.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TA_Case_Escalated&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;TriggerAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;AfterUpdate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;IEventPublisher&lt;/span&gt; &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;afterUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;triggerNew&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;triggerOld&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;Account&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;accountMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TA_Case_Queries&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAccountMap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Case&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Case&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;triggerNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;shouldPublishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountMap&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;CaseEscalated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;AccountId&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="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;Boolean&lt;/span&gt; &lt;span class="nf"&gt;shouldPublishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Case&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;Account&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;accountMap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Account&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accountMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;AccountId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;Tier__c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;Enterprise'&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;What's clean about this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single responsibility.&lt;/strong&gt; One action class, one concern. The trigger file is infrastructure: it delegates to &lt;code&gt;MetadataTriggerHandler&lt;/code&gt; and contains no logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fact, not command.&lt;/strong&gt; &lt;code&gt;CaseEscalated&lt;/code&gt; names something true in the Support domain's past tense. It doesn't tell anyone what to do with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publisher is indifferent to consumers.&lt;/strong&gt; Support doesn't know whether an account manager, a downstream warehouse, or a notification system will react. It announces; others decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain stays in its own territory.&lt;/strong&gt; The action queries its own data (account tier), publishes to an abstract interface (&lt;code&gt;IEventPublisher&lt;/code&gt;), and returns. No cross-domain DML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The event publisher interface&lt;/strong&gt; keeps the domain agnostic of the transport layer. Whether the platform event gets bridged to Kafka, routed to a middleware, or handled purely on-platform is infrastructure's concern:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IEventPublisher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;addMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="n"&gt;messageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SObject&lt;/span&gt; &lt;span class="n"&gt;record&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;correlationId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publish&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 contrast with the order-handoff flow is instructive. Both use events. Both have Trigger Actions wiring the logic. The difference is entirely in what the event means and who controls the downstream writes.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The rule that flips at the boundary
&lt;/h2&gt;

&lt;p&gt;Here's the question that immediately follows "publish a fact": &lt;strong&gt;how much do you put in the event?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The microservices world has two answers, named by Martin Fowler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event notification:&lt;/strong&gt; a thin signal. "Order 0061x placed." Just an id and what changed. The subscriber goes and looks if it needs more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event-carried state transfer:&lt;/strong&gt; a fat, self-contained event. Everything a consumer could need, baked in, so it never has to call back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;People argue about which is "right." The argument is unresolvable because &lt;strong&gt;the right answer depends on whether the subscriber can cheaply query back, and on Salesforce that answer is different inside the org versus outside it.&lt;/strong&gt; This is the rule:&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%2Fbjsx506y7rb4nxlxlbl4.png" 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%2Fbjsx506y7rb4nxlxlbl4.png" alt=" " width="799" height="184"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the org → thin notification events.&lt;/strong&gt; The subscriber is in the same database. Re-querying is one cheap, current, transactional SOQL call. This is the Salesforce advantage a distributed system never has: the data is already local. Stuffing state into the event would duplicate data that's right there and instantly stale. So on-platform, publish &lt;code&gt;OrderPlaced__e&lt;/code&gt; carrying little more than the record id and the change; let the Fulfillment trigger query the source data itself. Communication, not duplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the external boundary → fat, self-contained facts.&lt;/strong&gt; The moment the consumer is outside Salesforce, re-querying is expensive and coupling: a callback over the API, an auth handshake, latency, rate limits, and a hard dependency on Salesforce being up. So the events you publish &lt;em&gt;outward&lt;/em&gt; should carry the full business fact: everything a downstream system needs to act without calling back.&lt;/p&gt;

&lt;p&gt;A common pattern is a single outbound platform event (&lt;code&gt;OutboundEvent__e&lt;/code&gt;) that acts as a generic envelope: on-platform triggers add messages via an event publisher interface, which serializes the payload and routes it outward (e.g. through a middleware layer to Kafka). The fat-vs-thin distinction maps cleanly onto that: inside the org, thin signals on domain-specific events; at the outbound envelope boundary, fat self-contained payloads.&lt;/p&gt;

&lt;p&gt;And this is where your earlier instinct finally becomes correct: &lt;strong&gt;the read model, the projection, the materialized copy all belong downstream, outside Salesforce, fed by the fat boundary events.&lt;/strong&gt; Not because projections are wrong, but because &lt;em&gt;that&lt;/em&gt; is the place where data locality genuinely breaks and a local copy genuinely earns its keep. Inside the org you'd never build one. Across the boundary, you must.&lt;/p&gt;

&lt;p&gt;So the same system uses both styles, and choosing by &lt;em&gt;location&lt;/em&gt; dissolves the whole thin-vs-fat debate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Inside the org&lt;/th&gt;
&lt;th&gt;At the external boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subscriber can re-query?&lt;/td&gt;
&lt;td&gt;Yes, cheap SOQL&lt;/td&gt;
&lt;td&gt;No, expensive callback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event style&lt;/td&gt;
&lt;td&gt;Thin notification&lt;/td&gt;
&lt;td&gt;Fat, self-contained fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carry full state?&lt;/td&gt;
&lt;td&gt;No, it's already local&lt;/td&gt;
&lt;td&gt;Yes, consumer can't fetch it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build a projection?&lt;/td&gt;
&lt;td&gt;Never, data is co-located&lt;/td&gt;
&lt;td&gt;Yes, downstream where locality breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event's job&lt;/td&gt;
&lt;td&gt;Communication + ownership&lt;/td&gt;
&lt;td&gt;Communication + data transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you take one thing from this article, take this table. It's where Salesforce's data-locality advantage changes the rules, and where the distributed-systems playbook applies unchanged.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Facts, not commands (the bleed wears a costume)
&lt;/h2&gt;

&lt;p&gt;Domain bleed has a sneaky form that survives even after you've "gone event-driven." It's the command disguised as an event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bleed in a costume. Not an event.&lt;/span&gt;
&lt;span class="n"&gt;CreateShipment__e&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateShipment__e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderId__c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;EventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&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;CreateShipment__e&lt;/code&gt; is not a fact. It's the publisher reaching across a boundary to tell one specific subscriber what to do, just routed through the bus so it &lt;em&gt;looks&lt;/em&gt; decoupled. The publisher still knows the shipment step exists, still knows what it should do, still breaks when that domain changes. You've moved the bleed onto the event bus, not removed it.&lt;/p&gt;

&lt;p&gt;A real fact names something true in the &lt;em&gt;publisher's&lt;/em&gt; domain, in the past tense, and is indifferent to who reacts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A fact. Past tense. Publisher's domain. Reaches into no one.&lt;/span&gt;
&lt;span class="n"&gt;OrderPlaced__e&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderPlaced__e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;OrderId__c&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AccountId__c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;AccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PlacedAt__c&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;EventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The litmus test:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A fact says "X happened." A command says "service Y, do Z."&lt;/strong&gt; If you can name the one subscriber that's &lt;em&gt;supposed&lt;/em&gt; to handle it, you've written a command, and the coupling you were trying to cut is still there, now harder to see.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  It's the name, not the transport
&lt;/h3&gt;

&lt;p&gt;Here's the crucial point, and it's where a lot of advice goes wrong: &lt;strong&gt;a Platform Event can legitimately carry either a fact or a command.&lt;/strong&gt; Nothing about the platform forces the distinction. The same &lt;code&gt;EventBus.publish()&lt;/code&gt; call ships both. The difference lives entirely in &lt;em&gt;how you name and shape the message&lt;/em&gt;, and in whether you're honest about which one it is.&lt;/p&gt;

&lt;p&gt;You don't need a separate piece of infrastructure (a queue product, a second bus) to carry commands. That's the import error again: borrowing "facts go on the event bus, commands go on a queue" from architectures where those are physically different systems. On Salesforce, both are just Platform Events. What keeps the line honest is naming discipline, not a second transport:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;Past tense: &lt;code&gt;OrderPlaced__e&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Imperative: &lt;code&gt;RecalculateDiscount__e&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answers&lt;/td&gt;
&lt;td&gt;"What happened?"&lt;/td&gt;
&lt;td&gt;"What should be done?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knows the recipient?&lt;/td&gt;
&lt;td&gt;No, indifferent to who reacts&lt;/td&gt;
&lt;td&gt;Yes, aimed at a specific handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumers&lt;/td&gt;
&lt;td&gt;Zero to many&lt;/td&gt;
&lt;td&gt;Exactly one (it's a directed instruction)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coupling&lt;/td&gt;
&lt;td&gt;Publisher depends on no one&lt;/td&gt;
&lt;td&gt;Publisher depends on the handler existing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Commands aren't evil; they're necessary. Sometimes you genuinely do need to instruct one specific handler to do one specific thing. The mistake is dressing a command up in fact's clothing (a thin event that &lt;em&gt;looks&lt;/em&gt; decoupled but names exactly one intended subscriber) and then believing you've cut the coupling. If it's a command, name it like one: imperative, owned by the caller, pointed at a known handler, so the coupling is at least visible and you can decide whether it belongs. The danger isn't commands on the bus; it's commands &lt;em&gt;pretending&lt;/em&gt; to be facts.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Who owns the cross-boundary flow? Choreography or orchestration
&lt;/h2&gt;

&lt;p&gt;Once you've drawn borders, some business processes still span them, and you have to decide how they're coordinated. The cheap version of this debate is "choreography good, orchestration bad." It's wrong, and on Salesforce you can fall into the failure mode of either one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default to choreography.&lt;/strong&gt; Facts on the bus; whoever cares reacts. Loosely coupled, scales by addition. Right for "a thing happened, others may want to know."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for orchestration deliberately&lt;/strong&gt;, when a process spans domains and &lt;em&gt;must&lt;/em&gt; complete, needs ordering or end-to-end visibility, or has to be undoable (compensation).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sentence to tattoo on the team:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Someone has to own the multi-step transaction. Pick the owner on purpose.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The order-handoff anti-pattern from §5 illustrates this. Several steps chained via platform events, each step publishing the next, with no single owner tracking whether the whole thing succeeded. It's "Lambda pinball": an emergent chain nobody owns end-to-end, impossible to trace holistically, with no clean answer to "did the handoff finish, and how do we compensate if a middle step failed?" Per-step observability and error logging helps, but that's a bandage on a structural problem.&lt;/p&gt;

&lt;p&gt;The fix is not a central god-orchestrator micromanaging anemic domains. It's a &lt;em&gt;local&lt;/em&gt; coordinator: a state machine that owns the process, issues commands, and tracks completion, while the business logic stays in the domain handlers. For long-running, undoable processes you want a &lt;strong&gt;saga&lt;/strong&gt;, where each step registers a compensating action so a half-finished process rolls back cleanly.&lt;/p&gt;

&lt;p&gt;One honest caveat: the elegant "just use a local in-process engine" advice from the JVM world assumes a free-form runtime. On Salesforce your coordination primitives are Flow, Platform Events, and async Apex, all under governor limits, so you own the process explicitly &lt;em&gt;within&lt;/em&gt; that async model.&lt;/p&gt;

&lt;p&gt;The two anti-patterns, both reachable on-platform:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Events that are secretly commands → hidden coupling, no owner.&lt;/li&gt;
&lt;li&gt;An orchestrator babysitting steps that are really independent reactions → a bottleneck for nothing.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  10. Splitting the monorepo into domains
&lt;/h2&gt;

&lt;p&gt;The architecture above only pays off if you can actually enforce the boundaries it describes. Domain isolation in a Salesforce org isn't just about code style - it's a deployment question.&lt;/p&gt;

&lt;p&gt;A repo that takes this seriously reflects it in directory structure. Rather than one flat &lt;code&gt;src/&lt;/code&gt; folder where every team drops files, you organize by bounded context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── domain-core/              # Shared infrastructure (trigger       # Event bus infrastructure (platform event objects, routing metadata)
├── domain-sales/             # Leads, opportunities, orders
│   ├── lead-management/
│   ├── opportunity/
│   └── order/
├── domain-fulfillment/       # Shipments, inventory, logistics
│   ├── shipment/
│   ├── inventory/
│   └── logistics/
├── domain-finance/           # Invoicing, billing, payments
├── domain-support/           # Case management
└── _common-modules/          # Cross-cutting UI components and utilities
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each domain is a candidate for its own &lt;strong&gt;unlocked package&lt;/strong&gt; (2GP). A domain can refactor its internals freely as long as the events it publishes stay compatible.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deployment decoupling is the one that changes how fast your org can move&lt;/strong&gt; - and it's only reachable once cross-domain communication goes through facts instead of reach-ins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In a bled org, the deployment unit is the whole org. With events as the only sanctioned cross-domain channel, each domain can become its own independently-shippable unit. Fulfillment deploys on Tuesday. Sales deploys on Thursday. Neither regression-tests the other, because neither reaches into the other's objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. The field guide: Platform Event mechanics that actually decide success
&lt;/h2&gt;

&lt;p&gt;Everything above is design. This is the part where the platform's specific behaviour makes or breaks it. These are fact-checked against current Salesforce documentation, because half the lore floating around is stale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Publish behaviour, and a footgun
&lt;/h3&gt;

&lt;p&gt;When you define an event you choose &lt;strong&gt;when it publishes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publish Immediately&lt;/strong&gt; (default): fires when the &lt;code&gt;publish()&lt;/code&gt; call executes, &lt;strong&gt;regardless of whether the transaction commits.&lt;/strong&gt; A subscriber can receive the event before - or even when - the publishing transaction rolls back. Great for fire-regardless and logging-style events; a footgun if the subscriber expects committed data, because you can emit &lt;code&gt;OrderPlaced&lt;/code&gt; for an order that then never existed. It counts against a separate limit of &lt;strong&gt;150 &lt;code&gt;EventBus.publish()&lt;/code&gt; calls per transaction&lt;/strong&gt; (&lt;code&gt;Limits.getPublishImmediateDML()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish After Commit&lt;/strong&gt;: publishes &lt;strong&gt;only after the transaction commits successfully&lt;/strong&gt;, not at all if it fails. Use it when the subscriber will go query the data you just wrote (which, given thin events, is most of the time on-platform). It counts as &lt;strong&gt;one DML statement&lt;/strong&gt; each (&lt;code&gt;Limits.getDMLStatements()&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction interacts directly with §7: thin notification events usually want &lt;strong&gt;Publish After Commit&lt;/strong&gt;, because the subscriber's whole plan is to re-query, and re-querying a rolled-back record is a bug. If you're using a generic outbound envelope event to bridge to external systems, &lt;code&gt;PublishAfterCommit&lt;/code&gt; is the right default for exactly this reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  A successful publish is a successful &lt;em&gt;enqueue&lt;/em&gt;, not a delivery
&lt;/h3&gt;

&lt;p&gt;High-volume events (the default for new events since API v45.0) publish &lt;strong&gt;asynchronously&lt;/strong&gt;. After &lt;code&gt;publish()&lt;/code&gt; returns success, the request is queued; the actual publish happens when resources free up, and in rare cases the async publish can fail &lt;em&gt;after&lt;/em&gt; your synchronous call already returned "success." If you need certainty of the final result, use &lt;strong&gt;Apex Publish Callbacks&lt;/strong&gt;. Don't treat a green &lt;code&gt;publish()&lt;/code&gt; as proof of delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subscribers, and who pays the delivery bill
&lt;/h3&gt;

&lt;p&gt;You get a genuinely broad set of subscriber types: &lt;strong&gt;Apex triggers&lt;/strong&gt;, &lt;strong&gt;Flows/processes&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;empApi&lt;/code&gt;&lt;/strong&gt; (an LWC CometD client, for live UI), &lt;strong&gt;Pub/Sub API&lt;/strong&gt; (gRPC over HTTP/2, the modern external path), and &lt;strong&gt;CometD&lt;/strong&gt; (legacy). Worth knowing: Apex triggers, processes, and flows &lt;strong&gt;don't&lt;/strong&gt; count against the event delivery allocation - only CometD-style clients do, and for &lt;code&gt;empApi&lt;/code&gt; it's metered &lt;em&gt;per channel, per browser session&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Durability - the bus is not an event store
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retention:&lt;/strong&gt; high-volume events live in the bus for &lt;strong&gt;72 hours&lt;/strong&gt;; the older standard-volume events (definable only before Spring '19, now being retired) for &lt;strong&gt;24 hours&lt;/strong&gt;. Then purged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max message size: 1 MB.&lt;/strong&gt; Fat events still have a ceiling - another reason the fat ones belong at the boundary, carrying a business fact, not a kitchen sink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No SOQL/SOSL on events&lt;/strong&gt;, no reports, no list views. If you need to &lt;em&gt;see&lt;/em&gt; that an event fired, you persist a trace yourself. Events are shouted, not stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the deepest reason §7's projections live &lt;em&gt;downstream&lt;/em&gt;: the platform deliberately doesn't durably store events, so your long-term, replayable, queryable copy belongs on a real log (Kafka/Redpanda) that is built for retention - outside Salesforce.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling out: it's broadcast, not consumer groups
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The intuitive Kafka model: add consumers, partitions distribute, each message processed once - &lt;strong&gt;does not apply to external Salesforce subscribers.&lt;/strong&gt; Per Salesforce's own docs, if you start multiple Pub/Sub API subscriptions for the same org and topic, each stream is independent and &lt;strong&gt;the same events are delivered to every subscriber.&lt;/strong&gt; Scale a gRPC connector from one pod to three and you get every event three times, not a third each. The burden is &lt;strong&gt;deduplication&lt;/strong&gt; (on the &lt;code&gt;EventUuid&lt;/code&gt; / &lt;code&gt;id&lt;/code&gt; field), not partition assignment. To actually parallelise, consume once and fan the work onto a real log with real consumer groups.&lt;/li&gt;
&lt;li&gt;The one place you get competing-consumer semantics is &lt;strong&gt;on-platform&lt;/strong&gt;: Apex &lt;strong&gt;parallel subscribers&lt;/strong&gt;, where one trigger runs as multiple concurrent instances and Salesforce assigns each event to exactly one, partitioned by a key (default: a hash of &lt;code&gt;EventUUID&lt;/code&gt;, which randomises, and therefore does &lt;strong&gt;not&lt;/strong&gt; preserve order). Need ordering within an entity? Set the partition key to that entity's id: ordered within a key, parallel across keys, exactly like a Kafka partition key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Errors, retries, and the replay asymmetry
&lt;/h3&gt;

&lt;p&gt;Inside an Apex subscriber you get a real retry model: throw &lt;strong&gt;&lt;code&gt;EventBus.RetryableException&lt;/code&gt;&lt;/strong&gt; and the batch is resent after an increasing delay, in original &lt;code&gt;ReplayId&lt;/code&gt; order, up to &lt;strong&gt;10 total runs&lt;/strong&gt; (initial + 9 retries) before the subscription moves to an error state. Read the attempt count via &lt;code&gt;EventBus.TriggerContext.currentContext().retries&lt;/code&gt; so you cap your own retries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;OrderPlacedTrigger&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;OrderPlaced__e&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&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="n"&gt;FulfillmentService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Trigger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;New&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="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;TriggerContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;EventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RetryableException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;OrderPlaced handler exhausted retries'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&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;For partially-processed batches, checkpoint with &lt;code&gt;EventBus.TriggerContext.currentContext().setResumeCheckpoint(replayId)&lt;/code&gt; so a retry resumes after the last success instead of redoing it - important because uncatchable &lt;strong&gt;limit exceptions&lt;/strong&gt; (DML/SOQL governor limits) can't be caught, and an un-checkpointed batch's unprocessed events aren't redelivered.&lt;/p&gt;

&lt;p&gt;The asymmetry to remember: &lt;strong&gt;there is no Apex API to replay an arbitrary historical event by &lt;code&gt;ReplayId&lt;/code&gt;.&lt;/strong&gt; From Apex you can resume a &lt;em&gt;suspended&lt;/em&gt; subscription (from earliest, or "from tip" to skip poison events), checkpoint within a batch, or publish a &lt;em&gt;new&lt;/em&gt; event - but arbitrary replay-by-id within the retention window is an &lt;strong&gt;external&lt;/strong&gt;-subscriber capability (Pub/Sub API and CometD let you subscribe from Earliest, Latest, or a custom &lt;code&gt;ReplayId&lt;/code&gt;). Once again: durable replay lives downstream, not in Apex.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. The one thing the integrated platform gives you that Kafka doesn't
&lt;/h2&gt;

&lt;p&gt;For all the constraints, Platform Events have a genuinely lovely property a raw Kafka setup lacks: &lt;strong&gt;one fact can drive backend logic and live UI at the same time, with almost no extra plumbing.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: an Apex trigger on the &lt;code&gt;__e&lt;/code&gt; reacts and runs domain logic.&lt;/li&gt;
&lt;li&gt;Frontend: an LWC subscribes to the same channel via &lt;code&gt;empApi&lt;/code&gt; and updates in real time.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onError&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;lightning/empApi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;connectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/event/OrderPlaced__e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&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="s1"&gt;empApi error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;disconnectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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;A single &lt;code&gt;OrderPlaced__e&lt;/code&gt;, published once, can drive backend logic (Apex), refresh an ops team's live dashboard (LWC), &lt;em&gt;and&lt;/em&gt; hit Kafka for the rest of the company (Pub/Sub API bridge). This is the producer-knows-no-consumers property from §3 made vivid: three subscribers across two completely different runtimes - Apex and JavaScript - and the publisher knows about none of them. Add a fourth and the publisher still doesn't change. One fact, N consumers, zero coupling between them - provided the fact is a fact and not &lt;code&gt;RefreshOrdersDashboard&lt;/code&gt; in disguise. Operationally, remember &lt;code&gt;empApi&lt;/code&gt; is a CometD client and meters per browser session, so a thousand open dashboards is a thousand subscriptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. The contras, stated plainly
&lt;/h2&gt;

&lt;p&gt;This is not friction-free, and the JVM-world defaults don't all survive contact with the platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bus is not a store.&lt;/strong&gt; 72h / 24h retention, no SOQL, no native audit. Durability is your job - downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No arbitrary replay from Apex.&lt;/strong&gt; Suspend/resume and checkpoints only; true replay-by-id is external.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fan-out, not consumer groups, for external subscribers.&lt;/strong&gt; Scaling out means deduping, not load balancing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Governor and async limits constrain orchestration.&lt;/strong&gt; Local-engine sagas don't port one-to-one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 MB cap and limited field types.&lt;/strong&gt; Fat events have a ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish-immediate footgun.&lt;/strong&gt; Events can reference data that never committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability tax.&lt;/strong&gt; You trade the easy end-to-end call stack for emergent flows. Eventual consistency becomes something you engineer - idempotent handlers, lag monitoring, reconciliation for any fire-and-forget fan-out with an SLA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger action discipline.&lt;/strong&gt; Single-responsibility actions only pay off if the team enforces it. Without governance on &lt;code&gt;Trigger_Action__mdt&lt;/code&gt;, you end up with the same chaos as the old shared handler class, just in metadata form.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Borders cost discipline. Bleed costs velocity. Pick your bill.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Finding the borders: event storming
&lt;/h2&gt;

&lt;p&gt;Everything so far assumes you already know where the boundaries are and which facts matter. In a mature org you usually don't - the knowledge is scattered across the heads of admins, developers, and the business people who actually understand the process. &lt;strong&gt;Event storming&lt;/strong&gt; is the workshop technique for getting it out of those heads and onto a wall before you write a line of Apex.&lt;/p&gt;

&lt;p&gt;The mechanics are deliberately low-tech: a long roll of paper (or a digital whiteboard) and sticky notes in a few colours. You get the people who know the business in one room and reconstruct what actually happens, using a fixed vocabulary of colours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orange: domain events (facts).&lt;/strong&gt; Something that happened, past tense: &lt;em&gt;Order Placed&lt;/em&gt;, &lt;em&gt;Shipment Dispatched&lt;/em&gt;, &lt;em&gt;Invoice Issued&lt;/em&gt;. You brainstorm these first and arrange them left-to-right in time. This is the raw material for your Platform Events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blue: commands.&lt;/strong&gt; The action or decision that &lt;em&gt;caused&lt;/em&gt; an event: &lt;em&gt;Place Order&lt;/em&gt;, &lt;em&gt;Dispatch Shipment&lt;/em&gt;. Note the one-to-one feel - a command, when it succeeds, produces an event. This is exactly the fact-vs-command distinction from §8, discovered on the wall before it's encoded in a name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yellow: actors / roles.&lt;/strong&gt; Who issues the command - a sales rep, a warehouse operator, a system integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pink: external systems.&lt;/strong&gt; Anything outside the org that emits or receives - the payment gateway, the carrier's shipping API. These pink notes are where your &lt;em&gt;fat boundary events&lt;/em&gt; from §7 will live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purple: policies.&lt;/strong&gt; "Whenever &lt;em&gt;this&lt;/em&gt; event happens, &lt;em&gt;that&lt;/em&gt; command should fire." Purple notes are the reactive glue - and crucially, &lt;strong&gt;they reveal cross-domain reach-ins.&lt;/strong&gt; A policy that says "whenever Order Placed, create the Shipment" is the order-handoff anti-pattern from §5, caught on a sticky note.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The borders emerge on their own. As the orange events line up across the timeline, you'll see them cluster - a run of sales facts, then a run of fulfillment facts, then finance. &lt;strong&gt;The seams between the clusters are your bounded contexts.&lt;/strong&gt; You didn't impose them top-down; the language did. Where the business stops using one set of nouns and starts using another is exactly where one domain ends and the next begins. Draw a vertical line there and you've found a border to defend with an event.&lt;/p&gt;

&lt;p&gt;What event storming gives you, concretely, before any migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A candidate list of Platform Events&lt;/strong&gt; - every orange note is a fact you might publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bounded contexts&lt;/strong&gt; - the clusters become your &lt;code&gt;domain-*&lt;/code&gt; packages from §10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reach-ins to kill&lt;/strong&gt; - every purple policy that crosses a cluster line is a place a domain is reaching into another's territory. Those are your refactor targets, prioritised by how much pain they cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shared language&lt;/strong&gt; - the admins and developers now describe the system the same way, which is half the battle in cutting domain bleed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don't need a two-day formal workshop to start. Even a 90-minute "big picture" storm of one painful flow - the order handoff, say - will surface more hidden coupling than a week of reading code, because it pulls in the people who know what the system is &lt;em&gt;supposed&lt;/em&gt; to do, not just what it currently does.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. How to get there without a rewrite
&lt;/h2&gt;

&lt;p&gt;You don't refactor a twelve-year-old org in one go. Use &lt;strong&gt;branching by abstraction&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep internals as they are.&lt;/li&gt;
&lt;li&gt;Define a clean set of &lt;strong&gt;facts&lt;/strong&gt; at each context's edge and publish them - thin on-platform, and bridged (e.g. via Redpanda Connect) to a real log as fat boundary facts for the wider company.&lt;/li&gt;
&lt;li&gt;Move downstream consumers onto that durable, properly-partitioned stream instead of subscribing to raw Platform Events.&lt;/li&gt;
&lt;li&gt;Migrate trigger logic from shared handler classes to Trigger Actions, one object at a time - the metadata-driven composition means you can wire up new actions without touching the trigger file.&lt;/li&gt;
&lt;li&gt;Peel domains into their own packages behind those stable contracts, on your own schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The facts are the contract. Everything behind them is yours to modernise; everything in front sees clean borders - even while the engine room is mid-renovation.&lt;/p&gt;

&lt;p&gt;The hardest part isn't the platform mechanics - it's the cultural shift. Publishing a fact and trusting other domains to react is slower in the short term than reaching in and doing it yourself. The order-handoff anti-pattern from §5 exists because it &lt;em&gt;works&lt;/em&gt; - it's just not &lt;em&gt;owned&lt;/em&gt;. Inverting it requires accepting that a domain's records are that domain's responsibility, even when another domain is the trigger for creating them. That discipline is what makes independent deployability real.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat domain boundaries as if they were network boundaries.&lt;/strong&gt; Distributed systems engineers get this for free; on Salesforce you have to enforce it yourself. The discipline is identical - only the mechanism differs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shared database is your advantage, not your problem.&lt;/strong&gt; Distributed systems copy data across service boundaries because they have no choice. You can re-query. Use it - keep events thin on-platform, fat only at the external boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There are two kinds of event-driven Salesforce.&lt;/strong&gt; Triggers are synchronous events; Platform Events are async. Both require the same ownership discipline; the difference is timing and failure boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger ownership is an architecture decision.&lt;/strong&gt; A trigger owned by everyone is owned by nobody. Trigger Actions with metadata-driven composition give you single responsibility, independent deploy, and explicit ordering - the same guarantees a service boundary gives a distributed system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain bleed is the core failure mode&lt;/strong&gt; - one domain reaching into another's objects, queries, and automation. Events are the membrane: facts cross boundaries, reach-ins don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts, not commands.&lt;/strong&gt; If you can name the one subscriber meant to handle it, you've written a command. The coupling is still there, just on the bus now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thin inside, fat at the external boundary.&lt;/strong&gt; Inside the org, thin notifications - the data is a query away. Outward, fat self-contained facts. That's the only place a read model projection belongs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the owner of every cross-domain flow on purpose.&lt;/strong&gt; Default to choreography; reach for orchestration when a process must complete end-to-end and someone needs to own whether it did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split your repo into domains.&lt;/strong&gt; Directory structure is the first step toward independent packages. Code isolation is the prerequisite for deployment isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the mechanics cold&lt;/strong&gt; - publish behaviour, async publish + callbacks, retention, broadcast vs parallel subscribers, retry/checkpoint, no-arbitrary-replay-from-Apex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prize is independent deployability.&lt;/strong&gt; Distributed systems get it from service boundaries. You get it from event contracts. Same outcome, different path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The platform is a tool, not an identity. Distributed systems engineers don't get credit for their hard boundaries - the network just forces them. The work on Salesforce is choosing to hold those same boundaries when nothing makes you.&lt;/p&gt;

&lt;p&gt;So don't wait for a rewrite, and don't try to redraw the whole org at once. On your next change, find one place where a domain reaches across a boundary it doesn't own - one trigger, one cross-domain write, one query reaching three objects deep - and replace just that reach-in with a published fact. Draw one border. That single event is the first place your org starts behaving like the distributed system it always secretly was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;p&gt;This article borrows vocabulary from distributed systems and domain-driven design. If you came up through the admin/declarative side of Salesforce, here's the plain-English version of each term, in roughly the order it appears.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain / subdomain.&lt;/strong&gt; A cohesive area of the business with its own data and rules - Sales, Fulfillment, Finance. On Salesforce, a domain is the set of objects, automation, and code one team owns. The opposite of "one big shared org where everyone edits everything."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded context.&lt;/strong&gt; The boundary around a domain inside which a word means exactly one thing. "Account" might mean a prospect in Sales and a billing entity in Finance; a bounded context is where you decide which. The seam between two contexts is exactly where you draw a border and put an event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain bleed.&lt;/strong&gt; This article's term for one domain's logic running inside another's territory - querying its objects, firing on its records, or writing its fields. The disease the whole article is about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coupling.&lt;/strong&gt; How much one piece of code depends on another. &lt;em&gt;Tight&lt;/em&gt; coupling means changing A forces you to change (and re-test) B. The goal is &lt;em&gt;loose&lt;/em&gt; coupling - domains that can change independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed monolith.&lt;/strong&gt; A system split into pieces that still have to deploy together because they're too entangled - all the cost of being distributed, none of the independence. The "non-distributed monolith" is the Salesforce mirror image: one org, but the same entanglement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fact vs command.&lt;/strong&gt; A &lt;strong&gt;fact&lt;/strong&gt; (or domain event) states something that already happened, past tense: &lt;em&gt;OrderPlaced&lt;/em&gt;. A &lt;strong&gt;command&lt;/strong&gt; instructs a specific handler to do something: &lt;em&gt;CreateShipment&lt;/em&gt;. Both can ride on a Platform Event - the difference is naming and intent (see §8).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Producer / consumer (publish / subscribe).&lt;/strong&gt; The &lt;strong&gt;producer&lt;/strong&gt; publishes a message and doesn't know who's listening. &lt;strong&gt;Consumers&lt;/strong&gt; subscribe to declare their own interest. This is what "the producer doesn't know its consumers" means - the dependency points from consumer to event, not the other way around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event notification (thin) vs event-carried state transfer (fat).&lt;/strong&gt; A &lt;strong&gt;thin&lt;/strong&gt; event carries just an id and what changed; the subscriber re-queries for details. A &lt;strong&gt;fat&lt;/strong&gt; event carries the whole payload so the subscriber never has to call back. Thin inside the org, fat at the external boundary (see §7).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projection / read model.&lt;/strong&gt; A pre-built copy of data, shaped for fast reading, kept in sync by listening to events. On-platform you almost never need one (you can just SOQL the source). Downstream of Salesforce - in Kafka, a warehouse - you build them because re-querying across the boundary is expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CQRS (Command Query Responsibility Segregation).&lt;/strong&gt; The pattern of separating the write side (commands) from the read side (queries/projections). Mentioned mainly to say: it's a downstream concern, not something you build inside the org.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>salesforce</category>
      <category>architecture</category>
      <category>eventdriven</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Salesforce Debug Logs suck and how I fixed them</title>
      <dc:creator>René Knierim</dc:creator>
      <pubDate>Sun, 21 Jun 2026 09:11:34 +0000</pubDate>
      <link>https://dev.to/oblitus/why-salesforce-debug-logs-suck-and-how-i-fixed-them-3ilh</link>
      <guid>https://dev.to/oblitus/why-salesforce-debug-logs-suck-and-how-i-fixed-them-3ilh</guid>
      <description>&lt;p&gt;If you are a Salesforce Developer, you know the drill: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Develop&lt;/li&gt;
&lt;li&gt;Deploy&lt;/li&gt;
&lt;li&gt;Enable Debug Logs for a User or open Developer Console&lt;/li&gt;
&lt;li&gt;Test&lt;/li&gt;
&lt;li&gt;Something breaks -&amp;gt; inspect the Debug log&lt;/li&gt;
&lt;li&gt;Download the Debug log and use it with the Apex Log Replayer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For me, this always felt a bit icky. Why do I have to go through a full release cycle, interact with the org, and find and download the log to even get the most basic information about what happened in the backend?&lt;/p&gt;

&lt;p&gt;And that's one of the keywords here. Something &lt;strong&gt;already happened&lt;/strong&gt;. I created an error on the Org that I might even share with other people and developers. Let alone if it is a production org that faces this issue. Every fix I deploy will affect other people and needs to go through a full release cycle, just to check again retrospectively if it is fixed.&lt;/p&gt;

&lt;p&gt;Apex again is the odd one out in the world of programming languages. Its historic strength to develop directly against the platform turns out to be a big time consumer on complex orgs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging at Runtime
&lt;/h2&gt;

&lt;p&gt;Imagine for a second you are not a Salesforce developer, but you develop in Java, Go, Kotlin, Swift or any other widely used programming language. There is no Org, no server you deploy to, nothing that generates Debug Logs for you during your dev cycle to debug issues. &lt;br&gt;
Instead, you can execute those languages on your machine. And because they execute on the machine, your IDE enables you to set breakpoints and inspect every single variable. All that without going through a release cycle to a Sandbox or a Scratch org. And all that without cluttering your machine with tons and tons of old debug logs that you forgot to delete.&lt;br&gt;
If this is something you have dealt with in the past and would like to experience the same comfort as developers in other languages, having a look into &lt;a href="https://testnimbus.dev" rel="noopener noreferrer"&gt;Nimbus&lt;/a&gt; might be worth your time&lt;br&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%2F3gep8zey3mhf1izixkuv.png" 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%2F3gep8zey3mhf1izixkuv.png" alt=" " width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nimbus is a full local Apex Runtime and Debugger. It runs your Apex code against an embedded database as if you were on a Salesforce org, with the only difference that you don't need a Sandbox or a Scratch Org to verify what you have built&lt;/p&gt;

&lt;p&gt;Set up Nimbus in less than a minute: &lt;a href="https://github.com/nimbus-solution/nimbus" rel="noopener noreferrer"&gt;https://github.com/nimbus-solution/nimbus&lt;/a&gt; and go Pro for free&lt;/p&gt;

&lt;p&gt;Get the &lt;a href="https://marketplace.visualstudio.com/items?itemName=NimbusSolutions.testnimbus" rel="noopener noreferrer"&gt;VSCode&lt;/a&gt; and &lt;a href="https://plugins.jetbrains.com/plugin/32360-nimbus--local-apex-runtime/versions/stable/1083152" rel="noopener noreferrer"&gt;IntelliJ&lt;/a&gt; plugins and improve you development cycle today!&lt;/p&gt;

&lt;p&gt;Found something that doesn't work as expected? Leave us an &lt;a href="https://github.com/nimbus-solution/nimbus/issues" rel="noopener noreferrer"&gt;issue in GitHub &lt;/a&gt;or reach out to us directly in our &lt;a href="https://join.slack.com/t/nimbuslocalap-tj23081/shared_invite/zt-3x7fxoo38-AAgO9QHP7if53JPunBxOPQ" rel="noopener noreferrer"&gt;Slack community&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>productivity</category>
      <category>tdd</category>
      <category>apex</category>
    </item>
    <item>
      <title>Trunk-Based Development Working for Salesforce Without a Single Org</title>
      <dc:creator>René Knierim</dc:creator>
      <pubDate>Sat, 20 Jun 2026 23:54:18 +0000</pubDate>
      <link>https://dev.to/oblitus/trunk-based-development-working-for-salesforce-without-a-single-org-531f</link>
      <guid>https://dev.to/oblitus/trunk-based-development-working-for-salesforce-without-a-single-org-531f</guid>
      <description>&lt;p&gt;I've wanted easy trunk-based development for Salesforce for years.&lt;/p&gt;

&lt;p&gt;Short-lived branches, frequent merges, small pull requests, and CI fast enough that developers aren't afraid to commit. The same practices that engineering teams use everywhere else.&lt;/p&gt;

&lt;p&gt;Every time I tried to make it work, I hit the same wall: Apex tests require an org.&lt;/p&gt;

&lt;p&gt;That single dependency turns every validation run into an infrastructure problem. Before a test can execute, you need authentication, environment provisioning, metadata deployment, test execution, and cleanup. The result is feedback loops measured in minutes instead of seconds.&lt;/p&gt;

&lt;p&gt;I got tired of waiting and built Nimbus, a local Apex runtime that executes Apex tests without an org.&lt;/p&gt;

&lt;p&gt;This is what I learned while trying to make trunk-based development actually work for Salesforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why trunk-based development is hard in Salesforce
&lt;/h2&gt;

&lt;p&gt;Trunk-based development depends on fast feedback. If validation takes seconds, developers make smaller changes, merge more frequently, and keep branches short-lived. If validation takes fifteen minutes, behavior changes. Pull requests get larger, unrelated work gets batched together, and validation stops happening continuously because validation itself becomes expensive.&lt;/p&gt;

&lt;p&gt;Salesforce has always had a structural challenge here because Apex only runs inside Salesforce.&lt;/p&gt;

&lt;p&gt;A typical validation pipeline looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sf org login jwt
sf org create scratch
sf project deploy start
sf apex run &lt;span class="nb"&gt;test
&lt;/span&gt;sf org delete scratch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing inherently wrong with these steps. The problem is that most of them have nothing to do with testing. They're infrastructure management. The actual validation of business logic is only one part of the process.&lt;/p&gt;

&lt;p&gt;The longer I worked with Salesforce CI, the more obvious it became that the bottleneck wasn't Apex itself. The bottleneck was everything required to create an environment where Apex could run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solutions I tried first
&lt;/h2&gt;

&lt;p&gt;Before building a local runtime, I tried solving the problem the same way most Salesforce teams do.&lt;/p&gt;

&lt;p&gt;If Apex tests require an org, the obvious answer is to make org creation faster. Scratch org pools, sandbox pools, pre-provisioned environments, and reusable test orgs all sound reasonable on paper.&lt;/p&gt;

&lt;p&gt;In practice, I found myself building infrastructure whose only purpose was making test execution possible.&lt;/p&gt;

&lt;p&gt;A scratch org pool still needs a Dev Hub. It still needs authentication. It still needs lifecycle management. Someone has to create orgs, monitor capacity, replenish exhausted pools, rotate certificates, and investigate failures when something inevitably goes wrong.&lt;/p&gt;

&lt;p&gt;Sandbox pools introduce a different set of problems. Now you're dealing with shared state, refresh cycles, environment drift, and coordination between teams. The CI pipeline becomes responsible not only for validating code, but also for managing a fleet of Salesforce environments.&lt;/p&gt;

&lt;p&gt;I spent a lot of time trying to make these approaches work. They helped, but they never solved the underlying problem.&lt;/p&gt;

&lt;p&gt;The dependency was still there.&lt;/p&gt;

&lt;p&gt;Whether the org was created on demand, pulled from a pool, or shared across teams, every test run still depended on Salesforce infrastructure existing before validation could begin.&lt;/p&gt;

&lt;p&gt;Eventually I realized I was optimizing around the dependency instead of removing it.&lt;/p&gt;

&lt;p&gt;If trunk-based development was the goal, I didn't need faster orgs.&lt;/p&gt;

&lt;p&gt;I needed no orgs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dependency I wanted to remove
&lt;/h2&gt;

&lt;p&gt;When I started building Nimbus, the goal wasn't to create a faster CI pipeline, but the goal was simpler.&lt;/p&gt;

&lt;p&gt;I wanted to run Apex tests without needing Salesforce infrastructure.&lt;/p&gt;

&lt;p&gt;That meant solving many of the same problems I described in a previous article: parsing Apex, executing Apex code locally, supporting DML and SOQL, reproducing trigger behavior, implementing test isolation, and matching Salesforce runtime semantics.&lt;/p&gt;

&lt;p&gt;A local runtime that behaves differently from Salesforce is worse than useless. If local tests pass and org tests fail, developers stop trusting the tool. Every design decision ultimately came down to one question:&lt;/p&gt;

&lt;p&gt;"Does this behave the same way Salesforce behaves?"&lt;/p&gt;

&lt;p&gt;Because if the answer was no, nothing else mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when Apex runs locally
&lt;/h2&gt;

&lt;p&gt;Once tests no longer require an org, something interesting happens.&lt;/p&gt;

&lt;p&gt;Large parts of the pipeline disappear.&lt;/p&gt;

&lt;p&gt;The GitHub Actions workflow for one of my demo projects looks like this:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apex-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Install Nimbus&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -fsSL https://install.testnimbus.dev | sh&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;Run Apex tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;nimbus test "*" \&lt;/span&gt;
            &lt;span class="s"&gt;--coverage --coverage-report coverage.xml \&lt;/span&gt;
            &lt;span class="s"&gt;--results-xml results.xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Dev Hub.&lt;/p&gt;

&lt;p&gt;No scratch org.&lt;/p&gt;

&lt;p&gt;No source deployment.&lt;/p&gt;

&lt;p&gt;No org cleanup.&lt;/p&gt;

&lt;p&gt;Nimbus produces JUnit and Cobertura reports, so existing tooling still works. Coverage gates, pull request checks, SonarQube analysis, and test reporting continue operating exactly as before.&lt;/p&gt;

&lt;p&gt;The difference is that the tests execute directly against the source code in the repository rather than a deployed copy inside an org.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repository becomes the source of truth
&lt;/h2&gt;

&lt;p&gt;This ended up being one of the most important shifts.&lt;/p&gt;

&lt;p&gt;Traditional Salesforce development often treats the org as the thing being tested. Code is deployed into an environment, and that environment decides whether the code works.&lt;/p&gt;

&lt;p&gt;In most software ecosystems, the repository itself is the source of truth. The code in Git is the thing being compiled, tested, and validated.&lt;/p&gt;

&lt;p&gt;A local runtime pushes Salesforce closer to that model.&lt;/p&gt;

&lt;p&gt;Nimbus reads the SFDX project directly from disk and executes the code that's actually committed to source control. There is no deployment step sitting between the code and the test run.&lt;/p&gt;

&lt;p&gt;The org stops being a development dependency and starts becoming a deployment target.&lt;/p&gt;

&lt;p&gt;That sounds like a subtle distinction, but it changes how the workflow feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the inner loop
&lt;/h2&gt;

&lt;p&gt;The biggest difference wasn't CI.&lt;/p&gt;

&lt;p&gt;It was how I wrote code.&lt;/p&gt;

&lt;p&gt;Before, a typical iteration looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit Apex&lt;/li&gt;
&lt;li&gt;Deploy to an org&lt;/li&gt;
&lt;li&gt;Wait&lt;/li&gt;
&lt;li&gt;Run tests&lt;/li&gt;
&lt;li&gt;Wait&lt;/li&gt;
&lt;li&gt;Fix the bug&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a local runtime, the loop becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit Apex&lt;/li&gt;
&lt;li&gt;Run tests&lt;/li&gt;
&lt;li&gt;Fix the bug&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The difference isn't just speed.&lt;/p&gt;

&lt;p&gt;Developer behavior changes when feedback becomes cheap.&lt;/p&gt;

&lt;p&gt;You run tests more often. You refactor more confidently. You make smaller changes and catch mistakes earlier. Validation stops being something you do at the end of a feature because validation is no longer expensive.&lt;/p&gt;

&lt;p&gt;The workflow starts looking much closer to what developers in other ecosystems have had for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in CI
&lt;/h2&gt;

&lt;p&gt;The CI improvements were almost a side effect of solving the local testing problem.&lt;/p&gt;

&lt;p&gt;One of the BerlinBrew demo runs currently executes 157 tests with over 93% coverage. The GitHub Actions workflow completes in about 30 seconds, and the actual Apex execution takes roughly one second. Most of the remaining time comes from GitHub Actions startup overhead.&lt;/p&gt;

&lt;p&gt;More importantly, the pipeline becomes dramatically simpler.&lt;/p&gt;

&lt;p&gt;There are no org credentials to manage. There are no scratch org limits to monitor. There are no deployment failures unrelated to the code being tested.&lt;/p&gt;

&lt;p&gt;The build focuses on validating behavior.&lt;/p&gt;

&lt;p&gt;That's what CI should be doing in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;A few things surprised me during this journey.&lt;/p&gt;

&lt;p&gt;The first is that most Salesforce CI time is not spent running tests. It's spent preparing an environment capable of running tests.&lt;/p&gt;

&lt;p&gt;The second is that developers adapt their workflow to the speed of feedback. Fast feedback encourages small pull requests. Slow feedback encourages batching.&lt;/p&gt;

&lt;p&gt;The third is that many of the challenges people associate with trunk-based development in Salesforce aren't really process problems.&lt;/p&gt;

&lt;p&gt;Most teams already understand the benefits of smaller changes and more frequent merges. The difficult part is making validation fast enough that those practices become natural.&lt;/p&gt;

&lt;p&gt;And finally, I learned that scratch orgs are often doing two completely different jobs at once.&lt;/p&gt;

&lt;p&gt;They're acting as deployment targets and test environments.&lt;/p&gt;

&lt;p&gt;Once those responsibilities are separated, a lot of complexity disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this replace Salesforce?
&lt;/h2&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;There are still platform features that require a real org. Nimbus is not trying to replace Salesforce.&lt;/p&gt;

&lt;p&gt;It's trying to remove Salesforce from the parts of the development loop where Salesforce isn't adding value.&lt;/p&gt;

&lt;p&gt;For many Apex tests, that turns out to be a much larger portion of the workflow than I originally expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Nimbus runs directly against existing SFDX projects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://install.testnimbus.dev | sh
nimbus &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've ever wanted Salesforce development to feel more like every other modern software stack, this is the closest thing you will find.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>devops</category>
      <category>architecture</category>
      <category>apex</category>
    </item>
    <item>
      <title>I Built a Local Apex Runtime - Nimbus</title>
      <dc:creator>René Knierim</dc:creator>
      <pubDate>Tue, 16 Jun 2026 20:22:54 +0000</pubDate>
      <link>https://dev.to/oblitus/i-built-a-local-apex-runtime-nimbus-596o</link>
      <guid>https://dev.to/oblitus/i-built-a-local-apex-runtime-nimbus-596o</guid>
      <description>&lt;p&gt;Every Salesforce developer knows the wait. You change three lines of Apex, run your tests, and then go make coffee, because deploying to a scratch org and running the suite takes minutes. There is nothing useful you can do in that gap except lose your train of thought.&lt;/p&gt;

&lt;p&gt;I got tired of that gap, so I built &lt;a href="https://testnimbus.dev" rel="noopener noreferrer"&gt;Nimbus&lt;/a&gt;. It runs Apex tests locally, with no org and no Docker, in about a second.&lt;/p&gt;

&lt;p&gt;This post is about how it works and what surprised me along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with the feedback loop
&lt;/h2&gt;

&lt;p&gt;Apex doesn't run anywhere except Salesforce. That is the whole premise of the platform: your code lives next to the database, the metadata, and the governor limits. It's a feature, not an accident.&lt;/p&gt;

&lt;p&gt;It's also why the inner loop is slow. To run a single test method the usual flow is: push your code to a scratch org or sandbox, wait for the deploy, enqueue the tests, poll for results, then read the failures and start over. Every round trip is bound by the network and the org. On a real project the cycle gets long enough that you stop running tests after every edit, which is exactly when they'd be most useful.&lt;/p&gt;

&lt;p&gt;What I wanted was the thing every other language already has. Save the file and get instant feedback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "local" actually requires
&lt;/h2&gt;

&lt;p&gt;To run an Apex test with no org, you have to reimplement a surprising amount of the platform. You need a lexer and parser for the language, including classes, triggers, and annotations. You need an interpreter that actually executes the AST: control flow, exceptions, collections, static state, the type system. You need a database that behaves enough like Salesforce's to back DML and SOQL. You need a SOQL engine that turns Salesforce's query language into something a real database understands. And you need the platform semantics, which is the part that bites: trigger order of execution, &lt;code&gt;@testSetup&lt;/code&gt;, test isolation, and a long list of quirks.&lt;/p&gt;

&lt;p&gt;That last one is where most of the real work lives, and it's the part you can't shortcut. Apex is full of behavior that only matters once you've been burned by it. I'll get to some of those.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stack
&lt;/h3&gt;

&lt;p&gt;Nimbus is written in Go. The shape of it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Apex source
   │
   ▼
 Lexer  ──►  Parser  ──►  AST
                            │
                            ▼
                      Interpreter  ◄──►  Local PostgreSQL (embedded)
                            │                    ▲
                            ▼                    │
                       SOQL engine ──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of the choices behind it.&lt;/p&gt;

&lt;p&gt;It's a tree-walking interpreter rather than a bytecode VM. Apex test suites aren't tight numeric loops; they're shaped by DML and queries. A tree-walker is much easier to get correct, and correctness is the entire point. If a local runtime gives a different answer than the org, it's worse than useless, because now it's lying to you. So I optimized for matching Salesforce's behavior, not for raw speed.&lt;/p&gt;

&lt;p&gt;It embeds PostgreSQL, with no Docker. An early version reached for a containerized database, and that's a non-starter for a dev tool. Asking developers to run Docker just to execute a unit test is the kind of friction that quietly kills adoption. Nimbus ships a single binary with the database embedded. Nothing to install, nothing to start, nothing to clean up.&lt;/p&gt;

&lt;p&gt;It translates SOQL to SQL on the fly. SOQL looks like SQL but it isn't. Relationship traversal like &lt;code&gt;Account.Owner.Name&lt;/code&gt;, child subqueries like &lt;code&gt;SELECT Id, (SELECT Id FROM Contacts) FROM Account&lt;/code&gt;, polymorphic fields, semi-joins: all of it gets rewritten into PostgreSQL at query time. This is one of the harder corners, and one of the most important to get exactly right, because SOQL shows up in nearly every Apex test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: platform semantics
&lt;/h2&gt;

&lt;p&gt;Writing a parser is a known quantity. The difficulty, and honestly the fun, is in reproducing the parts of Apex that live in the runtime's behavior and not in any document.&lt;/p&gt;

&lt;p&gt;Here are a few that cost me real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trigger order of execution
&lt;/h3&gt;

&lt;p&gt;When you insert a record, Salesforce runs the before triggers, saves, then runs the after triggers, and the context variables (&lt;code&gt;Trigger.new&lt;/code&gt;, &lt;code&gt;Trigger.oldMap&lt;/code&gt;, the boolean flags) have to be correct at each stage. Recursive DML inside a trigger needs to fire the next trigger. Get the ordering subtly wrong and a whole class of tests passes locally but fails on the platform, which is the worst outcome you can have.&lt;/p&gt;

&lt;h3&gt;
  
  
  Map case sensitivity
&lt;/h3&gt;

&lt;p&gt;This one is a good story, and it doesn't have a clean ending. Apex's &lt;code&gt;Map&amp;lt;String, V&amp;gt;&lt;/code&gt; is case sensitive. Put &lt;code&gt;'Foo'&lt;/code&gt; and &lt;code&gt;'foo'&lt;/code&gt; in the same map and you get two entries, and &lt;code&gt;get('FOO')&lt;/code&gt; returns null. That's the platform behavior, confirmed against a live org.&lt;/p&gt;

&lt;p&gt;The wrinkle is that a fair amount of real-world Apex leans, usually without meaning to, on case insensitive lookups, especially code keyed on field API names. And the platform itself is inconsistent about it: the describe maps like &lt;code&gt;Schema.getGlobalDescribe()&lt;/code&gt; and &lt;code&gt;fields.getMap()&lt;/code&gt; actually are case insensitive on the org, even though a plain &lt;code&gt;Map&amp;lt;String, V&amp;gt;&lt;/code&gt; is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Id 15 vs 18 characters
&lt;/h3&gt;

&lt;p&gt;Salesforce Ids come in two forms. The 15-character version is case sensitive, and the 18-character version is case insensitive with a checksum on the end. Code casts and compares them in ways that depend on which form you have. A single decision, like whether &lt;code&gt;(Id) '001...15chars'&lt;/code&gt; expands to 18 characters, pushes dozens of tests in opposite directions depending on the codebase. There's rarely a free answer. You pick the behavior that matches the most real code and you write down the tradeoff.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON serialization key order
&lt;/h3&gt;

&lt;p&gt;Here is one almost nobody thinks about until a test fails on it. When you call &lt;code&gt;JSON.serialize&lt;/code&gt; on a &lt;code&gt;Map&amp;lt;String, V&amp;gt;&lt;/code&gt;, the order the keys come out in is not arbitrary, and it is not insertion order either. On a live org, the keys come out in reverse insertion order. So a test that builds a map and then asserts on the exact serialized string is implicitly depending on that ordering, whether the author knew it or not.&lt;/p&gt;

&lt;p&gt;Nimbus originally emitted keys in plain insertion order, which is the obvious thing to do and the wrong thing to do, and any test asserting on a serialized map string failed against it. The fix was to match the org's reverse-insertion-order behavior, verified across maps of several sizes. As a separate quirk, string concatenation on a map (&lt;code&gt;'' + map&lt;/code&gt;) sorts the keys alphabetically instead, which is a different code path again. None of this is documented. You find it by serializing a map locally, serializing the same map on an org, and diffing the two strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static state and test isolation
&lt;/h3&gt;

&lt;p&gt;Each test method has to run as if nothing else ever happened: fresh static variables, a clean database, and its own transaction that rolls back when the method ends. Data created in &lt;code&gt;@testSetup&lt;/code&gt; has to be visible to every method, but changes one method makes can't leak into the next. Reproducing that isolation model, and the static-reset rules around it, is essential to matching how Salesforce scopes a test run.&lt;/p&gt;

&lt;p&gt;None of this is written down in a tidy spec. You learn it by running large, real, messy codebases and watching what breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually work?
&lt;/h2&gt;

&lt;p&gt;That's the question that matters, and the only honest way to answer it is to point it at real Apex and count.&lt;/p&gt;

&lt;p&gt;I've been running Nimbus against well-known community libraries and full application codebases, the kind with thousands of test methods, heavy trigger frameworks, mocking libraries, and dynamic SOQL. The bar I hold it to is simple: the same pass or fail result you'd get in the org, just faster.&lt;/p&gt;

&lt;p&gt;When a test passes in the org and fails in Nimbus, that's a bug in Nimbus. No exceptions. Each one is a case study: a missing builtin, a semantic mismatch, an edge in the SOQL translator. Tracking those down is how the runtime earns its fidelity, and the compatibility surface is large enough that this is the ongoing work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local-first matters beyond speed
&lt;/h2&gt;

&lt;p&gt;Speed is the headline, but it isn't the only thing you get once Apex runs on your own machine.&lt;/p&gt;

&lt;p&gt;Because Nimbus understands Apex deeply enough to execute it, it can also back a language server. That means diagnostics, code lenses, inlay hints, and semantic tokens driven by the same engine that runs the tests.&lt;/p&gt;

&lt;p&gt;It also makes CI simpler, which is the part I think is genuinely underrated, so it gets its own section below.&lt;/p&gt;

&lt;p&gt;And it makes coverage something you can iterate on. Per-line coverage collected locally and instantly lets you close gaps in a tight loop, instead of deploying just to find out you missed a branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does to your pipeline
&lt;/h2&gt;

&lt;p&gt;Salesforce CI is usually built around an org. A typical pipeline authenticates to the Dev Hub, spins up a scratch org, pushes the source, runs the tests, scrapes the results, and tears the org down. It works, but it's slow, it needs credentials sitting in your secrets, and scratch org limits are a real constraint when a lot of builds run at once.&lt;/p&gt;

&lt;p&gt;If the tests run locally, all of that disappears. The pipeline just fetches a binary and runs it. There's no org to authenticate to, no scratch org to provision, no credentials beyond the Nimbus license key, and nothing to clean up afterward.&lt;/p&gt;

&lt;p&gt;I put together a demo repo called &lt;a href="https://github.com/nimbus-solution/berlinbrew-demo" rel="noopener noreferrer"&gt;BerlinBrew&lt;/a&gt;, a fictional coffee-subscription Salesforce project, to show the whole thing end to end. You can clone it and watch the pipeline run for yourself. The GitHub Actions job is short enough to read in one go. It installs Nimbus, caches the embedded PostgreSQL binaries so cold starts stay fast, and runs the suite:&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="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;Install Nimbus&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;curl -fsSL https://install.testnimbus.dev | sh&lt;/span&gt;
    &lt;span class="s"&gt;nimbus --version&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;Run Apex tests&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NIMBUS_LICENSE_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NIMBUS_LICENSE_KEY }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;nimbus test "*" \&lt;/span&gt;
      &lt;span class="s"&gt;--coverage --coverage-report coverage.xml \&lt;/span&gt;
      &lt;span class="s"&gt;--results-xml results.xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version is pinned in an environment variable so builds are reproducible and you bump it on purpose. The embedded Postgres binaries get cached under &lt;code&gt;~/.nimbus/pg&lt;/code&gt;, keyed on the Nimbus version, so a bump busts the cache automatically.&lt;/p&gt;

&lt;p&gt;The two output files are the part that makes this drop into existing tooling without any glue. &lt;code&gt;results.xml&lt;/code&gt; is JUnit, so any test reporter understands it. In BerlinBrew it's fed to a standard publish-test-results action that posts a check on the pull request. &lt;code&gt;coverage.xml&lt;/code&gt; is Cobertura, so the coverage gate is just a few lines parsing &lt;code&gt;lines-covered&lt;/code&gt; and &lt;code&gt;lines-valid&lt;/code&gt; and failing the build under 75 percent, which is the same threshold Salesforce enforces for a production deploy:&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="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;Enforce coverage threshold&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;covered=$(grep -m1 -oE 'lines-covered="[0-9]+"' coverage.xml | grep -oE '[0-9]+')&lt;/span&gt;
    &lt;span class="s"&gt;valid=$(grep -m1 -oE 'lines-valid="[0-9]+"' coverage.xml | grep -oE '[0-9]+')&lt;/span&gt;
    &lt;span class="s"&gt;# fail the build if covered/valid is under the threshold&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing the demo leans on: a CI profile. Nimbus reads a &lt;code&gt;nimbus.properties&lt;/code&gt; file, and you can scope settings to a profile that only activates in the pipeline. BerlinBrew uses it to turn governor limits from a warning into a hard error and to run tests across four workers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="py"&gt;ci.nimbus.governor.mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;
&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="py"&gt;ci.nimbus.test.parallel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So locally you get lenient, fast feedback, and in CI you get strict governor enforcement that catches the kind of limit problems you'd otherwise only discover on deploy. Same binary, different posture. None of it touches an org.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;A few things I'd tell myself at the start.&lt;/p&gt;

&lt;p&gt;Correctness is the product. A fast runtime that lies is worse than a slow org that doesn't, and almost every design decision bent toward matching the platform's observed behavior.&lt;/p&gt;

&lt;p&gt;The spec is a starting point, not the destination. Where the docs and the runtime disagree, the runtime wins, because people are running code that already works on the runtime.&lt;/p&gt;

&lt;p&gt;Real codebases are the only real test suite. Hand-written sample tests find none of the interesting bugs. Pointing the thing at thousands of community tests finds all of them.&lt;/p&gt;

&lt;p&gt;Friction is fatal for dev tools. "Install Docker first" or "spin up an org" is enough to lose people. One binary and zero setup, or it doesn't matter how clever the internals are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Nimbus runs your existing Apex tests locally, with no org and no Docker, from a single binary. You can grab it and read the full CLI reference at &lt;a href="https://testnimbus.dev" rel="noopener noreferrer"&gt;testnimbus.dev&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# from your Salesforce project root&lt;/span&gt;
nimbus &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you spend your day in the Apex inner loop and you're tired of the gap, point it at your codebase and tell me what breaks. The bugs you find are the roadmap. Docs and install instructions live at &lt;a href="https://testnimbus.dev" rel="noopener noreferrer"&gt;testnimbus.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>testing</category>
      <category>devops</category>
      <category>apex</category>
    </item>
  </channel>
</rss>
