Blog

Design Salesforce like a distributed system

Bounded contexts, event-driven architecture, and the Trigger Actions framework — the discipline that network boundaries enforce for free, chosen on purpose. Plus how to test every cross-domain contract locally, with no org in the loop.

Short version

Distributed-systems teams stay decoupled because the network forces them to. Salesforce gives you no such barrier — any team can query any object and fire triggers across domains — so you have to impose the discipline yourself. Draw bounded contexts, make platform events the only sanctioned cross-boundary channel, publish facts not commands, and compose automation with the Trigger Actions framework instead of shared trigger files. The result is independent deployability without a service mesh — and every contract in it is testable on your laptop.

The non-distributed monolith

Distributed systems engineers are disciplined about boundaries because they have no choice. A service cannot reach into a neighbour's database; the network refuses the call. That refusal is the whole point — it turns coupling into a compile-time impossibility rather than a code-review argument.

A Salesforce org has no such barrier. Every team shares one database, one metadata namespace, one automation runtime. Sales can query Fulfillment's objects, fire triggers that write to Finance's records, and encode assumptions about three other domains' data models inside a single SOQL query. Nothing stops it, so it happens. The org quietly becomes a non-distributed monolith: multiple teams committing to the same trigger file, deployment dependencies nobody drew on purpose, and a refactor in one domain breaking another with no warning.

The fix isn't a framework you install. It's the same domain-driven design discipline distributed-systems teams get for free — applied deliberately, because on Salesforce nobody applies it for you.

Treat domain boundaries as network boundaries

The first principle is a decision, not a tool: refuse cross-domain reach-ins as strictly as a service refuses a direct database connection from a neighbour. If Fulfillment needs something from Sales, it does not query Sales' objects and it does not call Sales' Apex. It waits for Sales to announce something, and reacts.

That turns your org into a set of bounded contexts — Sales, Fulfillment, Finance, Support, a shared Core — each owning its objects, its automation, and its transaction. The only thing that crosses a boundary is an event. Everything else is a reach-in, and reach-ins are the coupling you're trying to delete.

Two kinds of event-driven Salesforce

Once events are the only sanctioned channel, there are two ways to run them, and they're for different jobs:

  • Trigger Actions — synchronous, in-transaction composition. Each action is a single-responsibility Apex class with one clear owner, wired into a DML operation through metadata rather than a shared trigger body.
  • Platform Events — asynchronous messaging across boundaries. A domain publishes a fact to the event bus; other domains subscribe. The publisher never names a subscriber.

Use Trigger Actions inside a context to keep one object's automation ordered and owned. Use Platform Events between contexts to keep domains from ever touching each other's code.

The producer–consumer inversion

A direct call points from producer to consumer: Sales knows Fulfillment, imports its types, and depends on it compiling. Add a second consumer and the producer changes again. That's the dependency arrow pub/sub is built to reverse.

With an event bus, the producer names an event, not a consumer. Sales publishes OrderPlaced__e and knows nothing about who listens. You can add a second, third, and tenth subscriber with zero changes to the producer. The dependency arrow now points from each consumer to the event contract — and a contract is a far cheaper thing to keep stable than a class.

Facts, not commands

This is the distinction that decides whether your event-driven org is actually decoupled or just wearing the costume.

  • A fact states what happened, in the past tense — OrderPlaced__e. It is indifferent to who reacts, or whether anyone does.
  • A command instructs a specific handler what to do — CreateShipment__e. It carries a hidden assumption about exactly one subscriber and exactly what that subscriber will do.
The tell

If you can name the one subscriber the event is meant for, you've written a command and disguised coupling as decoupling. A real fact doesn't know who's listening — it just reports that the world changed.

Event design depends on where it's going

Salesforce hands you an advantage true distributed systems don't have: one shared database. That changes how fat your events should be.

  • Inside the org, publish thin notifications — an ID and a timestamp. Subscribers re-query the source with SOQL, because the data is right there. No copying, no drift, no stale payloads.
  • At external boundaries, publish fat, self-contained facts carrying full business context. A downstream system on the Pub/Sub API can't cheaply re-query your org, so the fact has to stand on its own.

The order-handoff anti-pattern

Here's domain bleed in costume. Sales, on order creation, inserts Fulfillment's Shipment__c records directly — it knows the schema, sets the fields, and orchestrates the creation. It looks event-driven if you squint, but Sales is still reaching straight into Fulfillment's model. Change that schema and Sales breaks.

