--- 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: .. or . 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: `.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