Synchronous request/response is simple to reason about until one downstream service stumbles and the entire checkout flow freezes. Event-driven architecture shifts the contract from blocking commands to asynchronous facts. That model breaks down when one action must fan out to many independent systems, when those systems scale at different rates, or when a temporary outage in one dependency should not block the whole checkout path.
Event-driven architecture shifts the contract: a producer publishes a fact about something that already happened, and consumers react in their own time. You gain decoupling and horizontal scale. You pay with eventual consistency, harder debugging, and failure modes that do not show up in a single stack trace.
This post is a backend engineer's map of the ideas that matter on high-demand systems: the event/command split, brokers, idempotent consumers, the outbox pattern, and the cases where events are the wrong tool.
What "event-driven" actually means
In an event-driven design, components communicate by emitting and reacting to events, usually through a message broker or a log. Producers do not know who consumes. Consumers do not call the producer back for the same fact. Coupling moves from "I must know your API and be online when I call you" to "I must agree on a message schema and on how to recover when delivery is delayed or duplicated."
Typical high-demand shapes:
| Shape | Example | Why events help |
|---|---|---|
| Fan-out after a write | Order placed → inventory, email, analytics, loyalty | One write, many independent side effects |
| Async handoff | Upload finished → virus scan → thumbnail → search index | Slow work leaves the request path |
| Integration | Payment webhook → ledger + notification | External systems arrive on their schedule |
| Stream processing | Click stream → fraud score → feature store | Continuous, high volume, low per-item work |
Events do not mean "replace every HTTP API with a queue." Most systems stay hybrid: synchronous for user-facing reads/writes that need an immediate answer, asynchronous for work that can complete later.
Events vs commands
People mix these two. They are different contracts.
Event: a fact that happened
An event is past tense and immutable once published. The producer already committed a state change (or at least decided the fact is true). Consumers may:
- update their own models
- trigger workflows
- ignore the event if it is not relevant
Examples:
{
"type": "OrderPlaced",
"eventId": "evt_01J8K2...",
"occurredAt": "2026-02-04T10:15:30Z",
"orderId": "ord_9f3a",
"customerId": "cus_12",
"totalCents": 4599,
"currency": "USD"
}
Naming stays past tense: OrderPlaced, PaymentCaptured, UserEmailChanged. The payload should carry enough data for consumers to act without calling back into the producer for every field (within reason). Thin events that only contain an id force every consumer into a chatty read path under load.
Command: an instruction to do something
A command is imperative. A sender wants a specific receiver to perform an action. It may be accepted, rejected, or fail. The outcome is not yet a fact.
Examples: PlaceOrder, ChargeCard, ReserveInventory.
Commands often travel on queues with a single logical consumer group (or a known worker type). Events often go to topics with many independent subscribers.
| Event | Command | |
|---|---|---|
| Tense | Past (OrderPlaced) |
Imperative (PlaceOrder) |
| Ownership of outcome | Already decided by producer | Decided by the handler |
| Coupling | Producer ignores consumers | Sender targets a responsibility |
| Fan-out | Natural (many subscribers) | Usually one handler type |
| Failure | Consumers retry their own work | Command may need reject / compensate |
In practice, a command handler that succeeds often emits an event. PlaceOrder succeeds → OrderPlaced is published. That split keeps write intent and broadcast of facts separate.
Brokers and logs: what you are actually buying
The broker (or log) is the shared infrastructure between producers and consumers. The product choice matters less than the delivery semantics you design for.
Common options (mental model, not a bake-off)
| System | Model | Good default for |
|---|---|---|
| Kafka / Redpanda | Append-only log, consumer offsets, partitions | High throughput, replay, many consumer groups |
| RabbitMQ | Queues, exchanges, routing keys | Work queues, complex routing, lower volume |
| SQS (+ SNS) | Managed queues / fan-out | AWS-native workers, simple ops |
| NATS / JetStream | Lightweight messaging + optional persistence | Low latency, simpler topologies |
| Google Pub/Sub | Managed topics/subscriptions | GCP-native event fan-out |
Delivery guarantees you will live with
Almost every production broker gives you at-least-once delivery in the failure paths that matter. A consumer can crash after processing but before acknowledging. The same message comes back.
Design for:
- At-least-once as the baseline.
- Idempotent consumers (next section).
- Ordering only where you need it (usually per aggregate key, e.g.
orderId), not global order across the whole system. - Retention / replay if you need to rebuild a consumer or recover from a bug.
"Exactly-once" marketing usually means a careful combination of transactional producers, idempotent consumers, and broker features. Treat it as a property of the whole pipeline, not a checkbox on a product page.
Topics, partitions, and keys
For log-based systems:
- Put events that must stay ordered for one entity on the same partition key (e.g.
orderId). - Keep partitions balanced. A single hot key becomes a single hot partition.
- Separate public integration topics (stable schemas) from internal ones you can break more freely.
For queue-based systems:
- Prefer competing consumers on a work queue for parallel processing.
- Use dead-letter queues (DLQ) for poison messages after N failures.
- Cap concurrency so a spike does not melt the database behind the workers.
Idempotency: consumers will see duplicates
If you only remember one operational rule: every consumer must tolerate the same event twice.
Duplicates show up when:
- the broker redelivers after a crash or network blip
- a producer retries a publish that actually succeeded
- you reprocess a partition after a bug fix
- at-least-once meets a slow acknowledge
Practical patterns
1. Idempotency key stored before side effects
Use a stable id from the message (eventId, or a natural key like paymentId + status). In the same database transaction as your write:
INSERT INTO processed_events (event_id, consumer, processed_at)
VALUES ($1, 'inventory-service', now())
ON CONFLICT (event_id, consumer) DO NOTHING;
-- if insert did nothing, skip business work
If the insert wins, apply the business change in the same transaction. If it loses, you already handled this event.
2. Natural idempotency in the domain
Some writes are inherently safe to repeat:
SET status = 'shipped' WHERE order_id = $1 AND status = 'paid'- Upsert by primary key with the same payload
- "Add item if missing" rather than "always increment"
Prefer domain checks when they fit. They read cleaner than a giant side table for every micro-update.
3. Outbound side effects (email, webhooks, charges)
External APIs are the hard part. A second send can double-charge or spam a user.
- Pass a client request id / idempotency key to providers that support it (payment APIs often do).
- Record "notification already sent for this event" before or after the call with a clear rule for partial failure.
- Prefer "send once" tables over fire-and-forget in the consumer loop.
What not to do
Do not rely on "the broker said exactly once." Do not use only in-memory "I already saw this" sets on a multi-instance consumer. Do not treat message order as a substitute for idempotency; reordering and redelivery both happen under load.
The dual-write problem and the outbox
Here is the classic failure:
1. BEGIN; INSERT order; COMMIT;
2. publish OrderPlaced to broker
If step 2 fails after commit, the order exists and no consumer ever hears about it. If you reverse the order and the DB write fails after publish, consumers process a ghost order.
Publishing inside the same DB transaction is not available on most brokers. Two independent systems cannot share one atomic commit without help.
Transactional outbox
Write the business row and an outbox row in the same database transaction. A separate process (or CDC) publishes outbox rows to the broker, then marks them sent.
BEGIN;
INSERT INTO orders (id, customer_id, total_cents, status)
VALUES ($1, $2, $3, 'placed');
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES ($4, 'order', $1, 'OrderPlaced', $5::jsonb, now());
COMMIT;
Relay loop (simplified):
1. SELECT pending outbox rows (FOR UPDATE SKIP LOCKED)
2. publish to broker
3. mark published_at (or delete)
Properties you want:
| Concern | Approach |
|---|---|
| Atomicity of state + intent to publish | Same DB transaction |
| No lost events after commit | Relay retries until publish succeeds |
| No stuck rows under concurrency | SKIP LOCKED, batch size limits |
| Duplicate publish still possible | Consumers stay idempotent |
| Observability | Metrics on outbox lag, oldest unsent age |
CDC as an outbox variant
Change Data Capture (Debezium and friends) tails the database log and turns row changes into events. Same idea: the source of truth is the commit log, not a best-effort app publish after commit. You still design schemas, filters, and consumer idempotency.
Inbox (optional mirror)
Some teams also use an inbox table on the consumer side as the durable "I received event X" store, then process from there. Same theme: make the "handled" marker transactional with the domain write.
Failure, retries, and poison messages
High-demand systems fail in partial ways. Design the consumer loop as if every dependency can time out.
- Retry with backoff for transient errors (DB lock, network blip). Cap attempts.
- DLQ after N failures so one bad payload does not block the partition or queue forever.
- Alert on DLQ depth and outbox lag. Silent lag is worse than a loud failure.
- Make handlers short. Long handlers increase the chance of redelivery mid-flight.
- Separate "process event" from "call flaky third party" when you can: process fast, enqueue a dedicated job for the flaky call.
For Kafka-style logs, a stuck consumer on a poison message can stall the whole partition. That is why DLQ (or skip-and-metric with care) is not optional at scale.
Schema and evolution
Loose JSON without a contract turns into production pain after the third consumer.
Practical rules:
- Version the event type or schema (
OrderPlaced.v1, or aschemaVersionfield). - Prefer additive changes: new optional fields. Avoid renaming or repurposing fields.
- Use a registry (Avro/Protobuf/JSON Schema) when many teams share topics.
- Document which fields are required for correctness vs convenience denormalization.
- Do not put secrets in event payloads. Events are often retained and widely readable inside the org.
When a breaking change is unavoidable, run dual-publish for a while or stand up a new topic and migrate consumers deliberately.
When not to use event-driven design
Events are a trade-off, not a promotion. Skip or limit them when:
| Situation | Prefer instead |
|---|---|
| User needs an immediate, correct answer in the same request | Synchronous API + DB transaction |
| One team owns one deployable and there is no fan-out | In-process calls or a modular monolith |
| Strong consistency across multiple aggregates is required in one click | Single transaction boundary, or sagas only if you accept complexity |
| Team has no ops for brokers, lag metrics, DLQs, schema review | Simpler architecture until you can operate the plumbing |
| Debugging skill is thin and traffic is low | Request/response is easier to trace end to end |
| You only need a nightly report | Batch job, not a real-time topic |
Also avoid "distributed monolith over Kafka": every service still needs every other service's events to complete a single user action, with no clear ownership. You get the failure modes of distributed systems without the isolation benefits.
A useful test: if losing the broker for 10 minutes would make the core product unusable rather than delayed on side effects, you may have put critical path logic on the wrong transport.
A minimal production checklist
Before you ship an event path on a high-demand flow:
- Event vs command is named and owned correctly.
- Schema is documented; consumers know required fields.
- Producer uses outbox (or CDC), not dual-write hope.
- Consumer is idempotent for at-least-once delivery.
- Ordering is defined per key if it matters; not assumed globally.
- Retries + DLQ + lag alerts exist and are tested.
- Backpressure: consumers and DB pools will not melt under a replay or traffic spike.
- You know how to reprocess a day of events after a bug without double-charging users.
Closing
Event-driven architecture earns its place when independent systems must react to the same facts at different speeds, and when request paths cannot wait for every side effect. The engineering cost is real: brokers, schemas, idempotency, outboxes, and lag monitoring are part of the feature, not extras.
Start with one clear fact (OrderPlaced), one outbox, one idempotent consumer, and metrics on lag. Expand only when the next fan-out hurts more as a synchronous call than as another subscriber. That sequence keeps high-demand systems flexible without turning every write into a distributed mystery.