The decoupled version: Sales publishes OrderPlaced__e with just the order ID. Fulfillment subscribes, queries whatever it needs, and creates its own Shipment__c records in its own transaction, under its own validation. Sales never learns that shipments exist. Each domain owns its side of the contract and nothing more.

Compose automation with the Trigger Actions framework

Inside a context, the shared trigger file is the local version of the same disease: three teams editing one AccountTrigger, each deployment blocking the others. The Trigger Actions framework breaks that up. Each unit of behaviour is a single-responsibility class implementing a trigger interface, and the order and activation are declared in Trigger_Action__mdt — metadata, not code.

No more shared trigger body, no more merge conflicts on the one file everything touches, and each action has an owner you can point at. Combined with a repository split by domain, each context becomes a candidate for its own unlocked package:

text
src/
├── domain-core/
├── domain-sales/
├── domain-fulfillment/
├── domain-finance/
└── domain-support/

Find the boundaries with Event Storming

You don't guess bounded contexts; you discover them. In an Event Storming workshop, orange sticky notes (domain events) naturally cluster into contexts. The purple notes — policies — that stretch across two clusters are your reach-ins made visible: every cross-cluster line is a coupling to convert into an event. The map falls out of the wall.

Platform Event mechanics worth knowing

The event bus has sharp edges. A few that decide your design:

  • Publish Immediately fires even if the transaction later rolls back — a footgun when the fact turns out not to be true.
  • Publish After Commit fires only once the transaction commits — the right default when subscribers will re-query, since the data is guaranteed to be there.
  • High-volume events publish asynchronously; success means enqueued, not delivered. Retention is 72 hours, max size 1 MB, and you can't SOQL an event.
  • Apex subscribers get retries — up to 10 attempts via EventBus.RetryableException. External subscribers get broadcast, not consumer-group, semantics, and replay-by-ReplayId is an external-only feature.

Choreography vs orchestration

Default to choreography: facts get published, domains react independently, nobody's in charge. It's the loosest coupling and usually the right one.

Reach for orchestration deliberately — when a process needs end-to-end ownership, strict ordering, or compensation on failure. The sentence to keep: someone has to own the multi-domain flow; pick the owner on purpose. The mistake isn't choosing orchestration. It's ending up with it by accident.

Migrate by branching by abstraction

You don't earn this with an org rewrite. You get there incrementally:

  1. Define clean facts at each context edge — thin on-platform, fat downstream.
  2. Move downstream consumers onto a durable log rather than raw Platform Events.
  3. Migrate shared triggers to Trigger Actions, one action at a time.
  4. Extract domains into independent packages behind the now-stable event contracts.

The core benefit of distributed systems — independent deployability — turns out to be reachable on Salesforce through event contracts and domain discipline, no service mesh required. One published fact can drive a trigger, a live LWC via empApi, and an external system on the Pub/Sub API at once, with the publisher knowing none of them.

Where testing usually kills this

Here's the practical problem with everything above: an event-driven org is only as good as your confidence that the contracts hold. Publish a fact with the wrong field, forget to guard a subscriber against replays, let a Trigger Action fire in the wrong order — and you've reintroduced the coupling you spent a quarter removing, invisibly, until production.

The usual way to check is to deploy the whole thing to a scratch org or sandbox and watch what happens. That's slow enough that most teams stop checking the cross-domain paths and just test each domain in isolation — which is exactly where the coupling bugs hide. The architecture asks you to test more integration surface, and the platform's inner loop makes testing anything painfully slow. So people don't.

Test the whole design locally with Nimbus

Nimbus is a local Apex runtime. It runs your classes, triggers, SOQL, and DML against an embedded PostgreSQL database that ships inside the binary — no scratch org, no sandbox, no deploy, no Docker. Which means the boundaries this whole post is about become things you can actually exercise on a laptop, in milliseconds:

  • Trigger Actions. They're ordinary Apex composed through Trigger_Action__mdt, so Nimbus runs them exactly as the org does — you can assert a single action's behaviour in isolation and assert the composed order, locally.
  • Platform Events. Nimbus executes EventBus.publish and delivers to Apex subscribers, including publish-after-commit and Test.getEventBus().deliver(), so a publish/subscribe contract is a unit test — no waiting on an asynchronous bus you can't see.
  • Cross-domain flows. Fire the DML that publishes OrderPlaced__e, deliver the event, and assert that Fulfillment created its Shipment__c — the exact order-handoff path, end to end, without leaving the editor.

The discipline this article argues for only survives if it's cheap to verify. A local runtime makes the cross-boundary tests fast enough that you actually keep them green — so the decoupling you designed stays decoupled.