System designCore13 min

Event-driven architecture

Instead of calling every service that needs to know, a service announces what happened and lets the others react. That one change removes a web of calls, and brings delays, duplicates and flows nobody can see. This page shows how to get the first without being surprised by the rest.

1 · The problem

A checkout that calls everyone fails when anyone does

An online grocery store takes order 3307: 14 items, $86.40, delivery tomorrow morning. Placing it should trigger four things: the warehouse prepares the picking list, the loyalty service adds points, the email service sends a receipt, and analytics records the sale. The straightforward design has the checkout service call each one before answering the customer:

Checkoutanswers after all fourWarehouseLoyaltyEmailAnalyticsdownprepare pickingadd pointssend receiptrecord saleCheckoutanswers after all fourWarehouseLoyaltyEmailAnalyticsdownprepare pickingadd pointssend receiptrecord sale

Checkout calls four services and waits for each. If analytics is down, the customer can't buy groceries.

This design ties checkout to every service it calls, in three ways:

  • Availability multiplies. If checkout and each of the four are up 99.9% of the time, and they fail independently, checkout works only when all five do: 0.999⁵ ≈ 99.5%, about 44 hours of failed orders a year instead of 9.
  • Latency adds up. The customer waits for the slowest call, or for all of them in turn.
  • Every new feature edits checkout. When a fraud check is added next month, checkout's code changes, and checkout is redeployed, to tell one more service about orders.

None of the four has to finish before the customer sees "order placed". Event-driven architecture turns the calls around: checkout records the fact that order 3307 was placed and announces it, and each service that cares reacts on its own time.

2 · The idea

An event states a fact; a command asks for work

An event is a record that something has happened: order 3307 was placed. Checkout publishes it once to a topic, and every service with a subscription gets a copy (the mechanics are on Message queues & pub/sub):

Checkoutpublishes, then answersOrderPlaced topicWarehouseLoyaltyEmailFraud checkadded: no checkout changeorder 3307 placedCheckoutpublishes, then answersOrderPlaced topicWarehouseLoyaltyEmailFraud checkadded: no checkout changeorder 3307 placed

Checkout no longer knows who is listening. Analytics can be down for an hour and catch up later; the new fraud check subscribes without touching checkout.

The arrows of dependency have flipped. Before, checkout depended on four services. Now the four depend on the event, and checkout depends on no one but the broker. That is the core of the style, and it rests on a difference between two kinds of message:

Compared

Command

Event

Example

PrepareOrder 3307

OrderPlaced 3307

Means

Please do this

This happened

Named

A verb: an instruction

Past tense: a fact

Sent to

One service, which owns the work

A topic, for any number of subscribers

Can be refused?

Yes: the receiver may say no

No: it already happened

Who knows whom

The sender knows the receiver

The publisher knows no subscriber

A common mistake is a command dressed as an event. If checkout publishes "ReceiptNeeded" and the order is broken whenever no service acts on it, that message is really a command to the email service. Send it as one, to the email service's own queue, so it is clear who must handle it.

One more distinction: in event-driven architecture, events are messages between services, and each service still keeps its own database. Using a log of events as the database is a different idea, covered on Event sourcing & CQRS.

3 · The content

A thin event makes everyone call back; a fat one copies data everywhere

What goes inside "order 3307 placed"? The smallest possible event carries just the ID. Each subscriber that needs details asks checkout for them:

A thin event. Loyalty learns that something happened, then calls checkout for the details, so checkout is back on the path. Multiply by every subscriber and every order.

The callback brings back some of the coupling that events were meant to remove: if checkout is down, loyalty stalls, and checkout serves one extra read per subscriber per order. It also returns the order as it is now, which may differ from the moment the event describes. The opposite choice puts the data in the event, so subscribers never call back:

Event content

Thin: IDs only

Fat: the data itself

Subscriber needs the publisher?

Yes, for every detail

No, it can work while the publisher is down

Size

Tiny

Larger, on every message

Data copies

One system of record

Each subscriber may keep its own copy, which can go stale

