A player finishes a ranked match and lands a new personal best. You write the result to the database, and now a few other things should happen. The leaderboard needs recomputing. The cached top ten is stale. A couple of friends should probably get a “you’ve been overtaken” notification. None of that has to finish before you tell the player “result saved”, but all of it has to happen eventually.
The obvious move is to keep that work out of the request. Drop it on a queue, let a worker pick it up, and respond right away. That’s event-driven thinking, and it’s the right instinct.
But “event” is one of the most overloaded words we use. Sometimes it means “do this thing, just not right now”, a note you leave for yourself. Sometimes it means “this happened, in case anyone cares”, an announcement to the world. On the wire they look the same: a message on a queue. In intent they’re opposites, and mixing them up is where a lot of event-driven designs quietly go wrong.
This post is about both jobs: offloading work inside your own app, and broadcasting facts to other services. Mostly it’s about where the line between them sits.
Two things we both call “events”
Split the word in two before you write any code.
A command is a directed instruction: do this. It’s aimed at a specific handler you chose, and you expect it to run once. Microsoft’s microservices guidance describes this point-to-point style as delivering a message to “exactly one of the consumers“, and notes it’s “especially well suited for sending asynchronous commands”.
An event is a statement of fact: this happened. As Microsoft’s domain-events guidance puts it, “an event is something that has happened in the past“, and because it’s in the past, it doesn’t change. The publisher doesn’t pick a handler. In an event-driven style “a producer doesn’t know which consumers are listening“, and the same event might be handled “zero or n times” by different consumers for different reasons.
A command says do this. An event says this happened. Same queue, opposite intent.
That one distinction decides almost everything else: who knows about whom, where the business rules live, and how much machinery you need to do it safely.
Commands to yourself: offloading work that can wait
Back to the leaderboard. The player is waiting for one thing: confirmation that the result was saved. Everything else can happen a moment later without anyone noticing.
So you save the result, put a command on a queue, and return:
// In the request handler: save what the player is waiting for, then return.
async function submitResult(req) {
const entry = await results.insert(req.playerId, req.score);
// The player doesn't need the leaderboard recomputed before they
// get a response. Hand that off and answer immediately.
await queue.enqueue('RecomputeLeaderboard', { boardId: entry.boardId });
return { status: 'saved', entryId: entry.id };
}
// Elsewhere, a worker drains the queue on its own time:
worker.on('RecomputeLeaderboard', async ({ boardId }) => {
await leaderboard.recompute(boardId); // slow, but nobody is waiting
});The request stays fast, and the leaderboard catches up a second later.
This is unapologetically command-style, and that’s correct here. It’s your code on both ends. You know who should handle the message, because you wrote the handler. The tight coupling between producer and consumer, the thing that hurts you across service boundaries, is free inside a single application. So name the message after the job you want done (RecomputeLeaderboard), point it at your worker, and move on.
None of this is tied to a language or a framework. A background job runner like Sidekiq, Celery, BullMQ, or Symfony Messenger, or a managed queue like Cloud Tasks or SQS, all do the same thing: get work out of the request path. What you get for it:
- Fast responses. The request returns once the command is queued, not when the work finishes.
- Retries for free. Most runners retry a failed job with backoff before giving up.
- Failure isolation. The notification service being down doesn’t fail the result submission.
- Independent scaling. Recompute falling behind? Run more workers. The web tier doesn’t change.
What this looks like in a running app
A concrete version, from a side project of mine: a Symfony monolith where every deferred job goes through Messenger’s Doctrine transport. The queue is a table in the same PostgreSQL database as the rest of the app. No broker, no Redis, nothing new to operate. For a single application, the database you already run is a perfectly good queue.
Not all jobs matter equally, so messages route to three queues: normal work like emails and notifications, low-priority bookkeeping like activity tracking, and a queue just for the genuinely heavy job, recomputing which competitions match which users whenever one of them changes. Every worker consumes all three in one command:
php bin/console messenger:consume async async_low async_matchThe order is the priority: Messenger drains async before it looks at async_low, and only touches async_match when the other two are empty. A burst of recompute work piles up harmlessly at the back while a sign-in email still leaves within a second. In production that line runs a few times over, one identical process per unit of concurrency, and scaling is turning that number up. Failure handling is configuration too: three retries with exponential backoff, then the message parks in a failed-messages table to be inspected and retried by hand. The “retries for free” bullet above is not theoretical. Nobody wrote any of that code.
The part worth stealing is the naming, because both kinds of message from this post show up inside the one codebase. Some are commands: SendVerifyEmail, DeleteCompetitionAttachment, named after a job, one handler each. Others are named like facts: CompetitionCreated, CompetitionUpdated. Those have several handlers, each with its own reason to care:
// One fact, three independent reactions. The dispatcher names none of them.
#[AsMessageHandler]
public function refreshSearchIndex(CompetitionCreated $event): void { /* ... */ }
#[AsMessageHandler]
public function recomputeAvailableSpots(CompetitionCreated $event): void { /* ... */ }
#[AsMessageHandler]
public function kickOffMatching(CompetitionCreated $event): void
{
// A handler of a fact dispatching a job: one target handler,
// routed to the heavy queue where it can't crowd out the emails.
$this->bus->dispatch(new RecomputeCompetitionMatches($event->competitionId));
}Messenger, like most buses, hands one message to every handler registered for it. So when a new requirement lands, “oh, and the search engines should be pinged when a competition is published”, you add a handler. The code that announced the fact doesn’t change. Keep that shape in mind: it’s exactly the property we’re about to need across services, already working inside one app, with none of the machinery the rest of this post is about. The line between command and event was never the network. It’s whether the sender picks the handler.
One trap. Don’t dispatch the command before the data it depends on is committed. If you enqueue RecomputeLeaderboard inside a database transaction and a fast worker grabs it before that transaction commits, the worker recomputes a leaderboard that doesn’t include the new result yet. Dispatch after the commit, not during it. Or dissolve the problem: when the queue is a table in the same database, as above, a message enqueued mid-transaction commits atomically with the data it depends on, and no worker can grab it early because until commit it doesn’t exist. That side project’s message table is named outbox_messages, and the section on not losing events explains why that name is exactly right. Either way, remember the trap, because it comes back with much higher stakes once events leave your app.
Events to the world: telling other services what happened
Now change the scenario. This time it’s not your leaderboard that needs updating. It’s another team’s service that needs to know something happened in your domain, so it can do something you don’t own and shouldn’t care about.
The tempting move is to reuse what worked internally and send that service a command. “The achievements service needs to count this match toward the player’s badge, so I’ll publish a message telling it to.” This is where command-style thinking starts to hurt, and it hurts in two ways.
The producer ends up knowing its consumers. If the topic is named after the consumer (achievements-tasks) and the event after the action you want taken, you’re not describing what happened. You’re addressing a known peer. The day a second service also wants to know about matches, the producer has to change to tell it. That’s the opposite of the decoupling event-driven architecture is supposed to buy you.
Domain logic leaks across the boundary. The rule for which matches count toward a badge belongs to the achievements service. It’s part of what an achievement means. But if the matches service decides whether to emit a “count this” message, that rule now lives in matches. Change how a badge is earned, and you’ve changed the wrong service. The producer is carrying knowledge it should never have owned.
Name events after entities, not after consumers
The fix is the heart of an event-driven design that survives more than one team: stop describing what you want done, and start describing what happened.
Name the topic after the entity, not the consumer. The matches service owns a matches topic and publishes lifecycle events on it: MatchCreated, MatchUpdated, MatchDeleted. It publishes every change, for nobody in particular, and filters for no one.
Each consumer subscribes and applies its own rules. The achievements service listens to matches and decides for itself which ones count toward a badge. The domain decision moves back to the service that owns it, and the producer goes back to not knowing or caring who’s listening.
This is a pattern Martin Fowler calls Event-Carried State Transfer: the event carries enough of the entity’s state that a recipient keeps a local copy and “never needs to talk to the main customer system in order to do its work in the future”. Consumers stay current from the stream instead of calling back to the source.
There’s a real trade-off. Lifecycle names describe state snapshots, not transitions. A consumer that receives MatchUpdated can’t tell from the name what changed, so it diffs the event against its own copy. You pay that to get two things back: one small, uniform contract per entity, and a clean rule that “what this means” is always the consumer’s business, never the producer’s. A topic named after an entity also survives a consumer being renamed or split in two. A topic named achievements-tasks doesn’t.
A shared envelope, so events can evolve: CloudEvents
The moment an event becomes a contract between teams, its shape matters. You don’t want every service inventing its own envelope, and you don’t want the first sign of a breaking change to be a decode error in production. So wrap every event in a standard envelope. CloudEvents is the obvious choice, a CNCF spec that plenty of tooling already understands.
CloudEvents 1.0 requires four attributes: specversion, id, source, and type. It also offers optional ones worth using: subject (the entity id, surfaced at the envelope level so consumers can filter without decoding the body), time, datacontenttype, dataschema, and data, the payload itself.
{
"specversion": "1.0",
"id": "5f6a-...-e2",
"source": "service:matches",
"type": "com.example.match.created.v1",
"subject": "match_8821",
"time": "2026-05-15T10:32:00Z",
"datacontenttype": "application/json",
"dataschema": "https://schemas.example.com/match/created/v1.json",
"data": { "id": "match_8821", "mode": "ranked", "winnerId": "player_19", "score": 4200 }
}The decision that pays off is putting the schema version right in the type: com.example.match.created.v1. That keeps the version visible to broker-level routing and observability without anyone parsing the body, and it follows CloudEvents’ own advice. The primer says that “when a CloudEvent’s data changes in a backwardly-incompatible way, the value of the type attribute should generally change“, and the spec says the same about the schema: “Incompatible changes to the schema SHOULD be reflected by a different URI“.
Evolving without breaking consumers
With the version in the type, schema evolution has two clean cases.
Non-breaking additions, like a new optional field in data, stay on the same version. Consumers ignore the fields they don’t read, and nothing breaks.
Breaking changes bump the version, so ...created.v1 becomes ...created.v2. Removing a field, changing a type, or making a required field nullable all count. You don’t flip a switch, though. CloudEvents’ guidance is to keep both alive: the producer is “encouraged to produce both the old event and the new event for some time (potentially forever) in order to avoid disrupting consumers“. You retire v1 only once nothing consumes it, which means you have to measure consumption per version instead of guessing.
If you pin the version into the generated type on both ends, the compiler keeps you honest about which one you’re handling:
// One version-pinned type per event version, generated from the schema.
// MatchCreatedV1 -> wire type "com.example.match.created.v1"
// Publish: the topic is the entity, the version travels in the type.
client.publish<MatchCreatedV1>('matches', event);
// Subscribe: you receive only the version you asked for.
client.subscribe<MatchCreatedV1>('matches', onMatchCreated);A consumer normally subscribes to one version. While migrating across a breaking change it runs a second subscription for v2 next to the v1 handler, then drops v1 once it’s moved over. Consuming two versions at once is a transient migration state, not the steady state.
Version-pinned handlers force one more question: what happens when a message arrives whose type nobody registered a handler for, say a v3 this consumer has never heard of? Hand it to a fallback handler if one exists, or log its non-personal envelope fields (id, source, type, time) and ack it. What you must not do is nack it, because the broker will dutifully redeliver an event that no handler can process, forever. An unknown version is a fact to record, not an error to retry.
Where do the schemas live? The lazy-but-wrong answer is “in each producer’s repo, shipped as that producer’s package”. That quietly rebuilds the coupling you just removed: now every consumer depends on one package per producer it listens to, a web of dependencies that encodes exactly who consumes whom. The better answer is one shared, neutral contracts package that every service depends on instead of depending on each other. The producing team still authors its own schemas. It just ships them through the common artifact.
On top of that, AsyncAPI, “the industry standard specification for defining asynchronous APIs”, gives you a documentation layer describing which events flow over which channels. Brokers like Google Cloud Pub/Sub support schemas and schema revisions at the wire level, which you can add later as a guardrail. Neither replaces the contracts package. They sit around it.
Don’t lose events: the outbox and the idempotent consumer
Here’s the part that separates a demo from a system you can trust. Once other services act on your events, two things have to be true. An event must be published if and only if the change really happened. And a consumer must survive seeing the same event twice.
Start with delivery. A broker like Pub/Sub gives you, “by default… at-least-once delivery“. That’s the floor, and it makes work on both ends.
The dual-write problem. Your service updates its database and then publishes to the broker, two separate writes. Crash in between, and you’ve changed state without telling anyone, or announced something that later rolled back. As the transactional-outbox write-up puts it, “if a service sends a message after committing the transaction there’s no guarantee that it won’t crash before sending the message“.
The fix is the transactional outbox: write the event into an outbox table in the same transaction as the state change, and let a separate relay publish it afterwards. Now “messages are guaranteed to be sent if and only if the database transaction commits“. It’s the leaderboard race from earlier, dispatch-before-commit, except now it crosses a service boundary, where getting it wrong means permanently inconsistent data instead of a stale cache. And if the database-backed queue from the worker section looked quaint, notice what it quietly was all along: an outbox, with the worker as the relay. State change and message in one transaction, processed only after commit. This pattern is the same move, kept even when the broker no longer lives inside your database.
Duplicate delivery. At-least-once means a consumer will eventually see the same event twice, so consumers have to be idempotent. The simplest way comes straight from the idempotent consumer pattern: “record the IDs of processed messages in the database”, then “detect and discard duplicates by querying the database”. Some people call that receiving-side table an “inbox”, the mirror image of the outbox.
If you’d rather the broker handle dedup, Pub/Sub also offers exactly-once delivery within a region. But idempotent consumers are cheaper, portable across brokers, and protect you from your own retries too, so I reach for them first.
So which one do you reach for?
Here’s the part that saves you the most work: most of the machinery above is for the cross-service case only. Don’t tax every background job with it.
If the work is “do this later” and it never leaves your application, a command on a job queue is the whole solution. Recompute the leaderboard, resize the image, send the email: no CloudEvents envelope, no version suffixes, no outbox table, no schema package. A queue and a worker, and you’re done.
The envelope, the versioning, the outbox, the idempotency: that whole tax is what you pay when an event becomes a contract between teams who deploy on their own schedules. That’s when keeping the producer ignorant of its consumers actually pays off, and when losing one event actually costs money. Reach for the heavy version when an event crosses a boundary you don’t control, and not before.
Conclusion
One word, two tools. “Do this, just not now” is a command you hand to yourself: couple freely, keep it in-process, name it after the job, and dispatch it after you’ve committed. “This happened” is an event you publish to the world: decouple on purpose, name it after the entity, wrap it in a contract that can version, and make sure you neither lose it nor apply it twice.
Most event-driven messes I’ve seen come from reaching for one when the situation called for the other. Knowing which one you’re holding is most of the architecture.
Further reading
- Martin Fowler: What do you mean by “Event-Driven”?
- CloudEvents 1.0: specification and primer
- microservices.io: Transactional Outbox and Idempotent Consumer
- Google Cloud: Pub/Sub delivery guarantees and schemas
- AsyncAPI: defining and documenting asynchronous APIs
Thank you for taking the time to read this! How do you draw the line between commands and events in your own systems? You can reach me on LinkedIn. I’d love to hear about it 🌞