Skip to content
Ayhan Sipahi Ayhan Sipahi

Thinking in Events: A Beginner's Guide to Commands vs Events

Learn the mental shift behind event-driven systems: announce facts instead of issuing commands. Covers naming, decoupling, eventual consistency, and idempotency.

Many teams adopt a message broker, rename their RPC calls to “events,” and keep thinking in commands. The rename changes the label, not the mindset. A command still says “do this now,” addressed to one known worker, so nothing is actually decoupled. You pay the operational cost of messaging and get none of the benefit.

If you are comfortable with request/response HTTP and CRUD but have never built an event-driven system, one small shift makes it click. Announce facts that already happened (events) instead of issuing instructions (commands). Because that shift is a way of thinking and not a tool, you can get it right before you touch any broker.

Command, Event, or Message

Three words get used as if they mean the same thing. They do not.

A command is an instruction to do something. It is directed at a specific handler, and the sender expects it to happen. Commands read as imperatives: PlaceOrder, ChargeCard, SendEmail. The sender owns the outcome. If the handler is missing, the operation fails.

An event is a statement that something already happened. It is a fact, broadcast to whoever cares. Events read as past tense: OrderPlaced, CardCharged, EmailBounced. The producer does not know or care who reacts. If nobody is listening, the producer is still correct.

A message is neither. It is the envelope that carries a command or an event across the wire. “Message-driven” describes transport. “Event-driven” describes intent. A queue full of DoThisNow messages is message-driven and still command-shaped.

One litmus test settles most cases. If you can name a single service that must handle it or the operation fails, it is a command. If the producer would be equally correct with zero consumers, it is an event.

The word “event” is overloaded, and that is worth naming up front. AWS describes an event as a change in state. Confluent describes it as something that happened at a specific point in time. Martin Fowler points out that people use “event-driven” for at least four different patterns, and that lumping them together causes confusion. Here, an event is a fact about something that already happened, announced to whoever cares.

Yes

No

Yes

No

Something happened in my system

Must exactly one service act, or it is an error?

Command: request/response or a directed queue

A fact others may care about, and fine with zero listeners?

Event: announce the fact, let consumers subscribe

Not modeled yet: clarify what actually happened

Name Events as Facts

An event name is a contract. Treat it like one.

Use past tense and domain language: OrderPlaced, PaymentReceived, InventoryReserved, EmailBounced. Each name states a fact that is already true. It describes what happened, never what should happen next.

This is where most fake events hide. SendWelcomeEmail is a command wearing an event costume. It tells one service what to do. Rename it to the fact that triggers it, UserRegistered, and the email service can decide on its own to react. So the test for a name is simple: if it reads as an order, it is a command; if it reads as history, it is an event.

Carry enough in the event to identify the fact and look up more later. OrderPlaced should carry the order id at least. How much more it carries is a real decision, and we come back to it below.

Producers Do Not Know Their Consumers

This is the inversion that request/response never asks of you.

In request/response, the caller imports or addresses the callee. The caller holds a list of everyone to notify. Add a side effect, such as updating a search index, and you edit the caller again. The producer accumulates knowledge of every downstream step.

In an event-driven system, the producer emits a fact to a channel and stops. Consumers subscribe on their own. The producer never learns their names. Because of that, adding a consumer requires zero change to the producer. That single property is the test of whether you have actually decoupled anything. If adding a listener means editing the emitter, you still have commands.

A Small Example You Can Run

Here is the inversion in about forty lines, in-process, with no infrastructure. It uses Node’s built-in EventEmitter, so there is nothing to install.

import { EventEmitter } from "node:events";

// Command version (tightly coupled): the order code must know every side
// effect and call each one directly.
//   sendConfirmationEmail(order);
//   reserveInventory(order);
// Adding a new reaction means editing this function again.

// Event version: the order code announces a fact and stops.
const bus = new EventEmitter();

type OrderPlaced = {
  eventId: string;
  orderId: string;
  email: string;
  items: { sku: string; qty: number }[];
};

// Consumer 1: email. It subscribes itself; the producer never sees it.
bus.on("orderPlaced", (event: OrderPlaced) => {
  console.log(`email: confirmation for ${event.orderId} to ${event.email}`);
});

// Consumer 2: inventory, with an idempotency guard. At-least-once delivery
// means the same event can arrive more than once.
const processed = new Set<string>();
bus.on("orderPlaced", (event: OrderPlaced) => {
  if (processed.has(event.eventId)) {
    console.log(`inventory: skipping duplicate ${event.eventId}`);
    return;
  }
  processed.add(event.eventId);
  for (const item of event.items) {
    console.log(`inventory: reserving ${item.qty} x ${item.sku}`);
  }
});

// The order code emits the fact. It does not import or know either consumer.
const placed: OrderPlaced = {
  eventId: "evt-1",
  orderId: "order-42",
  email: "[email protected]",
  items: [{ sku: "BOOK-1", qty: 2 }],
};

bus.emit("orderPlaced", placed);
bus.emit("orderPlaced", placed); // a redelivery of the same event

Save it as order.ts and run it with Node 24, which strips the type annotations at load:

node order.ts
email: confirmation for order-42 to [email protected]
inventory: reserving 2 x BOOK-1
email: confirmation for order-42 to [email protected]
inventory: skipping duplicate evt-1

