Skip to main content

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.

IntentTenseWho decides the effectTypical consumersReply expected?
EventPast (OrderPlaced)The consumer decides what to do0..N independentNo
CommandImperative (PlaceOrder, ChargeCard)The producer tells a specific handler what to doExactly 1Usually ack/result
Request / QueryInterrogative (GetOrder)The producer wants a reply with dataExactly 1Yes, 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-events because 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.charge or payment.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

  1. Give one-sentence rules to tell an event, a command, and a query apart without looking at the payload.
  2. Why does "zero to many consumers" force the producer to think in events, and why does "exactly one consumer" push toward commands?
  3. 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:

  1. List 8 messages/endpoints. Classify each as event, command, or query.
  2. 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.
  3. Pick one "event" that is actually a command. Rename it as a command and say who the single consumer is.

Transfer to Adjacent Domains

  • Distributed workflow (Cluster 4). Sagas mix all three intents on purpose: orchestrators command steps, steps emit events as they complete, and the orchestrator may query state before deciding. A saga that gets the three confused (event-masquerading-as-command, in particular) has no recoverable failure model.
  • API design (S8M4). The same three intents show up in REST / gRPC / GraphQL. A POST /orders is a command; a webhook order.placed is an event; a GET /orders/42 is a query. The design rules transfer directly; the transport is incidental.
  • Domain-Driven Design (S7M3). DDD distinguishes Commands (requests to change state, addressed to an aggregate) from Domain Events (facts the aggregate publishes). The intent taxonomy in this concept is the same vocabulary, viewed through the messaging lens.
  • Observability / tracing. Mixing intents on one topic makes distributed traces unreadable: "what caused X?" answers point at whichever service happened to receive the message, not at the producer's intent.

Read This Only If Stuck