EventsGENERALSCALE-SPECIFIC

Commands vs Events

A command asks for something to happen and has exactly one handler; an event states that something happened and has any number of consumers.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What actually changes when I stop telling a service what to do and start telling it what happened?

The requirement

Placing an order must charge a card, reserve stock, email the customer, update the search index and notify the warehouse. Someone has to decide how the checkout code expresses each of those.

The obvious build

Put everything on the message bus and call the system event-driven. Messages get names like SendWelcomeEmail and ProcessOrderEvent, and whichever service is subscribed picks them up.

Why it breaks

A second team subscribes to SendWelcomeEmail to also seed a CRM record. Now one message name means two unrelated things, and nobody can change either without reading both subscribers.

How it breaks in production
  • A second team subscribes to SendWelcomeEmail to also seed a CRM record. Now one message name means two unrelated things, and nobody can change either without reading both subscribers.
  • Someone subscribes a second consumer to ChargeCardCommand for observability. Every charge now runs twice, because the message was written as an instruction and instructions execute.
  • Checkout can no longer be read on its own. To know what placing an order does, you have to search the whole codebase for subscribers — the producer names an action but does not own it.
  • A command sent onto a bus has nowhere to return a rejection to. ReserveStockCommand fails validation and the caller who needed that answer already returned 200.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A command is an instruction: imperative, addressed to one specific recipient, may be refused, and the sender usually needs the outcome. ChargeCard, CancelOrder, ReindexDocument. Exactly one handler, by definition — two handlers means the instruction executes twice.
  • An event is a statement of fact: past tense, broadcast, and not refusable — it already happened, and a consumer that dislikes it can only react. OrderPlaced, PaymentCaptured, UserDeleted. Zero to N consumers, and the producer does not know or care how many.
  • The distinction is about who owns the decision, not about the transport. A command can travel over HTTP, a queue or an in-process function call; an event can too. Putting a message on a broker does not make it an event.
  • Direction of coupling is the whole payoff. With commands, the producer knows the consumer: adding a reaction means editing the producer. With events, the consumer knows the producer: adding a reaction means deploying a new consumer and touching nothing upstream.
  • The failure semantics differ accordingly. A failed command is the sender's problem and often returns to a caller who is still waiting. A failed event consumer is that consumer's problem, invisible to the producer, and it degrades one capability rather than the write (Writing Event Consumers).

One handler or many: the test that settles it

The reliable way to classify a message is not to look at its shape or its transport. It is to ask what a second subscriber would mean. If a second subscriber would be a normal, welcome extension, it is an event. If a second subscriber would be a production incident, it is a command with one legitimate owner.

That test also explains the grammar rule that follows from it. An imperative name invites a handler to execute it, and executing twice is wrong. A past-tense fact invites a reaction, and reacting twice from two different consumers is fine — each does its own thing.

CommandEvent
NameChargeCard, imperativePaymentCaptured, past tense
HandlersExactly oneZero to many
Addressed toA named recipientNobody in particular
Can be refusedYes — validation, authorization, business rulesNo. It already happened
Sender needs the outcomeUsually yesNo, by construction
Adding a new reactionEdit the senderDeploy a consumer; sender untouched
Second subscriberDuplicate side effect. IncidentNormal extension
Failure belongs toThe sender, often a waiting callerThe consumer, alone

Which way the arrow of knowledge points

Both diagrams below move the same information through the same system. The difference is which side holds the list of what happens next. In the command version, checkout contains that list; every new capability edits checkout, and every downstream failure is checkout's failure. In the event version, checkout states one fact and the list lives in the set of deployed consumers.

This is why "we will just add one more call in the handler" is the decision worth noticing. It is not a line of code — it is a commitment that checkout now owns the availability and latency of one more system (Synchronous vs Asynchronous Communication).

Commands: checkout knows everyone. Events: everyone knows checkout.
ChargeCardReserveStockSendEmailpublishes one factadded later, no edit upstreamCheckout (commands)Checkout (events)PaymentsInventoryEmailOrderPlacedEmail consumerSearch indexerAnalytics
UserLLMAgentToolDataDecisionHumanGuardrail

Neither is the default

The temptation after learning the distinction is to convert everything into events. That trades a legible synchronous flow for a system where nobody can answer "what happens when an order is placed" without reading a topic registry. The criteria below are the lesson; there is no winner.

The strongest signal is whether the caller needs the answer. If the HTTP response depends on the result, an event cannot supply it — you would be inventing a request/response protocol on top of a broadcast one (Request or Background?).

Command, event, or direct call?

Who needs to know the outcome, and who decides what happens next?

Direct synchronous call

when The response depends on the result, and a failure must fail the request. Charging a card at checkout.

cost Your availability is now the product of every dependency's (Failure Propagation). Latency is the sum.

Command on a queue

when Exactly one thing must happen, it must happen even if the caller vanishes, and the caller does not need the answer now. Generating an invoice PDF.

cost Delivery, retries, duplicates and dead letters are now yours (At-Least-Once Delivery).

Published event

