backend / architecture design / 19_event_driven_architecture.md

Event-Driven Architecture

5 interview angles 5 min read source

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.