The problem: a flow nobody can see

A team replaces a synchronous checkout with events. OrderPlaced triggers payment, payment emits PaymentCaptured, inventory reacts, shipping reacts to inventory, and a notification service listens to everything. It scales and services deploy independently. Six months later a customer reports "I paid but nothing shipped", and nobody can say which service dropped the ball without reading five codebases and three dashboards.

Event-driven architecture (EDA) is a powerful tool, but it moves complexity from call stacks into time and topology. This article is about knowing what you buy and what you pay.

Events and commands are different things

  • A command is a request addressed to one receiver: CapturePayment. It may be rejected, and the sender cares about the outcome.
  • An event is a statement of a fact that already happened: PaymentCaptured. The publisher does not know who listens and cannot reject it.

Naming matters because it reveals coupling. If a service publishes SendWelcomeEmail to a topic, that is a command wearing an event's clothes: the publisher expects one specific behaviour. Prefer past-tense facts (CustomerRegistered) and let the email service decide what to do with them.

Events also come in sizes. A thin notification event carries ids ("order 42 changed"), and consumers call back for details, which creates load and coupling to the producer's API. An event-carried state transfer event includes the data consumers need, which removes callbacks but copies data and makes the schema a public contract.

Choreography versus orchestration

In choreography, services react to each other's events with no central coordinator. It is loosely coupled and simple for two or three steps.

In orchestration, a coordinator (a saga orchestrator or workflow engine) tells each participant what to do and tracks the state of the whole process.

Choreography:
 Order --OrderPlaced--> Payment --PaymentCaptured--> Inventory
                                                  --Reserved--> Shipping

Orchestration:
            +--> Payment
 Orchestrator --+--> Inventory      (one place knows the process)
            +--> Shipping

Choreography makes the end-to-end flow implicit: it exists only in the sum of subscriptions, so it is hard to answer "what happens when payment fails after inventory is reserved?". Orchestration makes the flow explicit and testable and gives you one place for timeouts and compensation, at the price of a central component that must not become a god service. A reasonable rule: choreography within a small, stable flow; orchestration when the process has many steps, deadlines or compensations.

Without distributed transactions, a multi-service business operation is a saga: a sequence of local transactions, each with a compensating action (refund the payment, release the stock) if a later step fails. Compensation is business logic, not a technical rollback, and some steps cannot be undone at all.

Eventual consistency reaches the user interface

When the order service answers 202 Accepted and the read model updates later, the screen can show stale data. Pretending otherwise causes the classic bug report "I clicked pay and the page still says unpaid".

Practical techniques:

  • return the new resource id and state from the command so the UI can render optimistically;
  • show an explicit "processing" state and poll or push the final result;
  • design read-your-own-writes where it matters, for example by routing the user's next read to the writer;
  • treat "pending" as a first-class business state, not an error.

Schema evolution

Events outlive the code that wrote them: a topic with retention keeps old shapes for as long as it keeps records, and a new consumer may replay all of it. Rules that save pain:

  • add optional fields; do not rename or change the meaning of existing ones;
  • never reuse a field name with a different type or semantics;
  • version the event type explicitly (OrderPlaced.v2) when a breaking change is unavoidable, and run both versions during migration;
  • use a schema format with compatibility checks (Avro, Protobuf or JSON Schema with a registry) and enforce them in CI;
  • make consumers tolerant readers that ignore unknown fields.
{  "eventId": "7c1f...",  "type": "OrderPlaced",  "version": 2,  "occurredAt": "2026-10-19T09:30:00Z",  "data": { "orderId": "o-42", "currency": "AZN", "total": "120.00" }}

Debugging and operating an event flow

You pay for decoupling with observability work. Put a correlation id in every event header and log it everywhere; propagate trace context through the broker so one trace spans publisher and consumers; monitor consumer lag per group as a primary health signal; keep dead-letter topics visible. Build a way to answer "what happened to order 42" from a single query, whether by a process-state table in an orchestrator or a search over correlated logs.

Also plan for ordering and duplicates (see the article on delivery semantics): consumers must be idempotent, and logic that assumes global order across partitions will eventually break.

When a synchronous call is simpler

Use a direct call when the caller needs the answer to continue (price lookup, authorization check), when the operation must succeed or fail as one visible unit, when the team is small and the services are few, or when the data is needed with strong consistency. Adding a broker between two services that always deploy together and always need each other's answer buys complexity without independence.

A hybrid is common and healthy: synchronous request/response at the edges where users wait, events for propagating facts that other parts of the system react to later.

Common mistakes

  • Publishing commands disguised as events, then depending on a specific consumer.
  • Using events for everything, including simple queries.
  • Choreography across many services with no documented end-to-end flow.
  • Dual writes instead of an outbox, producing silent inconsistency.
  • Changing event schemas in place and breaking replaying consumers.
  • Ignoring consumer lag until users complain.
  • No owner for a topic or its schema.

Trade-offs in one table

AspectGainCost
DecouplingServices deploy and fail independentlyImplicit dependencies, harder impact analysis
ThroughputAbsorbs bursts, scales consumersLatency is no longer bounded by a call
AuditabilityEvent history can be replayedStorage, schema discipline
ConsistencyLocal transactions stay simpleEventual consistency, sagas, UX work
DebuggingIsolated componentsDistributed tracing is mandatory

Checklist

  1. Each message is clearly an event (fact) or a command (request).
  2. The end-to-end flow is documented, with timeouts and compensations.
  3. Every event has an id, type, version, timestamp and correlation id.
  4. Schema compatibility is checked automatically.
  5. Consumers are idempotent and tolerant of unknown fields.
  6. The UI has an honest pending state.
  7. Lag, dead letters and flow state are monitored.
  8. A synchronous call was considered and rejected for a stated reason.

Where to go next