Event-Driven Architecture
Event-driven architecture decouples producers from consumers through an event backbone: services publish facts about what happened, and interested parties react asynchronously. It enables loose coupling, elastic scaling and replayable history at the cost of new failure modes and eventual consistency.
What is this?
Event-driven architecture (EDA) coordinates services by exchanging immutable facts about what happened — order.created, payment.received — rather than by making synchronous requests. Producers publish events to a broker (Kafka, RabbitMQ, SNS, EventBridge) and consumers subscribe to the ones they care about. The producer does not know who, if anyone, will react.
Why do you need it?
- Loose coupling: producers and consumers evolve independently because neither knows the other's interface or availability.
- Elastic, decoupled scaling: you can scale consumers independently to absorb traffic spikes without touching producers.
- Replayable history: a log of events can rebuild read models, audit trails or test fixtures at any point in time.
- Better resilience: a downstream outage never blocks the primary flow — events buffer and process later.
- Domain clarity: the event stream becomes a shared, precise record of everything the system did.
Mental model
A town crier shouting news
A publish/subscribe event bus
The crier does not walk door to door; each household decides whether to listen and what to do. Producers publish facts, brokers deliver them at least once, and consumers react at their own pace — no one waits for a specific listener to answer.
How it works
- A producer records that something happened by publishing an event to a topic on a broker.
- The broker persists the event and delivers it to every subscribed consumer, usually with at-least-once semantics.
- Each consumer processes events independently and tracks its own position (offset) in the stream.
- Downstream state is derived from events rather than written by the producer, so consumers can rebuild or replay state.
- Outbox and saga patterns keep the local database and the event stream consistent across the system.
A producer publishes once; the broker fans out to many independent consumers.
Key commands
docker run -d --name kafka -p 9092:9092 apache/kafka-nativeStart a Kafka broker locally for development.
kafka-topics.sh --create --topic order.created --bootstrap-server localhost:9092Create the topic that producers publish to.
Created topic order.created.
kafka-console-producer.sh --topic order.created --bootstrap-server localhost:9092Manually publish a test event to the topic.
kafka-console-consumer.sh --topic order.created --from-beginning --bootstrap-server localhost:9092Consume and print every event on the topic from the start.
curl -X POST localhost:8080/orders -d '{"customer":"ada"}'Trigger a real event by invoking the order service.
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group payments --describeInspect a consumer group's lag to find slow or stuck consumers.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
Examples
Publishing an event through a transactional outbox
// order-service/placeOrder.jsimport { pool } from "./db" export async function placeOrder(db, publish, input) { const client = await pool.connect() try { await client.query("BEGIN") const order = await client.query( "INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING id", [input.customer, input.total] ) await client.query( "INSERT INTO outbox (aggregate_type, aggregate_id, type, payload) VALUES ('order', $1, 'order.created', $2)", [order.rows[0].id, JSON.stringify(order.rows[0])] ) await client.query("COMMIT") await publish(outboxRow) // relayed async by the outbox poller } catch (err) { await client.query("ROLLBACK") throw err } finally { client.release() }}The order and the outbox row are written in one database transaction, so the event cannot be lost if the publish fails. A separate relay reads the outbox, publishes to the broker, and marks the row dispatched — that is what makes the database and the event stream agree.
In production
- Payment providers react to order.placed events instead of holding the request open waiting for an answer.
- Analytics teams subscribe to the same stream to build data pipelines without the producers knowing.
- Inventory projections rebuild read models from event history after a schema migration.
- IoT fleets push telemetry events; downstream alerting and storage scale independently of the devices.
Best practices
- Name events in the past tense with a shared schema (event.aggregate.action) and version them from day one.
- Use the transactional outbox pattern so the database and the event stream cannot diverge.
- Design consumers to be idempotent: at-least-once delivery means the same event may arrive twice.
- Keep events immutable and compactable, and treat the topic as the system of record, not a cache.
- Put a timeout budget and a DLQ (dead-letter queue) around every consumer so poison messages cannot stall the group.
Common mistakes
- Sending events as afterthought HTTP calls from business logic, losing the transactional guarantee of the outbox.
- Designing synchronous event flows where a consumer blocks waiting for another event that may never come.
- Treating events as commands (order.ship it) instead of facts (order.shipped), which re-couples the services.
- Ignoring idempotency and replaying events into duplicates in downstream databases.
Common issues
Consumer group stuck: lag never decreasesA poison message or a blocking operation in the consumer loop.
Enable dead-lettering, add a per-message timeout, and inspect the failed payload's schema.
Duplicate events in the databaseAt-least-once delivery combined with a non-idempotent consumer.
Make the consumer idempotent with a unique event_id constraint or an upsert keyed by the event.
Event lost after a deployThe producer committed the DB transaction but the outbox relay never dispatched the row.
Retry the outbox relay with exponential backoff and a dispatcher that re-attempts undispatched rows.
Debugging tips
- Check consumer lag first — a growing lag means the consumer is slower than the producer or is stuck.
- Capture the event schema, producer and timestamp on the wire; most bugs are version mismatches.
- Replay a topic from the beginning in a staging environment to reproduce state drift.
- Look at the DLQ for poison events and inspect the exact payload that failed deserialization.
Interview checkpoint
Hands-on lab
- Run a Kafka broker locally with Docker
- Publish events from a producer script
- Consume events with a consumer group
- Inspect and replay history and consumer lag
Publish, Consume and Replay Events
50 min · advanced
Quick quiz
Which pattern guarantees that a database transaction and its event publication cannot diverge?
An event-driven producer must know exactly which consumers will react to its events.
Your consumer processes each event by sending an email. The broker redelivers an event after a timeout, and customers receive duplicate emails. What is the correct fix?
Cheat sheet
kafka-topics.sh --create --topic <t>Create a topickafka-console-producer.shPublish a test eventkafka-console-consumer.sh --from-beginningRead full historyconsumer-groups --describe --group <g>Check consumer lagkafka-configs.sh --alter --entity-type topicsChange retentionIdempotent consumerDedupe by event_idOutboxDB and stream agreeDLQPoison events never stallVersioned schemaEvolve without breaking