The order code emits orderPlaced and stops. It never mentions email or inventory. Adding a search-index consumer would be one more bus.on(...) and zero edits to the order code. That is the decoupling the rename alone never gave you.

Notice the second emit, a redelivery of the same event. Inventory checks its set of processed ids and skips. The email consumer has no guard, so it sends a second confirmation. That is the danger the guard removes. In this synchronous in-process example, a Set of processed ids is enough, because handlers never overlap. A distributed system adds a third case: a duplicate that arrives while the first copy is still processing. We come back to that below.

Thin Events and Fat Events

How much state an event carries is a design decision with a name. Fowler separates the patterns people lump under “event-driven,” and two of them shape your first system.

Event notification is the thin version. OrderPlaced carries little more than an id. The consumer calls back to the producer to fetch the details it needs. Schema coupling stays low, because the event barely has a schema. The cost is a runtime dependency: the consumer needs the producer available when it reacts, and there is more chatter on the network.

Event-carried state transfer is the fat version. The event carries the state the consumer needs, so the consumer never calls back. In Fowler’s words, a recipient that keeps its own copy “never needs to talk to the main customer system.” That buys resilience and lower latency. The cost, again in his words, is that there is “lots of data schlepped around and lots of copies,” plus stale data you have to reason about.

For a first system, pick one on purpose and name it. Starting with thin notifications keeps schemas small while you learn. Reach for state transfer when a consumer must keep working while the producer is down.

Fowler lists two more patterns under the same term: event sourcing and CQRS, both out of scope here. Event sourcing stores the event log itself as the source of truth. CQRS splits reads from writes. When you need them, the CQRS pattern post goes deeper.

Eventual Consistency Is a Feature

When you decouple, the read side lags the write side for a moment. The order is placed, and a beat later the invoice view catches up. That window is not a defect. It is the price of decoupling and availability, and it is usually small.

Frame it as a product decision. “The invoice appears a second after checkout” is fine for most flows, and it buys resilience: the invoice service can be briefly down without blocking checkout. Microsoft’s architecture guidance treats eventual consistency as a deliberate trade-off of the style, not an accident. Decide your budget for that lag per flow, and make it visible in the interface where it matters.

Idempotency Because Delivery Repeats

Most brokers deliver at-least-once. That means duplicates happen, so every consumer must be safe to run twice. This is not an edge case to patch later. It is a property you design in from the first consumer.

The basic pattern is the guard from the example. Derive a key from the event, usually its id or a business key. Record the keys you have processed. Skip anything you have seen. Microsoft names ordering and once-only processing as standing challenges of the style, and points at idempotent processing as the answer.

Keep the rule broker-agnostic while you are learning: assume delivery can repeat, and make the handler safe to run twice. When you need the exact delivery guarantees of a specific broker, the AWS messaging comparison covers the differences, and the idempotency guide covers keys, storage, and the in-flight duplicate case that the in-process example cannot show.

Choreography and Orchestration in One Minute

Once several services react to each other’s events, you choose how they coordinate.

Choreography means each service reacts to events and emits its own, with no central brain. It is maximally decoupled and easy to extend. The failure mode is visibility: no single place shows the whole flow, so “what happens after OrderPlaced?” gets hard to answer.

Orchestration means a coordinator drives each step in order. It is easier to trace, and easier to compensate when a step fails, at the cost of a central owner that knows the flow.

For simple fan-out, start choreographed. Reach for orchestration when a multi-step business transaction has to be ordered, visible, and compensatable. The saga pattern post covers that case in depth.

Common Beginner Traps

Six failure modes account for most first-system pain. Each has a direct fix.

Commands disguised as events. A SendEmail type published to a bus is still a command. Fix: name the fact, OrderPlaced, and let the email service decide to react.

Assuming exactly-once. Charging a customer twice on redelivery. Fix: assume at-least-once and make every consumer idempotent.

Event soup with no ownership. Dozens of event types, and nobody can say who produces or consumes them. Fix: keep an event catalog. Every event has one owning producer and documented consumers.

Coupling through a shared schema. One fat type edited by many teams needs a coordinated deploy for any change. Fix: the producer owns the schema, versions it additively, and consumers ignore unknown fields.

Using events to ask questions. Emitting an event to fetch data and awaiting a reply rebuilds RPC, badly. Fix: queries are request/response. Events announce facts; they do not ask questions.

Assuming order. Expecting events to arrive in the order you sent them. Fix: do not rely on global order. Use keys or versions where order matters, or write handlers that do not care about order.

Where to Go Next

The beginner-safe default holds for most first systems: model facts as past-tense events, start with thin notifications, keep consumers idempotent, and let simple fan-out stay choreographed. Reach for an override when a specific need shows up. Choose event-carried state transfer when a consumer must keep working while the producer is down. Choose orchestration when a multi-step transaction must be ordered and compensatable. Reach for event sourcing or CQRS only when the log itself, or a split read model, earns its cost.

The single next step is smaller than any of that. For your next feature, write the past-tense event name before you write any handler. If the name reads as history, you are thinking in events. If it reads as an order, you are still issuing commands.

To go deeper: event storming helps a team discover events together, domain-driven design sharpens the domain language those names come from, the outbox pattern makes emitting an event and writing your database atomic, and the event-driven CRM walkthrough shows the patterns in one worked system.

References

Related posts