Changing the shape

Easy: little is promised

Harder: subscribers depend on each field

Sensitive data

Stays in checkout

The address reaches every subscriber, analytics included

Most teams land in between: include what most subscribers need, and leave out what only one needs or what shouldn't spread, such as the delivery address. Whatever the content, give every event an envelope that a standard like CloudEvents formalizes:

One OrderPlaced event

json
{
  "specversion": "1.0",
  "id": "8f2c41d0-6a7e-4c1b-9b3e-2d5f7a9c1e04",
  "source": "/checkout",
  "type": "grocery.order.placed",
  "time": "2026-10-02T18:04:12Z",
  "subject": "order/3307",
  "data": { "orderId": 3307, "customerId": 5512,
            "items": 14, "totalCents": 8640 }
}

The source and id together are unique for each event. A copy resent after a network error may keep the same id, so a subscriber can treat a repeated pair as a duplicate and drop it (Delivery semantics). time says when the order was placed, not when the event arrived, and subject names what the event is about.

4 · The contract

An event's shape is a public API with readers you can't see

Checkout's team decides that totalCents should be called total from now on. Loyalty reads totalCents. The next event loyalty receives has no such field, and it either crashes or awards zero points. With a request-response API, checkout's team would at least know who calls it. With events, they may not know who subscribes.

Publishers and subscribers are deployed separately, so every change to an event must work while old and new versions run side by side. Some changes are safe and some always break:

Change to OrderPlaced

Old subscribers

Safe?

Add an optional field: couponCode

Ignore it

Yes

Remove a field nobody reads

Don't notice

Yes, if truly nobody reads it

Remove totalCents

Find it missing

No

Rename totalCents to total

Find totalCents missing: a rename is a removal plus an addition

No

Change totalCents 8640 to dollars, 86.40

Read the wrong amount, or fail to parse it

No

The rules are named by direction, backward, forward or full compatibility (Schema evolution & encoding). For events, backward compatibility matters most: subscribers on the new schema must read events written with the old one, because a log keeps last month's events to be replayed, so subscribers are upgraded first.

A schema registry enforces this. Each event type's schema is stored in it, and a new version is rejected if it breaks the chosen rule; Confluent's registry, for example, checks backward compatibility by default. When a breaking change is truly needed, publish it as a new event type, such as grocery.order.placed.v2, alongside the old one. Move subscribers over one at a time, then stop publishing the old type.

5 · The process

Choreography spreads a process across services; orchestration writes it down

Notifications are the easy case: loyalty and email do their work, and nobody waits for them. Fulfillment is different. Order 3307 must be paid for, then picked, then booked onto a delivery route, in that order, with a refund if the warehouse is out of stock. There are two ways to run such a process with messages.

In choreography, each service reacts to the previous service's event and publishes its own. No component holds the whole process:

Paymentson OrderPlacedWarehouseDeliveryPaymentCapturedOrderPickedPaymentson OrderPlacedWarehouseDeliveryPaymentCapturedOrderPicked

Choreography. The process "pay, pick, deliver" exists only as a chain of subscriptions spread over three services' code.

In orchestration, one component, the orchestrator, owns the process. It sends commands, waits for the replies and decides the next step: "capture payment for 3307", then "pick 3307", then "book a route for 3307". Each service does its step without knowing the rest of the process.

Compared

Choreography

Orchestration

Where is the process?

Nowhere in particular: spread over subscriptions

In the orchestrator's code, in one place

"Where is order 3307?"

Trace events across every service

Ask the orchestrator

Changing the steps

Edit several services

Edit the orchestrator

Coupling

Low: services know only events

Services are called by the orchestrator

Risk

Hidden loops: services triggering each other in circles

The orchestrator can be a bottleneck or single point of failure

A workable rule: use events to tell the rest of the system that something happened, especially across team boundaries; use orchestration for a multistep process that one team owns, that has a required order, and that must undo steps when one fails (Distributed transactions: 2PC & sagas). Many systems use both. Fulfillment is orchestrated inside its own boundary, and it publishes "OrderDelivered" for everyone else.

6 · The price

Events trade waiting for delays, duplicates and invisible flows

The customer places order 3307 and opens the loyalty page straight away. Checkout has answered, but loyalty hasn't read the event yet:

For a moment the customer sees the old balance; the event arrives later and adds the 86 points. Each service catches up at its own pace, so for a while services disagree about the present.

That is eventual consistency, and in an event-driven system it is the normal state, not a fault. The page can say "points from today's order are on their way". Where a customer must see their own change immediately, read it from the service that owns it (Consistency models). The rest of the price comes from the messaging underneath:

Cost

What goes wrong

The usual answer

Duplicates

A redelivered OrderPlaced adds points twice

Subscribers skip event IDs they have seen (Delivery semantics)

Order

OrderCancelled is handled before OrderPlaced

Key events by order ID, so one order's events stay in order (Message queues & pub/sub)

Lost events

The order is saved, then checkout crashes before publishing

Transactional outbox (Distributed transactions: 2PC & sagas)

Invisible flows

Nobody can say why order 3307 never shipped

A correlation ID in every event, and tracing (Observability: metrics, logs & traces)

Event storms

Services reacting to each other's events in a loop

Map who publishes and subscribes to what; break cycles

None of these is unique to events, but events make each one more likely. The asynchronous gap that makes the design resilient is also where duplicates, reordering and lost messages appear, and nothing in any one service's code shows the whole flow.

7 · In practice

Use events when the sender doesn't need to know what happens next

Event-driven isn't a choice made once for a whole system. It is a choice per interaction, and the test is simple: does the sender need an answer before it can go on?

Interaction

Needs an answer first?

Use

Check that the delivery slot is still free

Yes: checkout can't confirm without it

A request, or a reservation

Receipt, points, analytics

No

An event

Pay, pick, deliver, refund on failure

Each step needs the last

An orchestrated workflow

Feed search or a data warehouse

No, and new readers may need history

Events on a log they can replay

Events aren't the right default everywhere. For a simple flow, where a direct call is fast enough and one team owns both ends, a broker adds moving parts, asynchronous failures and eventual consistency for little gain. They pay off when several consumers react to the same fact, when a consumer's outage must not stop the producer, or when teams need to add consumers without asking the producer.

When you do use them:

  • Name events as facts in the past tense, and send anything that must be done as a command.
  • Give every event an ID, a type, a source and the time it happened, plus a correlation ID for the whole flow.
  • Choose the content deliberately: enough to spare most callbacks, without spreading data that shouldn't spread.
  • Treat the schema as an API: compatible changes only, checked by a registry, and a new type for a breaking change.
  • Make every subscriber idempotent, and key events so that each order stays in order.
  • Publish through an outbox, so a saved change and its event can't separate.

Check yourself

5 questions

1. Checkout calls warehouse, loyalty, email and analytics before answering. Each of the five services is up 99.9% of the time, failing independently. About how often does checkout work?
2. Checkout publishes "ReceiptNeeded", and the order is broken if no service sends the receipt. What is the problem?
3. Loyalty receives thin OrderPlaced events and calls checkout for the details. What happens when checkout is down?
4. Checkout's team renames the field totalCents to total in OrderPlaced. What happens to subscribers that haven't been updated?
5. Fulfillment must pay, then pick, then book delivery, and refund if picking fails. The team wants to answer "where is order 3307?" in one place. What fits best?

Takeaways

Remember this

  • In event-driven architecture a service announces what happened, and the services that care react, so the publisher depends on no one.
  • An event states a fact for any number of subscribers; a command asks one service to do work. Don't dress a command as an event.
  • Thin events make subscribers call back; fat events copy data everywhere. Choose the content deliberately and give every event an ID, a type, a source and a time.
  • An event schema is a public API: make only compatible changes, enforce them with a registry, and publish a new type for a breaking change.
  • Events bring eventual consistency, duplicates, reordering and invisible flows; orchestrate multistep processes that need a required order and undo steps.