Event-Driven Architecture
A style where components communicate by producing and reacting to events — facts about things that already happened — instead of calling each other directly. The producer doesn’t know who consumes the event, or whether anyone does.
This file is the architecture overview. For the saga/workflow side see 15_event_driven_saga.md; for EDA specifically across services see ../14_microservices/08_event_driven_microservices.md; for events-as-state see 13_cqrs_event_sourcing.md.
Event vs command vs query
The single most common interview confusion. They are different message kinds.
| Kind | Tense | Intent | Recipients | Coupling |
|---|---|---|---|---|
| Event | past (OrderPlaced) |
“this happened” | 0..N subscribers | producer doesn’t know consumers |
| Command | imperative (PlaceOrder) |
“do this” | exactly 1 handler | sender knows the target |
| Query | interrogative (GetOrder) |
“tell me” | 1 responder | synchronous, expects a reply |
Events are immutable facts. A command can be rejected; an event cannot — it already occurred. EDA is about events; commands and queries usually stay synchronous (direct call / RPC).
Core components
┌──────────┐ publish ┌────────┐ deliver ┌──────────┐
│ Producer │ ───────────► │ Broker │ ──────────► │ Consumer │
└──────────┘ event └────────┘ └──────────┘
(topic / stream) (subscription)
- Producer emits events; doesn’t wait for consumers.
- Broker (Kafka, RabbitMQ, NATS, SNS+SQS, Redis Streams) decouples in time and space.
- Topic/stream is a named channel of related events.
- Consumer subscribes and reacts; multiple independent consumers can read the same event.
Two topologies
| Broker (pub/sub) | Mediator / orchestrator | |
|---|---|---|
| Coordination | none — consumers react independently | a central component drives the steps |
| Flow | implicit, emergent | explicit, in one place |
| Coupling | lowest | consumers couple to the mediator |
| Best for | fan-out notifications, simple reactions | multi-step workflows with ordering and rollback |
The mediator topology is where EDA meets sagas — see 15_event_driven_saga.md.
Event payload patterns
How much data to put in the event:
# 1. Event notification — just the ID. Consumer fetches details if needed.
OrderPlaced(order_id=42)
# consumer: GET /orders/42 → small events, but load + coupling on producer's API
# 2. Event-carried state transfer — everything consumers need.
OrderPlaced(order_id=42, email="a@b.c", items=[...], total=99.5)
# consumer: self-sufficient → bigger events, consumers stay autonomous
# 3. Event sourcing — the events ARE the state; rebuild by replay.
# See 13_cqrs_event_sourcing.md
Event-carried state transfer is what lets services avoid synchronous “phone-home” calls — central to event-driven microservices.
Delivery semantics — and why idempotency is mandatory
Brokers deliver at-least-once in practice. Network blips and consumer restarts mean consumers will see duplicates. Exactly-once is mostly a marketing word; you achieve its effect with idempotent consumers.
def handle(event):
if processed.exists(event.id): # dedupe on event id
return
with db.transaction():
do_work(event)
processed.add(event.id)
Without this, every duplicate is a duplicate side effect — a double charge, a double email.
Ordering
Order is only guaranteed within a partition (Kafka) or a single queue (RabbitMQ), not globally. Pick a partition key (e.g. order_id) so all events for one entity land in order. Cross-entity global ordering is not something to rely on — design consumers to tolerate reordering.
Schema evolution
The producer’s event shape is a public contract. Change it carelessly and every consumer breaks.
- Add fields as optional; never repurpose or remove a field in place.
- Use a schema system with compatibility checks: Avro/Protobuf + a schema registry, or versioned JSON Schema.
- Version the event type (
OrderPlaced.v2) when a breaking change is unavoidable.
The dual-write problem
A handler that writes its DB and publishes an event has two writes that can’t be made atomic — if the publish fails after the commit, the event is lost (or vice versa).
def place_order(o):
db.insert(o) # commit succeeds
broker.publish(OrderPlaced(o.id)) # ...then this fails → event lost
The fix is the transactional outbox: write the event to an outbox table in the same transaction, then a separate relay publishes it. See 16_transactional_outbox.md.
When to use
- Fan-out: one fact, many independent reactions (email, analytics, search index, audit).
- Decoupling teams/services that evolve and scale independently.
- Buffering load spikes — the broker absorbs bursts.
- Audit trail / replay — the event log is a natural history.
When not to
- You need an immediate answer in the same request → use a synchronous call/query.
- Strong, single-database consistency is required → a local ACID transaction is simpler than eventual consistency.
- Simple CRUD with one consumer → events add latency and operational weight for no gain.
- The team can’t yet operate a broker, handle duplicates, and trace async flows → the complexity will bite.
Common pitfalls
- No idempotency — at-least-once delivery turns every retry into a duplicate side effect.
- “Synchronous events” — producer blocks on all consumers; that’s just RPC with extra steps. Events are fire-and-forget.
- Fat shared event schemas — every consumer couples to the full payload; one change breaks everyone. Evolve compatibly.
- Choreography for complex flows — logic scattered across services becomes unfollowable. Past ~3-4 steps, orchestrate (saga).
- Lost events — DB write and publish not made consistent. Use the outbox.
- No correlation IDs — async flows are unobservable without a trace/correlation id threaded through events.
Interview angle
- “What is event-driven architecture?” — components communicate by publishing immutable past-tense events to a broker and reacting to them, instead of direct synchronous calls. Decouples producers from consumers in time and space; enables fan-out, independent scaling, and resilience.
- “Event vs command?” — an event is a fact (“OrderPlaced”, past tense, 0..N consumers, producer unaware of them); a command is an instruction (“PlaceOrder”, imperative, one handler, can be rejected).
- “Choreography vs orchestration?” — choreography: services react to each other’s events, no coordinator, decoupled but the flow is implicit. Orchestration: a central component drives the steps, explicit and auditable but coupled to it. Use orchestration once a workflow has several ordered steps with rollback.
- “Why is idempotency non-negotiable in EDA?” — brokers deliver at-least-once, so consumers see duplicates; without idempotent handling each duplicate repeats the side effect (double charge). Dedupe on event id or use natural idempotency keys.
- “How do you publish an event consistently with a DB write?” — the dual-write problem; solve it with the transactional outbox (write event + data in one transaction, relay publishes) or CDC, not a naive write-then-publish.