Event-Driven Architecture
Purpose
Design event flows that survive redelivery, reordering, and consumer failure — because all three will happen, and the broker's guarantees are weaker than most designs assume.
When to Use
- Introducing a message broker or queue.
- Designing event schemas that other teams will consume.
- Debugging duplicate processing, lost messages, or stuck consumers.
- Evaluating event sourcing for a subsystem.
Capabilities
- Event schema design and versioning.
- Delivery semantics: at-most-once, at-least-once, effectively-once.
- Idempotent consumer design.
- Ordering and partitioning strategy.
- Dead-letter queues, retry policies, and poison-message handling.
- Event sourcing and CQRS, and when the cost is justified.
Inputs
- The events the domain actually produces, in the domain's language.
- The broker and its real guarantees (not its marketing).
- Consumer requirements: ordering, latency, replay.
Outputs
- Versioned event schemas with a registry or equivalent.
- Consumers that are safe to run twice on the same message.
- Retry, DLQ, and replay procedures.
Workflow
- Name events in the past tense —
OrderPlaced, PaymentFailed. An event is a fact that happened, not a command to do something.
- Design the payload for consumers you do not control — Include enough context that a consumer does not have to call back for the basics; do not include so much that every field change breaks someone.
- Assume at-least-once — Every consumer must be idempotent. Deduplicate on an event ID, or make the operation naturally idempotent.
- Partition for ordering — Ordering is guaranteed only within a partition. Key by the entity whose order matters (usually the aggregate ID).
- Handle poison messages — A message that always fails must land in a DLQ after N attempts, not retry forever and block the partition.
- Version from the start — Additive changes only; a new required field is a new event version.
Best Practices
- Exactly-once delivery does not exist across a network. Exactly-once processing is achievable with idempotent consumers, and that is where the effort belongs.
- Retry with exponential backoff and jitter. Fixed-interval retries from many consumers synchronize into a thundering herd.
- A consumer that retries a permanently failing message forever blocks every message behind it. Always cap attempts.
- Do not put large payloads on the bus. Publish a reference and let the consumer fetch — or accept the coupling knowingly.
- Events are a public contract. Removing a field breaks consumers you have never met.
- Log the event ID at every hop. Without it, tracing a lost message is archaeology.
Examples
Idempotent consumer keyed on event ID:
async def handle(event: Event, conn: Connection) -> None:
async with conn.transaction():
# Insert the marker first. If this event was already processed,
# the unique constraint rejects it and we skip the side effect.
inserted = await conn.execute(
"INSERT INTO processed_events (event_id, consumer) VALUES ($1, $2) "
"ON CONFLICT DO NOTHING RETURNING 1",
event.id, CONSUMER_NAME,
)
if not inserted:
logger.info("duplicate_event_skipped", extra={"event_id": event.id})
return
await apply_side_effect(event, conn) # same transaction as the marker
The marker and the side effect commit together. Redelivery is a no-op; a crash mid-transaction rolls back both and the message is redelivered cleanly.
Notes
- Kafka guarantees ordering per partition, not per topic. If two events for the same order land in different partitions, they can be processed out of order — key by order ID.
- A DLQ with no alerting and no replay tooling is a place where messages go to be forgotten. Build the replay path when you build the DLQ.
- Event sourcing is a large commitment: every schema change becomes a versioning problem for events you can never rewrite. Adopt it where the audit log is the product, not as a default persistence strategy.
1---2name: event-driven-architecture3description: Use when designing systems around events and message queues. Covers event schema design, delivery guarantees, idempotent consumers, ordering, dead-letter handling, and event sourcing.4---56# Event-Driven Architecture78## Purpose910Design event flows that survive redelivery, reordering, and consumer failure — because all three will happen, and the broker's guarantees are weaker than most designs assume.1112## When to Use1314- Introducing a message broker or queue.15- Designing event schemas that other teams will consume.16- Debugging duplicate processing, lost messages, or stuck consumers.17- Evaluating event sourcing for a subsystem.1819## Capabilities2021- Event schema design and versioning.22- Delivery semantics: at-most-once, at-least-once, effectively-once.23- Idempotent consumer design.24- Ordering and partitioning strategy.25- Dead-letter queues, retry policies, and poison-message handling.26- Event sourcing and CQRS, and when the cost is justified.2728## Inputs2930- The events the domain actually produces, in the domain's language.31- The broker and its real guarantees (not its marketing).32- Consumer requirements: ordering, latency, replay.3334## Outputs3536- Versioned event schemas with a registry or equivalent.37- Consumers that are safe to run twice on the same message.38- Retry, DLQ, and replay procedures.3940## Workflow41421. **Name events in the past tense** — `OrderPlaced`, `PaymentFailed`. An event is a fact that happened, not a command to do something.432. **Design the payload for consumers you do not control** — Include enough context that a consumer does not have to call back for the basics; do not include so much that every field change breaks someone.443. **Assume at-least-once** — Every consumer must be idempotent. Deduplicate on an event ID, or make the operation naturally idempotent.454. **Partition for ordering** — Ordering is guaranteed only within a partition. Key by the entity whose order matters (usually the aggregate ID).465. **Handle poison messages** — A message that always fails must land in a DLQ after N attempts, not retry forever and block the partition.476. **Version from the start** — Additive changes only; a new required field is a new event version.4849## Best Practices5051- Exactly-once delivery does not exist across a network. Exactly-once *processing* is achievable with idempotent consumers, and that is where the effort belongs.52- Retry with exponential backoff and jitter. Fixed-interval retries from many consumers synchronize into a thundering herd.53- A consumer that retries a permanently failing message forever blocks every message behind it. Always cap attempts.54- Do not put large payloads on the bus. Publish a reference and let the consumer fetch — or accept the coupling knowingly.55- Events are a public contract. Removing a field breaks consumers you have never met.56- Log the event ID at every hop. Without it, tracing a lost message is archaeology.5758## Examples5960**Idempotent consumer keyed on event ID:**6162```python63async def handle(event: Event, conn: Connection) -> None:64 async with conn.transaction():65 # Insert the marker first. If this event was already processed,66 # the unique constraint rejects it and we skip the side effect.67 inserted = await conn.execute(68 "INSERT INTO processed_events (event_id, consumer) VALUES ($1, $2) "69 "ON CONFLICT DO NOTHING RETURNING 1",70 event.id, CONSUMER_NAME,71 )72 if not inserted:73 logger.info("duplicate_event_skipped", extra={"event_id": event.id})74 return7576 await apply_side_effect(event, conn) # same transaction as the marker77```7879The marker and the side effect commit together. Redelivery is a no-op; a crash mid-transaction rolls back both and the message is redelivered cleanly.8081## Notes8283- Kafka guarantees ordering per partition, not per topic. If two events for the same order land in different partitions, they can be processed out of order — key by order ID.84- A DLQ with no alerting and no replay tooling is a place where messages go to be forgotten. Build the replay path when you build the DLQ.85- Event sourcing is a large commitment: every schema change becomes a versioning problem for events you can never rewrite. Adopt it where the audit log *is* the product, not as a default persistence strategy.