In one of my portfolio builds — HanseWatt, a fictional energy retailer that lives in a real Developer Edition org of mine — there is a moment where Salesforce announces that a meter change has been requested, and something outside Salesforce is supposed to pick that announcement up and act on it. Let me be plain about the shape of that build: the far side of that boundary is explicitly simulated. There is no real meter system out there. Nobody is waiting.
That simulation did me a favour. It forced me to be precise about a sentence I used to say carelessly, and that I still hear said carelessly in design reviews: “the event fired, so the work is done.”
It is not done. The event is a seam.
What a seam is
A seam is the place where one system stops and another begins. It is a real boundary, and things behave very differently on either side of it. Inside your transaction you have certainty: the record saved, or it did not. Across the seam you have nothing but a message you sent.
A platform event marks a seam. If you have not met them before, here is the one-paragraph version: a platform event is a special kind of Salesforce record, its API name ending in __e, that you publish rather than store. Subscribers — an Apex trigger, a Flow, an external system holding a subscription — react to it on their own. The publisher never learns who listened and never waits for them. That decoupling is the point of the pattern, and it is genuinely a strength.
But read the strength honestly, because the strength is the limit. An event carries exactly one fact: this happened, here, at this moment. It carries no news at all from the other side.
A published event is a handoff, not a completion. Everything you say to the user afterwards has to respect that difference.
The sentence I had to change
Here is the failure in plain language. Code publishes an event. The screen says “Meter change completed.” The user believes it. And on the far side of the seam nothing has happened yet — perhaps nothing will.
Nobody lied on purpose. Somebody simply wrote a success message about a step they had not observed. I spent twenty years in education before Salesforce, and this is exactly the mistake of sending a letter home with a pupil and telling their parent it was read. You know the letter left. You do not know it arrived.
So the first fix is not code. It is vocabulary. Requested. Submitted. Announced. Queued. Not completed, not updated, not done.
EventBus.publish gives you an answer, and most code throws it away
Now the code, because there is a second failure hiding underneath the first one.
This is the version I used to write:
// What I used to write
EventBus.publish(evt);
return 'Meter change submitted.';
Look at the first line. EventBus.publish returns a result — List<Database.SaveResult> for the list overload, a single Database.SaveResult for one event — and that line quietly discards it. A Database.SaveResult is the same object you get back from Database.insert: it knows whether the operation succeeded and, if it did not, why.
Discarding it and then telling the user the work succeeded is a real failure mode, not a theoretical one. The publish itself can be denied, and if you never look at the result your caller will cheerfully report success on top of a publish that never left the building. The absence of an exception is not proof that anything was published.
So read the result:
List<Database.SaveResult> results = EventBus.publish(events);
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
for (Database.Error err : sr.getErrors()) {
// Log it, surface it — and do NOT tell the user this succeeded.
System.debug(err.getStatusCode() + ' :: ' + err.getMessage());
}
}
}
Notice the discipline in that loop. It does not ask “did an exception happen?” It asks “what does the platform say about each event I handed it?” Those are different questions, and only the second one has a trustworthy answer.
One practical note: with the list overload you get one result per event, in the order you supplied them, so keep the index and the source record together when you log.
Two questions, two answers
I find it helps to hold these apart on purpose, because beginners collapse them constantly:
- Did the announcement leave the building? The
SaveResultanswers this, and it is the only question your publishing transaction can answer. - Did anyone hear it, act on it, and succeed? Nothing in your publishing transaction answers this. Not the return value, not the missing exception, not the fact that it worked yesterday.
Once those two are separate in your head, a lot of confused error handling untangles itself. You stop writing retry logic in the publisher for failures that live on the far side, and you stop treating a clean publish as evidence about a system you never contacted.
If the business really needs completion, build the return path
Sometimes “we announced it” is genuinely enough. But when a human or a downstream process truly needs to know the far side finished, you have to build that half deliberately. There is no free version of it. The usual shapes are simple:
- A status field on the originating record that the far side updates when it is done, so the screen can say requested now and completed later.
- A response event published back the other way, which your org subscribes to.
- A correlation id carried on the outbound event and echoed on the way back, so you can join the two halves in your logs. I wrote about that pattern in correlation ids for integration observability, and it remains the cheapest thing you can add to an event-driven design.
All three are work. That is the honest trade you make when you choose events: you buy decoupling, and you pay for confirmation separately.
Why a simulated far side made this clearer, not vaguer
You might expect a build whose far side is simulated to be sloppier about all this. It was the opposite. Because I knew nothing real was listening, I could not hide behind “well, it usually works.” Every claim the interface made had to be justified by something I could observe inside the org, and anything beyond that boundary I described as pending — because it was. That is a good habit to carry into real work, where the subscriber exists but is having a bad afternoon.
Your next step
Search your org for EventBus.publish and look at each call site with one question: is the return value assigned to anything?
Wherever it is not, you have found a place where your code can report success on a publish that was denied. Fix it in two moves. First, capture the results and check isSuccess() on every one of them. Second, read the message your code shows the user afterwards and make the verb honest — submitted, not completed.
If platform events themselves are still new to you, start with Platform Events: Salesforce’s Way of Announcing Things, then read error handling for integrations. The rest of the series lives in Integration.