Knowledge Guide
HomeSystem DesignMicroservices Patterns

Event-Driven Architecture A Promising Solution

Event-driven architecture works because the producer writes a fact — an immutable record that something happened — to a shared broker and then forgets about it, while any number of consumers read that fact on their own schedule; the broker's durability and its offset bookkeeping are what let a fire-and-forget write fan out to services the producer has never heard of, without the producer blocking on any of them.

That single mechanism — write a fact, let readers pull it independently — is the whole idea. Everything hard about EDA (ordering, duplicates, eventual consistency, the dual-write problem) falls out of the fact that the write and the reads are now separated in time and across process boundaries. This page traces one real event end to end, then confronts each of those hard parts head-on.

Bus vs. queue: the distinction that matters

The old version of this page called the event bus "also known as the message queue." That conflation hides the single most important design decision in EDA, so let's be precise:

EDA's fan-out — one photo-upload event triggering notifications, feed updates, and moderation — is a pub-sub property. A point-to-point queue gives you decoupling and async, but not fan-out. Calling them the same thing is the mistake that gets a design review sent back. The rest of this page assumes a retained log (Kafka-style), because that is what makes replay, multiple consumer groups, and offset-based ordering possible.

Tracing one event: PhotoUploaded

User u_812 uploads a photo. The Upload service publishes one event to the photo.uploaded topic. Three unrelated consumer groups react. Here is the actual byte-level trace, with real offsets and keys.

The event (JSON payload the producer writes):

topic:     photo.uploaded
partition: key = "u_812"  -> hash -> partition 2
offset:    2  (the 3rd record ever written to partition 2)

{
  "event_id":   "evt_9f3a",     // unique — used for dedup
  "event_type": "PhotoUploaded",
  "user_id":    "u_812",
  "photo_id":   "ph_55",
  "occurred_at":"2026-07-02T10:04:12Z",
  "version":    1
}

Step-by-step trace of what each actor does:

#ActorActionState after
1Upload svc (producer)Appends record to photo.uploaded. Broker acks once replicated. Producer returns to the user in ~3 ms — does not wait for any consumer.partition 2 log-end = offset 3
2BrokerRecord sits durably in the log. Three consumer groups are subscribed; each has its own committed offset for partition 2.notif@1, feed@2, mod@0
3notif-svc groupPolls, reads offset 2, sends push to u_812's 340 followers, commits offset 3.notif offset -> 3
4feed-svc groupPolls, reads offset 2, inserts into fan-out feed tables. Commits offset 3.feed offset -> 3
5mod-svc groupWas lagging (offset 0). Reads offsets 0,1,2 in order, runs the image classifier on ph_55. Commits offset 3.mod offset -> 3

Notice what the decoupling bought and cost: the producer finished in 3 ms regardless of the fact that moderation took 900 ms and was three records behind. The producer has no idea notif-svc or mod-svc exist. Adding a fourth consumer (say, analytics) tomorrow requires zero changes to the producer — it just subscribes and starts reading. That is the payoff. The cost is that at step 1 the photo is uploaded but not yet reflected in feeds or notifications: the system is eventually consistent, and every consumer above can crash, retry, and re-deliver — which is where the hard parts begin.

diagram
diagram

Delivery semantics: the trade-off you cannot avoid

The naive mental model is "the event gets delivered." In a distributed system, "gets delivered" is a choice among three guarantees, and you cannot have the strongest one for free:

GuaranteeWhat can go wrongWhat it costs
At-most-onceCommit offset before processing. If you crash mid-work, the event is lost forever.Cheap, no duplicates — but drops events. Fine for a metrics ping, fatal for a payment.
At-least-once (the default in Kafka, SQS, Pub/Sub)Process then commit offset. If you crash after processing but before committing, the event is redelivered — you see it twice.No loss, but duplicates are guaranteed to happen. Every consumer MUST be idempotent.
Exactly-onceOnly within a broker's transactional boundary (Kafka transactions), or emulated via idempotent consumers.Extra coordination, higher latency, and it does not extend to external side effects (sending an email twice can't be un-sent by the broker).

The practical reality: you run at-least-once and make your consumers idempotent. In the trace above, if notif-svc processes offset 2, sends 340 pushes, then crashes before committing offset 3, it will re-read offset 2 on restart and send 340 pushes again. The fix is a dedup key:

// Consumer side — idempotency via the event's own id
seen = redis.setnx("proc:" + event.event_id, 1)  // atomic
if (!seen) return;          // already handled — skip, still commit offset
redis.expire("proc:" + event.event_id, 7*24*3600) // match retention
handle(event)
commitOffset()

Why the naive version is wrong: a consumer that just does handle(event); commitOffset() with no dedup key double-sends on every crash-retry and every consumer-group rebalance. Redelivery is not an edge case in at-least-once systems — it is the contract. Idempotency is not optional polish; it is the price of admission.

The dual-write problem (the bug that eats EDA teams)

Here is the trap almost every team walks into. The Upload service needs to (a) save the photo row to its own database and (b) publish PhotoUploaded to the broker. The tempting code:

