Publish / Subscribe
One event, many independent readers. Each subscriber gets its own copy; none can slow the others down. The backbone of notification fan-out and cross-team integration.
Reach for it when: multiple teams care about the same fact.
topic: field-guide.eda · partition 0
An interactive field guide to event-driven architecture. Brokers, streams, sagas, delivery guarantees, and the failure modes that only show up in production.
The event bus is live. Click any producer node to publish.
An event is a fact: something happened, at a point in time, and it cannot un-happen. OrderPlaced, PaymentCaptured, SensorOverheated. Event-driven architecture is what you get when services stop calling each other and start reacting to these facts instead.
Checkout calls Payments and blocks. If Payments is slow, Checkout is slow. If Payments is down, Checkout is down. Every new consumer of "an order happened" means another synchronous call added to the critical path, and coupling compounds.
Checkout publishes OrderPlaced and moves on. Payments, Inventory, and Email each consume at their own pace. Adding a fourth consumer touches zero existing code. Failure in one consumer strands that one workflow, not the checkout.
{
"id": "evt_01J8ZQ4T", // unique — enables dedupe
"type": "order.placed", // past tense: it already happened
"time": "2026-07-02T09:14:03Z",
"source": "checkout-service",
"subject": "order/8842",
"data": {
"orderId": "8842",
"total": 129.90,
"currency": "EUR"
}
}
OrderAmended follows OrderPlaced, and the log keeps both.PlaceOrder) asks for something. An event (OrderPlaced) reports that it happened. Mixing the two is the most common modeling mistake in the field.orderId only) forces consumers to call back for details, reintroducing coupling. A "fat" event carries the data consumers need. Most mature systems drift fat.Nearly every event-driven system in production is a combination of these six. Hover a card to watch the pattern run.
One event, many independent readers. Each subscriber gets its own copy; none can slow the others down. The backbone of notification fan-out and cross-team integration.
Reach for it when: multiple teams care about the same fact.
Events line up; a pool of workers competes to process them. Each message is handled by exactly one worker. Load-leveling for free: bursts pile into the queue instead of crushing the service.
Reach for it when: work is expensive and must happen once, like image resizing, PDF generation, or payment capture.
Don't store state. Store the events, and derive state by replaying them. Current balance = sum of every deposit and withdrawal ever. Perfect audit trail, time travel for debugging, and the ability to build views you didn't know you'd need.
Reach for it when: the history is the business, like ledgers, medical records, or legal compliance.
Split the write model from the read model, with events syncing the two. Writes stay strict and normalized; reads become denormalized views shaped exactly for each screen. The cost: the read side lags the write side. Design for that staleness or it will design itself.
Reach for it when: read and write workloads want incompatible shapes or scale.
A long business process (place order → reserve stock → charge card → ship) becomes a chain of local transactions stitched together by events. When a step fails, you don't roll back. You publish compensating events that undo earlier steps, like StockReleased and PaymentRefunded. There are two flavors. Choreography means each service reacts to the previous event, with no coordinator and a flow that's harder to see. Orchestration means a saga coordinator issues commands and tracks state, so you get one place to look and one more thing to run. Watch one run end-to-end in Scenarios ↓.
Reach for it when: a business flow spans services and "all or nothing" still matters.
A service that saves to its database and then publishes an event performs two writes. Crash between them and state and log disagree. The order exists, but no one ever heard about it. The outbox pattern removes the gap: write the state change and the event into the same database transaction (the event goes into an outbox table), then a relay (a poller or change-data-capture) reads the table and publishes to the broker. The event is published at-least-once, so consumers still need idempotency. What you gain is that a committed change can never go unannounced.
Reach for it when: an event must never be lost relative to the state it describes, which for business events is almost always.
Queues absorb bursts, until they don't. Drive the producers faster than the consumers can drain, and watch consumer lag grow. Consumer lag is the first metric to check when an event system misbehaves. Find the settings where the system breathes, then break it.
Rule of thumb: a queue that only ever grows is an outage on a delay timer. Capacity must exceed arrival rate on average. The queue's job is to smooth variance, not to fix undersizing.
Networks drop packets, consumers crash mid-message, acks get lost. Every broker forces a choice about what happens next. There's no free option. Each mode trades message loss, duplication, or added complexity.
* "Exactly-once delivery" over a network is impossible (Two Generals problem). What systems like Kafka transactions actually provide is effectively-once processing: at-least-once delivery plus idempotent handling. You dedupe on event.id, idempotency keys, or transactional offsets. The dedupe box in this animation is doing the real work, and it belongs in every consumer you ship.
Real architectures, played one event at a time. Step through each flow, including the step where something goes wrong.
Event-driven architecture trades one set of problems for another. The trade is often worth it. It is never free.
Skip events entirely when: the workflow is a simple CRUD form, one team owns the whole flow, the caller genuinely needs the answer right now (auth checks, price quotes), or your team hasn't built the observability muscle yet. A synchronous monolith you can debug beats a distributed system you can't.
The broker decides your semantics: replay or not, ordering or not, push or pull. Pick by workload, not by fashion.
| Broker | Model | Replay | Ordering | Sweet spot |
|---|---|---|---|---|
| Apache Kafka | Distributed log, consumers pull & track offsets | Yes. Retention is a config, not a delete | Per partition | Event streaming, event sourcing, high-throughput pipelines, "the log is the system of record" |
| RabbitMQ | Smart broker, queues + flexible routing, push | No. Consumed means gone (streams plugin excepted) | Per queue | Task queues, complex routing topologies, RPC-over-messaging, modest scale with rich semantics |
| AWS SNS + SQS | Managed fan-out (SNS) into managed queues (SQS) | No (limited via FIFO + DLQ redrive) | FIFO queues only | Serverless architectures, zero-ops teams, spiky workloads billed per request |
| NATS JetStream | Lightweight subjects + optional persistent streams | Yes, with JetStream enabled | Per stream | Edge/IoT, low-latency service mesh messaging, small footprint deployments |
| Apache Pulsar | Segmented log, compute/storage separated, multi-tenant | Yes, tiered storage offloads to object stores | Per partition | Multi-tenant platforms, geo-replication, queue + stream semantics in one system |
Sensible default: if you need replay and scale, start with Kafka or a managed clone. If you need work queues and routing, RabbitMQ or SQS. If you're all-in on one cloud, its native broker usually wins on operational cost, and the exotic choice has to earn its keep.
Producers and consumers never meet. The event schema is the only contract between them, and the only thing a producer can break for a team it's never spoken to. Mature event platforms treat schemas the way service teams treat REST APIs: described in a spec, versioned deliberately, and checked in CI.
asyncapi: 3.0.0
info:
title: Order events
version: 1.2.0
channels:
order.placed:
messages:
OrderPlaced:
payload:
type: object
required: [id, type, time, data]
properties:
id: { type: string }
time: { type: string, format: date-time }
data: { $ref: '#/components/schemas/Order' }
order.placed.v2) with both published during migration.| Change to the schema | Verdict | Why |
|---|---|---|
| Add an optional field | Safe | Old consumers ignore fields they don't know; new consumers get the extra data. |
| Add a new event type to a topic | Usually safe | Consumers should skip unknown types, so verify yours actually do before relying on it. |
| Add a required field | Breaking | Every already-published event lacks the field; replays and lagging consumers fail validation. |
| Rename or remove a field | Breaking | Consumers reading the old name get nothing. A rename is a remove plus an add, so both ends break. |
| Change a field's type or narrow an enum | Breaking | Deserialization fails on the consumer side, typically at runtime and rarely where you're looking. |
Default posture: additive-only evolution, compatibility checked by the registry in CI, and a major-version topic when a redesign is unavoidable. Consumers must tolerate unknown fields from day one, because that single habit buys most of the flexibility.
A topic scales by splitting into partitions, and everything subtle about event systems lives in that split: ordering holds within one partition, never across the topic. The partition key is therefore a modeling decision disguised as a config value. It decides which facts the system is allowed to see out of order.
topic orders // key = orderId → same order, same lane
├─ p0 #8842 #8842 #9011 consumer A
├─ p1 #7734 #7734 #7734 #8100 consumer A
├─ p2 #6621 #9455 consumer B
└─ p3 #5590 #5590 consumer B
group billing // 2 consumers share 4 partitions
group analytics // reads the same log, own offsets
rebalance: consumer B leaves →
p2, p3 reassigned to A · processing pauses · resumes
from committed offsets — duplicates possible, loss not
orderId. Key on customerId and one big customer becomes a hot partition, one lane jammed while the rest idle. The right key is the entity whose story must read in order.orderId).