Events vs Commands vs Requests
What This Concept Is
Messages between services fall into three intents. Confusing them is the single biggest source of subtle coupling in "event-driven" systems.
| Intent | Tense | Who decides the effect | Typical consumers | Reply expected? |
|---|---|---|---|---|
| Event | Past (OrderPlaced) | The consumer decides what to do | 0..N independent | No |
| Command | Imperative (PlaceOrder, ChargeCard) | The producer tells a specific handler what to do | Exactly 1 | Usually ack/result |
| Request / Query | Interrogative (GetOrder) | The producer wants a reply with data | Exactly 1 | Yes, synchronously |
All three can travel over the same broker. The broker has no opinion. You enforce the distinction through naming, topic design, and expected consumer count.
Why It Matters Here
Mixing intents produces a set of predictable failures:
- Command disguised as event -> the producer secretly owns the workflow. When a consumer's behavior changes, the producer must change too. You have built synchronous RPC through a slower pipe.
- Event disguised as command -> a "real" fact ends up with exactly one consumer, and adding a second subscriber later requires reshaping the producer ("wait, who gets the email now?").
- Request disguised as event -> the caller blocks waiting for a reply message, reinventing RPC on top of pub-sub, complete with correlation IDs and ad hoc timeouts.
Once these get tangled, debugging becomes archaeology: who caused what, and who owned that decision?
Concrete Example: One Workflow, All Three Intents
Order checkout, done cleanly:
Customer clicks Buy
|
v
[checkout] --command--> [payment]: ChargePayment(order_id, amount, card_token)
(synchronous, expects PaymentResult back)
[payment] --event----> broker topic: PaymentCaptured(order_id, amount, txn_id)
(past tense; anyone can care)
+----> [ledger] (records the transaction)
+----> [notify] (sends a receipt email)
+----> [loyalty] (awards points)
+----> [analytics] (updates revenue dashboards)
[shipping] --query---> [inventory]: GetStockLevel(sku) -> 42
(synchronous request/reply)
Three observations:
- The command (
ChargePayment) is imperative, addressed to exactly one service, and has a reply. It is a remote procedure call and it is fine to call it that. - The event (
PaymentCaptured) is a fact. Four services consume it independently. None of them was known to the payment service when it was designed; new subscribers can appear without touching the producer. - The query (
GetStockLevel) is synchronous. It is a read. Forcing it through a broker buys nothing.
If you had published PaymentCaptured as a command (NotifyOfPayment), adding the loyalty service would have required changing the payment service. If you had issued ChargePayment as an "event," you would have had to invent "only one consumer please" rules inside the broker.
Counterexample Where Mixing Went Wrong
An example seen in the wild: a topic named user-events carrying, in practice, SendWelcomeEmail, DisableUser, GetUserProfile, and occasionally UserSignedUp. The system had:
- the email service tightly coupled to the user service (it was being told what to do)
- failed "events" that were really failed commands, bounced around with no clear retry ownership
- a read path that blocked waiting for "response events" on a second topic
- no one willing to add a subscriber to
user-eventsbecause nobody could predict what they would have to handle
The fix was to split the topic into user.signed_up, user.email_changed (events), a user-commands queue (commands), and a real HTTP endpoint (queries). Cost: a week. Confusion removed: years' worth.
Common Confusion / Misconception
"If it's on Kafka, it's an event." Kafka is a log. You can put commands on it. Many teams do. It makes your commands durable and replayable but does not magically turn them into events.
"Commands are bad; use events for everything." No. Commands are the right abstraction when you mean "do this specific thing." Hiding commands behind event-shaped topics does not remove coupling; it obscures it.
"Every event needs a reply event." Events are fire-and-forget by design. If your producer needs to know what the consumer did, you probably want a command with a reply, or a follow-up event emitted by the consumer (which the producer subscribes to if and only if it actually cares).
"Request/reply cannot coexist with EDA." It can, and in large systems it must. Reads are usually synchronous. State-changing commands are frequently synchronous. Async events sit alongside both.
How To Use It
Decision rule for each message you design:
Style guide:
- events -> topics named by past tense,
order.placed,payment.captured - commands -> queues named by action,
commands.payment.chargeorpayment.commands - queries -> HTTP/gRPC; never put a synchronous read on a broker
A field heuristic for spotting mixed intent in an existing system: scan topic names for imperatives (Send*, Notify*, Charge*, Reserve*). Every imperative is a candidate for reclassification as a command; pair each with the past-tense fact it really reacts to, and move the imperative to a command queue. The cleanup is usually mechanical once the vocabulary is in place.
Check Yourself
- Give one-sentence rules to tell an event, a command, and a query apart without looking at the payload.
- Why does "zero to many consumers" force the producer to think in events, and why does "exactly one consumer" push toward commands?
- When is request/reply over a broker (
CorrelationId+ reply topic) a valid design, and when is it a code smell?
Mini Drill or Application
Take any API you know (Stripe, GitHub, Slack, your internal system). In 20 minutes:
- List 8 messages/endpoints. Classify each as event, command, or query.
- Pick one command that could reasonably be replaced by an event. Rewrite its name in past tense, and list the consumers that would plausibly subscribe.
- Pick one "event" that is actually a command. Rename it as a command and say who the single consumer is.