// WRONG — two independent writes, no shared transaction
db.insert(photo);           // write 1: local Postgres
broker.publish(event);      // write 2: Kafka

These are two separate systems with no shared commit. Four things can happen, and two of them corrupt your system:

You cannot fix this by reordering the two lines — you just move which failure you get. There is no ordering of two non-atomic writes that is safe. The standard fix is the Transactional Outbox: write the event into an outbox table in the same local DB transaction as the business row, so they commit atomically. A separate relay process (or Change-Data-Capture tailing the DB log, e.g. Debezium) reads the outbox and publishes to the broker, marking rows as sent.

BEGIN;
  INSERT INTO photos(id, user_id, ...) VALUES ('ph_55','u_812',...);
  INSERT INTO outbox(event_id, type, payload, sent)
         VALUES ('evt_9f3a','PhotoUploaded', '{...}', false);
COMMIT;               -- both or neither

-- relay, decoupled, at-least-once to the broker:
for row in SELECT * FROM outbox WHERE sent=false:
    broker.publish(row.payload)     // may double-send on relay crash
    UPDATE outbox SET sent=true WHERE event_id=row.event_id

The outbox turns two unsafe writes into one atomic DB transaction plus one idempotent relay — and the relay double-sending is fine precisely because consumers are already idempotent. This is why the two hard parts (delivery semantics and dual-write) are really one problem viewed from both ends.

Pitfalls

When to use it — and when NOT to

Reach for EDA when the concrete signals line up: one action must fan out to many independent reactions (the photo-upload case), consumers should scale and fail independently, you want an audit/replay log of what happened, or the producer genuinely should not wait for downstream work. If you can't name a real fan-out or a real need to decouple deploys, you probably don't need it yet.

vs. synchronous request/response (REST/gRPC): Direct calls give you an immediate answer, a strong consistency story, and a stack trace when things break — you know now whether it worked. EDA trades all of that for decoupling and fan-out: the producer learns nothing about success, and correctness becomes eventual. Choose request/response when the caller needs the result to proceed (a checkout must know the card was charged). Choose EDA when the reactions are fire-and-forget and multiple.

vs. a point-to-point message queue: A plain work queue (SQS, RabbitMQ work queue) gives you async and load-leveling with far less operational weight than a retained log — no partitions, offsets, or consumer-group coordination to reason about. But it delivers each message to one worker and deletes it: no fan-out, no replay, no new-consumer-joins-later. Choose a queue when it's one producer handing tasks to a pool of identical workers. Choose a pub-sub log when multiple different services each need every event, or you need replay.

vs. orchestration (a Saga coordinator / workflow engine): Pure event choreography (services reacting to each other's events with no central brain) is beautifully decoupled but the end-to-end business flow exists nowhere — you can't point at the code that describes "place order." An orchestrator (Temporal, a Saga coordinator) makes the flow explicit and easy to reason about at the cost of re-introducing a coupling point. Choose choreography for loosely related reactions; choose orchestration when a multi-step business transaction needs coordinated compensation and a single place to see its state.

What EDA costs in every case: eventual consistency you must design around, mandatory idempotency, the dual-write problem, ordering caveats, and a whole new class of observability (lag, DLQs, distributed tracing). If your system is a modest CRUD app with synchronous needs, that operational tax buys you nothing — and "we might scale someday" is not a signal, it's a guess.

Takeaways


Re-authored and deepened for this guide. Sources: Sam Newman, Building Microservices, 2nd ed. (event-driven collaboration, choreography vs. orchestration); Chris Richardson, Microservices Patterns and microservices.io (Transactional Outbox, Saga, event sourcing); Martin Kleppmann, Designing Data-Intensive Applications (logs, delivery semantics, dual writes, exactly-once); Apache Kafka documentation (partitions, offsets, consumer groups, transactions); Debezium documentation (change-data-capture / outbox relay).

🤖 Don't fully get this? Learn it with Claude

Stuck on Event-Driven Architecture A Promising Solution? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🪜 Hint ladder (no spoilers)

Progressively stronger hints — you still solve it.

I'm working on the problem **Event-Driven Architecture A Promising Solution** (System Design). Give me a HINT LADDER: start with the tiniest nudge, then wait. Only reveal the next, stronger hint when I ask. Do NOT show the full solution unless I type 'show solution'. Keep me doing the thinking. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🎨 Explain the approach visually

See the technique, not just code.

Explain the optimal approach to **Event-Driven Architecture A Promising Solution** with a VISUAL walkthrough: trace it on a small concrete example using ASCII art / a step-by-step diagram, narrate what changes each step, then give time & space complexity with a one-line derivation. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔍 Review my solution

Catch bugs, edge cases, sub-optimality.

I'll paste my solution to **Event-Driven Architecture A Promising Solution**. Review it for correctness, missed edge cases, and time/space complexity, then coach me toward the optimal — don't just rewrite it. Ask me to paste my code now. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔁 Drill the pattern

Lock in recognition with look-alikes.

Give me 2 problems that use the SAME underlying pattern as **Event-Driven Architecture A Promising Solution**. For each, let me attempt first, then review my answer and name the trigger signal that reveals the pattern. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes