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:
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):
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
{
"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: | Ignore it | Yes |
Remove a field nobody reads | Don't notice | Yes, if truly nobody reads it |
Remove | Find it missing | No |
Rename | Find | No |
Change | 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:
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
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.