Blog

Design Salesforce like a distributed system

How we design Salesforce: business areas with hard boundaries, domain events as the only contract between them, Kafka where volume and history matter, and custom software where Apex is the wrong tool. What each choice gives the business, and how to test it on a laptop.

Short version

Most orgs grow object by object. Every team can read and write every other team's data, so one change breaks another team's work and every release gets bigger. We design the other way round. We start from the domain events, the things that happen in the business, draw boundaries around the areas that own them, and let areas talk only through those events. Platform Events carry them inside the org, a log such as Kafka carries them between systems, and custom services do the work Salesforce is not built for. The business gets teams that ship independently, integrations that survive change, and growth that adds capacity instead of fighting platform limits. Every contract in it can be tested on a laptop.

How we think differently

The usual way to build on Salesforce starts from the data model: objects, fields, page layouts, then automation per object. Integration comes last, as point-to-point calls between systems. That works for one team and one system. It stops working when several teams and several systems share the org.

We start from the business instead:

  • Events first. We begin with what happens in the business (an order placed, a contract signed, a payment received) and with who owns each of those facts. Objects and screens follow from that.
  • Boundaries like a network. Each business area owns its data and its automation. Other areas do not query it or write to it; they react to its events.
  • Facts, not instructions. An event states what happened, in the business's words and in the past tense. It never tells a particular system what to do.
  • The right place for each job. Salesforce runs the customer processes it is built for. High volumes, long history and heavy processing go to a log such as Kafka and to custom services, connected through the same events.
  • Contracts you can test. Every event is a contract between two areas, and every contract gets a test that runs on every change, not in a sandbox at the end of the sprint.

What it gives the business

  • Teams ship independently. A change inside one area does not break another, so releases get smaller and several teams can work at the same time.
  • Integrations survive change. Systems agree on events, not on each other's fields, so either side can rebuild its internals without breaking the other.
  • New systems join without rewiring. A new consumer subscribes to events that already exist. Nothing that publishes them has to change.
  • Growth adds capacity. Bursts queue in the log and are processed at the pace each consumer can take, and the services doing the heavy work scale out on their own, instead of the org failing on its limits.
  • History you can replay. A durable log lets a new system catch up from the beginning, and a failed one resume from where it stopped.
  • Fewer surprises in production. The cross-area paths that usually break after a release are checked on every change.

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.

Domain events are the design

A domain event is a fact the business cares about, named the way the business names it: OrderPlaced__e, ContractSigned__e, PaymentReceived__e. It is not a field change and not a technical signal. That difference decides who can read the design: a list of domain events is a map of the business that a head of sales and an engineer can both check.

  • Name events in the past tense and in the business's words.
  • Give each event one owner: the area where the fact happens.
  • Treat the schema as a contract. Add fields freely, but never change what an existing field means; a breaking change is a new event.
  • Carry an identifier for the fact itself, so a consumer that receives it twice can tell.

Change Data Capture has its place, for copying records into a warehouse. As a contract between areas it leaks the publisher's data model, so every consumer breaks when that model changes.

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 versus 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, like OrderPlaced__e. It is indifferent to who reacts, or whether anyone does.
  • A command instructs a specific handler what to do, like 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. Nothing is copied, so nothing drifts or goes stale.
  • At external boundaries, publish fat, self-contained facts carrying full business context. A downstream system reading the Pub/Sub API or a Kafka topic 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, which is metadata rather than 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
packages/
├── core/
├── sales/
├── fulfillment/
├── finance/
└── 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. It is also the one workshop where the business and the engineers write the design together, in the business's words.

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.

Between systems: a durable log such as Kafka

Platform Events are the right channel inside the org. Between Salesforce and the rest of your systems they run out of road: messages are kept for 72 hours, every external subscriber receives every message instead of sharing the work, and deliveries to external clients count against the org's event allocations. A log such as Apache Kafka takes that role:

  • Retention you choose. Keep events for days, months or for good, and let a new consumer start from the beginning.
  • Consumer groups. The instances of one service share a topic's partitions, so a consumer scales out by adding instances.
  • Order per key. Events with the same key, an account ID for example, land in the same partition and arrive in order.
  • Replay from any point. A consumer that failed resumes from its last committed offset; one that had a bug can be rewound and run again.

The bridge is small. A service subscribes to the platform events through the Pub/Sub API (or to Change Data Capture, where copying records is the job) and writes them to Kafka. The Pub/Sub API already delivers Avro payloads with a schema ID, so the same schemas can live in the registry the Kafka side uses. In the other direction, a consumer either publishes a platform event for the org to react to, or writes through the Salesforce APIs with upserts on an external ID, so a retried message updates the same record instead of creating a second one.

Plan for duplicates on both sides: a retried publish, a replayed offset and a redelivered event all reach a consumer twice. Keep the event's identifier, or make the write idempotent.

Custom software where Apex is the wrong tool

Apex is a good place for business rules that run inside one transaction on CRM data. Governor limits on CPU time, heap and callouts make it the wrong place for heavy computation, long-running jobs, bulk data processing, and anything that needs a library the platform does not have.

Those parts become services of their own, written in a general-purpose language (we use Go and Java), with their own database where they need one. They subscribe to the same domain events, do their work at their own pace, and report back with events of their own. A pricing engine, a document generator or an enrichment pipeline then scales with its own load and deploys on its own schedule, and the org stays fast for the people working in it.

Customer-facing front ends follow the same rule. A React app outside Salesforce reads and writes through APIs and events instead of reaching into another area's objects.

Built to scale

Scaling an org usually means fighting limits one transaction at a time. In this design the transaction stays small: it records the fact and publishes the event. Everything that can wait runs in a subscriber. Inside the org, a platform event trigger runs in its own transaction with its own limits. Outside it, a Kafka consumer group adds instances when the load grows. More volume becomes a question of capacity, not a rewrite.

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 such as Kafka rather than raw Platform Events.
  3. Migrate shared triggers to Trigger Actions, one action at a time.
  4. Move the work Apex is the wrong tool for into services that consume the same events.
  5. 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 through the Pub/Sub API or a Kafka topic 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 the tests that check its 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 deploy-and-run cycle makes testing anything painfully slow, so people don't.

Test the whole design locally with renmy runtime

renmy runtime is a local Apex runtime. It runs your classes, triggers, SOQL, and DML against an embedded PostgreSQL database that ships inside the binary, with no scratch org, no sandbox, no deploy, and 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. The framework's own test suite is one of the four on the benchmarks page and passes in full, so you can assert a single action's behaviour in isolation and assert the composed order, locally.
  • Platform Events. renmy runtime 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, with 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 bridge to Kafka. renmy serve (Pro) runs a Salesforce-compatible REST API and a gRPC Pub/Sub API on localhost, and passes the platform events your Apex publishes to Pub/Sub subscribers. Point the service that feeds Kafka at it and test the bridge without an org.

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.