Backend / Architecture & design / 19_event_driven_architecture.md

Event-Driven Architecture

Updated 5 interview angles 5 min read source
On this page12
  1. Event vs command vs query
  2. Core components
  3. Two topologies
  4. Event payload patterns
  5. Delivery semantics — and why idempotency is mandatory
  6. Ordering
  7. Schema evolution
  8. The dual-write problem
  9. When to use
  10. When not to
  11. Common pitfalls
  12. Interview angle

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 Sagas and compensation; for EDA specifically across services see Event-Driven Microservices; for events-as-state see CQRS and Event Sourcing.

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 them
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

text
┌──────────┐   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 Sagas and compensation.

Event payload patterns

How much data to put in the event:

python
# 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.

python
def handle(event):
    # dedupe on event id
    if processed.exists(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).

python
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 The transactional outbox.

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 5

  • “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.