when A fact is true and an unknown number of parties may care. Order placed, user deleted, price changed.

cost No feedback path, a payload contract you cannot easily change, and consumers you must operate (Naming Events).

Nothing at all

when The reaction can be derived on read, or is a scheduled reconciliation. Nightly export, aggregate counts.

cost Staleness bounded by the schedule instead of by the pipeline (Scheduled Jobs).

How to build it

Most important first.

  • Ask one question about each message: if two teams subscribed to this, would that be correct or catastrophic? Correct means it is an event. Catastrophic means it is a command and needs a single owner.
  • Write events as past-tense domain facts and commands as imperatives, and never mix the grammars in one topic (Naming Events).
  • Keep commands addressed. A command belongs in a direct call or a queue with exactly one consumer group — not in a fan-out topic where subscription is open (Job Queues).
  • Let the consumer own its own reaction. OrderPlaced says an order exists; whether that means an email, a CRM record or nothing at all is the consumer's decision, and the producer must not encode it.
  • Publish events from inside the same transaction that made the fact true, via an outbox — an event that claims something happened when it did not is worse than no event (The Transactional Outbox).

What can go wrong

Failure modes
  • A message named as an event but consumed as the only way a critical step happens. It is a command in disguise, and its "at least one consumer" requirement is unenforced — remove the last subscriber and the system silently stops charging cards.
  • A command with multiple subscribers: duplicate side effects, usually discovered by a customer with two charges (Duplicate Detection).
  • An event nobody consumes, published forever at cost, because the consumer was deleted and the producer had no reason to notice.
  • Events used to move state that the consumer then treats as authoritative, so a lost or reordered message means the consumer's copy is permanently wrong (Eventual Consistency in Practice).
What can race
  • Two consumers of the same event writing to the same row race with each other, and neither knows the other exists — the producer's single fact became two concurrent writers (Backend Races).
  • An event can be delivered before the transaction that produced it is visible to a reader on a replica, so a consumer that fetches the entity by id gets "not found" for a fact that is definitionally true (Eventual Consistency in Practice).
Security
  • Event payloads fan out to every consumer including ones added later, so a field added for one consumer is disclosed to all of them. Treat the event as a published contract with the same review as a public response body (Schema Leakage).
  • Personal data in an event outlives the request: it sits in the broker's retention window, in every consumer's local store and in the dead-letter queue. Deletion requests have to reach all of those.
  • A bus that lets any service subscribe to any topic is an authorization gap. Subscription rights are an access-control decision, not a configuration detail (Object-Level Authorization).
Misreads
  • "Events are the modern way; commands are legacy." Every system needs both. Charging a card is a command and always will be — you are instructing a specific system to do a specific thing and you need the answer.
  • "Putting it on a queue makes it an event." The transport is orthogonal. SendEmailJob on a queue is a command with one handler that happens to be asynchronous (Background Jobs).
  • "Events are asynchronous, commands are synchronous." Both can be either. An in-process event bus dispatches events synchronously; a command can sit in a queue for an hour.
  • "Event-driven means loosely coupled." It means the coupling moved into the payload schema. Change a field and every consumer breaks — the coupling is real, just deferred and less visible (Naming Events).

Operating it

How you see it in production
  • Per topic, count publishes and per-consumer processed messages. A consumer whose processed count is well below the publish count is failing or lagging; a topic with zero consumers is dead weight.
  • Record the producer service, the event id and the correlation id on every event, so one user action can be traced from the write through every reaction it triggered (Correlation Ids That Survive Every Hop).
  • Alert on "command topic with more than one consumer group" as a structural check. That single condition catches the most expensive version of this mistake before a customer does.
What changes at 10x and 100x
  • The number of consumers is where event-driven design pays: adding the tenth reaction to OrderPlaced costs one deployment and zero changes to checkout. The same tenth step added as a synchronous command means a tenth call in the request path.
  • It does not reduce total work. Ten consumers do ten consumers' worth of database and CPU work; the events model moves that work off the request path and onto capacity you must still provision (Worker Scaling).
  • At high fan-out the broker becomes the shared dependency every reaction depends on, which changes the failure domain rather than removing it (Failure Propagation).
What this costs
  • Events buy decoupling and pay in traceability. A synchronous command chain shows the whole flow in a stack trace; an event chain shows it only in a distributed trace you had to build.
  • The producer loses the ability to know whether the outcome happened. That is exactly the property that makes it decoupled, and exactly the property that makes "did the email send?" a research task.
  • Two grammars in one system is more concepts for a new engineer to hold, and the discipline decays. It is still cheaper than one grammar used for both meanings.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe command/event distinction is about ownership of a decision, so it holds for in-process buses, HTTP calls and brokers alike — only the delivery guarantees change with the transport.
  • SCALE-SPECIFICInside a single deployable, an in-process event bus gives the same decoupling with none of the delivery problems, because publish and consume share a transaction. The moment consumers are separately deployed, delivery, ordering and duplication all become yours (The Modular Monolith).

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — what a broker can and cannot promise about delivery and ordering once producer and consumer are separate processes on separate machines.