Use when designing or debugging an event-driven system — choosing Kafka partitioning strategies, implementing the outbox pattern, handling dead-letter queues, ensuring idempotent consumers, or making event sourcing decisions.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add kid-sid/codex-spellbook --skill event-driven --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Event Driven?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kid-sid-event-driven)More formats (shields.io, HTML) on the badges page.
---
name: event-driven
description: Use when designing or debugging an event-driven system — choosing Kafka partitioning strategies, implementing the outbox pattern, handling dead-letter queues, ensuring idempotent consumers, or making event sourcing decisions.
---
# Event-Driven Architecture Patterns
Design and implementation patterns for reliable, scalable asynchronous systems using message brokers.
## When to Activate
- Decoupling services that currently call each other synchronously
- Designing Kafka topics, partitions, or consumer group topology
- Implementing event schemas with versioning and backward compatibility
- Ensuring exactly-once or at-least-once delivery semantics
- Setting up dead-letter queues and poison message handling
- Implementing the outbox pattern to avoid dual-write problems
- Building event sourcing or CQRS read-model projections
## Core Concepts
### Broker Comparison
| Broker | Retention | Ordering | Throughput | Best For |
|---|---|---|---|---|
| Kafka | Days–forever (log) | Per partition | Very high | Event streaming, audit log, replay |
| RabbitMQ | Until consumed | Per queue | High | Task queues, RPC, routing flexibility |
| AWS SQS | Up to 14 days | None (FIFO available) | High | Simple queues, AWS-native workloads |
| Redis Streams | Configurable | Per stream | High | Low-latency, simple consumers |
| GCP Pub/Sub | 7 days default | None | Very high | GCP-native, push subscriptions |
### Delivery Semantics
| Semantic | How | Tradeoff |
|---|---|---|
| At-least-once | Commit offset only after processing | Duplicates possible; consumer must be idempotent |
| At-most-once | Commit before processing | No duplicates; messages can be lost |
| Exactly-once | Transactional producer + idempotent consumer | Highest complexity; use outbox pattern instead |
Default to **at-least-once with idempotent consumers** — it's simpler than exactly-once and eliminates loss.
## Event Schema Design
### Event Envelope
```json
{
"event_id": "01HXK4...",
"event_type": "order.placed",
"schema_version": "1.0",
"occurred_at": "2025-06-01T12:00:00Z",
"aggregate_id": "order-abc-123",
"aggregate_type": "Order",
"correlation_id": "req-xyz-456",
"payload": {
"customer_id": "cust-789",
"total": "59.99",
"currency": "USD",
"items": [{ "sku": "WIDGET-01", "qty": 2, "unit_price": "29.99" }]
}
}
```
Rules:
- `event_id`: globally unique (ULID or UUID v7) — used for idempotency keys
- `occurred_at`: when the fact happened, not when published
- `correlation_id`: propagate from the originating request for distributed tracing
- Monetary values: string decimal, not float
### Schema Versioning (Avro / JSON Schema)
```python
# GOOD: backward-compatible change — add optional field with default
{
"event_type": "order.placed",
"schema_version": "1.1", # minor bump — consumers on 1.0 still work
"payload": {
"customer_id": "...",
"total": "...",
"promo_code": null # new optional field, defaults to null
}
}
# BAD: breaking changes
# - removing a field consumers depend on
# - renaming a field
# - changing a field type (string → int)
# → always create a new event type or bump major version
```
## Kafka Patterns
### Topic and Partition Design
```
# Naming: <domain>.<entity>.<event-type> or <domain>.<entity>
orders.placed
orders.cancelled
payments
inventory.stock-updates
# Partition key selection — determines ordering guarantee
producer.produce(topic="orders", key=order.customer_id, ...) # all orders per customer are ordered
producer.produce(topic="payments", key=payment.order_id, ...) # all events per order are ordered
# Partition count tradeoffs
low partitions (1–6): easy rebalancing, less parallelism
high partitions (12+): more parallelism, more consumer instances, slower rebalance
rule: start with max(consumer_instances * 2, 12), increase later
```
### Python Producer
```python
from confluent_kafka import Producer, KafkaException
import json, uuid
from datetime import datetime, timezone
producer = Producer({
"bootstrap.servers": "kafka:9092",
"acks": "all", # wait for all ISR replicas
"retries": 5,
"retry.backoff.ms": 500,
"enable.idempotence": True, # exactly-once producer semantics
"compression.type": "lz4",
})
def publish_event(topic: str, key: str, event_type: str, payload: dict):
event = {
"event_id": str(uuid.uuid4()),
"event_type": event_type,
"schema_version": "1.0",
"occurred_at": datetime.now(timezone.utc).isoformat(),
"payload": payload,
}
producer.produce(
topic=topic,
key=key.encode(),
value=json.dumps(event).encode(),
on_delivery=_delivery_report,
)
producer.poll(0) # trigger callbacks without blocking
def _delivery_report(err, msg):
if err:
logger.error("delivery_failed", topic=msg.topic(), error=str(err))
producer.flush() # call at shutdown
```
### Python Consumer
```python
from confluent_kafka import Consumer, KafkaError
import json
consumer = Consumer({
"bootstrap.servers": "kafka:9092",
"group.id": "notification-service",
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # manual commit after processing
"max.poll.interval.ms": 300_000,
})
consumer.subscribe(["orders"])
try:
while True:
msg = consumer.poll(timeout=1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() == KafkaError.PARTITION_EOF:
continue
raise KafkaException(msg.error())
event = json.loads(msg.value())
try:
handle_event(event)
consumer.commit(message=msg) # commit only after success
except Exception as e:
logger.error("processing_failed", event_id=event["event_id"], error=str(e))
# don't commit — message will be redelivered
finally:
consumer.close()
```
### TypeScript Consumer (KafkaJS)
```typescript
import { Kafka } from "kafkajs";
const kafka = new Kafka({ brokers: ["kafka:9092"] });
const consumer = kafka.consumer({ groupId: "notification-service" });
await consumer.connect();
await consumer.subscribe({ topics: ["orders"], fromBeginning: false });
await consumer.run({
autoCommit: false,
eachMessage: async ({ topic, partition, message, heartbeat }) => {
const event = JSON.parse(message.value!.toString());
try {
await handleEvent(event);
await consumer.commitOffsets([{
topic, partition,
offset: (Number(message.offset) + 1).toString(),
}]);
} catch (err) {
logger.error({ eventId: event.event_id, err }, "processing_failed");
throw err; // re-throw to pause and retry
}
},
});
```
### Go Consumer (Sarama)
```go
import "github.com/IBM/sarama"
type OrderHandler struct{ db *sql.DB }
func (h *OrderHandler) Setup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *OrderHandler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *OrderHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
for msg := range claim.Messages() {
var event Event
if err := json.Unmarshal(msg.Value, &event); err != nil {
logger.Error("unmarshal_failed", "offset", msg.Offset)
sess.MarkMessage(msg, "") // skip unparseable messages
continue
}
if err := h.handle(sess.Context(), event); err != nil {
return err // stop processing; session will restart
}
sess.MarkMessage(msg, "")
}
return nil
}
```
## Idempotency
Consumers must handle duplicate messages — at-least-once delivery guarantees redelivery on failure.
```python
# Pattern: idempotency key in a processed-events table
def handle_order_placed(event: dict, db: Session):
event_id = event["event_id"]
# Check if already processed
if db.query(ProcessedEvent).filter_by(event_id=event_id).first():
logger.info("duplicate_skipped", event_id=event_id)
return
# Process in a transaction that also inserts the idempotency record
with db.begin():
create_notification(event["payload"])
db.add(ProcessedEvent(event_id=event_id, processed_at=datetime.utcnow()))
```
```typescript
// Alternative: upsert on natural key (e.g. order_id)
await db.transaction(async (trx) => {
await trx("notifications")
.insert({ order_id: event.payload.order_id, customer_id: event.payload.customer_id })
.onConflict("order_id")
.ignore(); // safe to call multiple times
});
```
## Outbox Pattern
Solves dual-write: never publish directly from application code after a DB write — they can desync.
```
# BAD: dual write (either step can fail independently)
BEGIN TRANSACTION
INSERT INTO orders (...)
COMMIT
kafka.produce("order.placed", ...) # ← this can fail silently
# GOOD: outbox pattern
BEGIN TRANSACTION
INSERT INTO orders (...)
INSERT INTO outbox (event_type, payload, published=false, ...) # same transaction
COMMIT
# Separate relay process (Debezium CDC or polling relay)
SELECT * FROM outbox WHERE published = false ORDER BY created_at LIMIT 100
→ produce to Kafka
→ UPDATE outbox SET published = true WHERE id IN (...)
```
```python
# Polling relay (simple, no CDC dependency)
def relay_outbox(db: Session, producer: Producer):
events = db.query(OutboxEvent).filter_by(published=False).limit(100).all()
for event in events:
producer.produce(
topic=event.topic,
key=event.aggregate_id.encode(),
value=event.payload.encode(),
)
producer.flush()
for event in events:
event.published = True
db.commit()
```
## Dead-Letter Queues
```python
# Python — send to DLQ after max retries
MAX_RETRIES = 3
def handle_with_dlq(consumer, dlq_producer, msg):
retry_count = int(msg.headers().get("retry-count", b"0"))
event = json.loads(msg.value())
try:
process_event(event)
consumer.commit(message=msg)
except Exception as e:
if retry_count >= MAX_RETRIES:
dlq_producer.produce(
topic=f"{msg.topic()}.dlq",
key=msg.key(),
value=msg.value(),
headers={"original-topic": msg.topic(), "error": str(e)},
)
consumer.commit(message=msg) # move past it
logger.error("sent_to_dlq", event_id=event["event_id"])
else:
# Re-queue with incremented retry count (or let Kafka retry via pause)
logger.warning("retry", attempt=retry_count + 1, event_id=event["event_id"])
```
DLQ naming convention: `<original-topic>.dlq`
DLQ review: alert on DLQ lag > 0; investigate and replay or discard manually.
## Event Sourcing
Store state as an append-only log of events; derive current state by replaying.
```python
# Events are facts, not commands
EVENTS = [
{"type": "OrderCreated", "payload": {"customer_id": "c1", "items": [...]}},
{"type": "ItemAdded", "payload": {"sku": "X", "qty": 1}},
{"type": "OrderConfirmed", "payload": {"confirmed_at": "2025-01-01T..."}},
]
def replay(events: list[dict]) -> Order:
order = Order()
for event in events:
match event["type"]:
case "OrderCreated": order.apply_created(event["payload"])
case "ItemAdded": order.apply_item_added(event["payload"])
case "OrderConfirmed": order.apply_confirmed(event["payload"])
return order
# Snapshot: periodically persist current state to avoid full replay
# Projection: consume event stream to build read models (CQRS read side)
```
| Use Event Sourcing When | Avoid When |
|---|---|
| Audit trail is a first-class requirement | Simple CRUD with no history needs |
| Need to replay history for new features | Team unfamiliar with the pattern |
| Multiple read models from one write model | Strong consistency required across aggregates |
| Temporal queries ("state at time T") | Simple, low-volume domain |
> See also: `microservices`, `observability`
## Red Flags
- **Kafka consumer without idempotency** — at-least-once delivery means the same message can arrive twice; design consumers to be idempotent before assuming exactly-once semantics
- **Hot partition from a low-cardinality key** — using `event_type` as the partition key routes all messages of one type to one partition; choose a high-cardinality key like `entity_id` for even distribution
- **No dead-letter queue for unprocessable messages** — a consumer that throws on a bad message blocks all subsequent messages on that partition; route poison messages to a DLQ immediately
- **Outbox table without a reliable poller** — writing to the outbox without a dedicated transactional poller means events may silently never be published; the poller is half the pattern
- **Schema changes without versioning** — adding a required field to an event schema breaks all existing consumers silently; always version events and maintain backward compatibility
- **Synchronous HTTP calls inside a consumer handler** — an upstream timeout blocks the consumer and grows partition lag; use async clients or pre-fetch data outside the consumer loop
- **Resetting offsets to earliest on every consumer restart** — without committed offsets, a restarted consumer reprocesses all historical events; commit offsets after processing and handle replay explicitly
## Checklist
- [ ] Event envelope includes `event_id`, `event_type`, `schema_version`, `occurred_at`, `correlation_id`
- [ ] Partition key chosen to guarantee ordering for events that must be ordered
- [ ] Producer uses `acks=all` and `enable.idempotence=true`
- [ ] Consumer commits offsets manually after successful processing
- [ ] All consumers are idempotent (duplicate `event_id` handled gracefully)
- [ ] Outbox pattern used — never dual-write to DB and broker in separate transactions
- [ ] Dead-letter queue configured; alerts fire when DLQ lag exceeds threshold
- [ ] Event schema changes are backward-compatible (add optional fields only)
- [ ] Consumer group IDs are service-specific and stable across deploys
- [ ] Dead-letter messages include original topic, offset, and error reason in headers
- [ ] Retention period set to cover the longest plausible consumer downtime plus buffer
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!