Event-Driven Architecture
Patterns for services that communicate via asynchronous events. Broker-agnostic at the conceptual level; defaults inline per use case (NATS for lightweight, Kafka for log-based replay, RabbitMQ for command queues). Protobuf schemas for events (protobuf-architect) so contracts get the same buf breaking discipline as gRPC. Outbox pattern is mandatory for any write that emits an event. Schemas, SQL, and tooling shapes in RECIPES.md; pinned brokers and client libs in STACK.md.
1. Event vs message vs command — the three shapes
| Shape |
Direction |
Semantics |
Example |
| Event |
Past-tense, broadcast |
"Something happened" — fact about the past, anyone can listen |
OrderPlaced, PaymentCaptured |
| Command |
Imperative, point-to-point |
"Do this" — request to one specific handler |
CancelOrder, SendEmail |
| Message |
Generic envelope |
Container for either — used when the distinction doesn't matter |
(mostly an implementation detail) |
- Events are immutable past-tense facts.
OrderPlaced was placed; nothing changes that. Subscribers react however they want.
- Commands have one intended handler. Multiple handlers reacting to a command is almost always wrong — it's an event in disguise. Rename it.
- Naming: events are
<Noun><PastVerb> (OrderPlaced, ShipmentDispatched); commands are <Verb><Noun> (PlaceOrder, SendShipment).
- Choose the shape per use case, not per technology. Both Kafka and RabbitMQ can carry either; the discipline is in the schema and contract.
2. Schema — Protobuf
Per protobuf-architect: events are .proto messages, code-generated, validated by protovalidate, and protected from breaking changes by buf breaking in CI.
Envelope contract (every event):
event_id — UUID v7, sortable + unique. Subscribers dedupe on it.
occurred_at — RFC 3339 timestamp. Replay tools sort by this.
aggregate_id — the entity the event is about. Drives partitioning.
schema_version — integer; bump on additive changes inside a topic version.
Payload discipline:
- Minimal. IDs and the few facts subscribers need — not the full aggregate state. Subscribers fetch via sql-architect repositories. Big payloads make schema evolution and replay expensive.
- Field numbers reserved on delete per protobuf-architect §3. Never reuse.
- One file per resource's events —
orders/v1/events.proto holds every event the orders context emits.
Canonical schema in RECIPES §1.
3. Topic / subject naming
Hierarchical, snake_case, versioned. Pick a convention and enforce it.
<org>.<context>.<resource>.<version>.<event_name>
- Matches the Buf-style package path from protobuf-architect.
- Version is part of the topic name, not just the schema. New major version → new topic. Run in parallel until consumers migrate.
- Lowercase + dots (NATS, Pub/Sub) or lowercase + underscores (Kafka). Pick one for your broker and stick to it.
- Document the catalog somewhere queryable — Confluent Schema Registry, BSR, or a simple
events.md in the repo.
Examples in RECIPES §5.
4. Outbox pattern — mandatory for "DB write + event emit"
The dual-write problem: an HTTP handler writes a row and publishes an event. If the DB commits but the broker rejects, the event is lost — silent inconsistency. If the broker accepts but the DB rolls back, subscribers process a phantom event.
Outbox fixes this with a single transactional write.
- Handler
BEGIN TX → INSERT INTO aggregate → INSERT INTO outbox → COMMIT.
- Separate publisher reads unpublished rows, sends them to the broker, marks them published.
- Outbox is a regular table in the same DB as the aggregate. The write is atomic with the business write.
- Publisher is separate — a goroutine, a sidecar, a cron, or CDC (Debezium reading Postgres WAL). CDC is the most robust; goroutine is fine for small services.
- At-least-once delivery — the same event can be republished if the publisher crashes between PUBLISH and UPDATE. Consumers must be idempotent (§6).
- Outbox table grows — partition or purge published rows older than 7–30 days.
- Why mandatory: there is no working pattern that avoids both the lost-event and phantom-event failure modes without the outbox. Anything else (publish-before-commit, publish-after-commit) is broken under failure.
Schema + publisher shapes in RECIPES §2.
5. Ordering and partitioning
Event ordering is the single hardest part of event-driven systems. Order is per-key, not global.
- Order is preserved within a partition / subject, but not across partitions. Kafka partitions by message key; NATS via subject hierarchy; RabbitMQ via consistent-hash exchanges.
- Partition key is the aggregate ID. All events for
order_id=abc land on the same partition, processed in order by one consumer. Different orders process in parallel.
- Globally-ordered events are a smell. If you "need" global order, you actually need a single consumer (and you've lost scaling), or you're modeling the domain wrong.
- Consumers process one partition at a time per instance. Concurrent processing within a partition breaks ordering. Most client libs handle this; verify your config.
6. Idempotency — consumer must dedupe
Brokers deliver at least once. Consumers see the same event more than once under network failure, restart, or rebalance.
- Dedupe by
event_id. Each consumer keeps a small store (Redis with TTL, or a processed_events table) of recently-seen IDs. Reject duplicates.
- Idempotent side effects — design the handler so a duplicate is harmless:
INSERT ... ON CONFLICT DO NOTHING, UPDATE ... WHERE version = ? (with optimistic concurrency).
- TTL on dedupe store — events older than the broker's retention can't be replayed anyway.
- Exactly-once illusion: idempotent consumer + at-least-once delivery = "effectively exactly-once" from the business perspective. Don't chase true exactly-once at the protocol level — far more expensive than just making consumers idempotent.
Concrete handler + dedupe table in RECIPES §3.
7. Dead-letter queues (DLQs)
Some events can't be processed — schema mismatch, downstream service down too long, business invariant violation. Don't let them block the partition.
- Every consumer has a DLQ. A topic / subject named
<original>.dlq receives messages the consumer gave up on.
- Retry policy first, DLQ second. N attempts with exponential backoff (typical: 3 attempts), then DLQ.
- DLQs are monitored. Per observability-architect: a Prometheus counter
<svc>_dlq_messages_total with an alert on any non-zero value. A DLQ that quietly fills is a silent outage.
- DLQ tooling — operator scripts to inspect, replay, or discard. Both audited.
Retry policy + tool CLI shape in RECIPES §4.
8. Backpressure
When the consumer can't keep up with the producer, the system needs to slow down — gracefully.
- Prefetch / consumer concurrency limits. Don't let one consumer instance buffer 10,000 in-flight messages.
- Lag-based autoscaling. Watch consumer-group lag; scale out the consumer pool when lag grows.
- Reject upstream when persistently overloaded — return
503 Service Unavailable with Retry-After per rest-api-architect §3. Better than building a backlog you can't drain.
- No unbounded queues in memory. A handler that reads from one topic and writes to another needs bounded size + a timeout.
Per-broker tuning knobs in RECIPES §6.
9. Schema evolution
Per protobuf-architect §4 — additive changes stay in the version; breaking changes go to a new vN.
- Additive: new optional field, new enum value, new event type on a new topic. Safe.
- Breaking: field removal, type change, semantic change. Bump to
<topic>.v2; both run side-by-side until consumers migrate.
buf breaking in CI catches accidental breakage in the proto files. Per protobuf-architect §8.
- Consumer compatibility tests — for each known consumer version, replay a sample event and assert it deserializes cleanly. Catches semantic-level breakage that's still wire-compatible.
10. Saga / orchestration / choreography
For multi-step workflows that span services. Two opposing patterns — comparison table in RECIPES § 6.
- Start with choreography — events flowing service-to-service, each subscriber reacts. Simpler.
- Promote to orchestration (Temporal, Camunda, AWS Step Functions) when the flow is genuinely complex — 5+ services, branching, retries, compensation.
- Compensation actions for partial failures:
OrderCancelled undoes PaymentCaptured via RefundIssued. Domain-level, not technical rollback.
- Correlation ID propagated through every event in a saga — ties the chain together (observability-architect §5).
11. Broker selection — when each fits
The pattern in §1–10 works on any modern broker. Full strengths/best-for table in RECIPES § 7. Defaults:
- Lightweight: NATS JetStream — single binary, conf-driven, easy to operate.
- Log-based: Kafka — when replay, retention, and analytics consumers matter.
- Command queues / legacy fit: RabbitMQ — when integration with non-Kafka non-NATS systems forces the choice.
- Cloud-managed (SNS+SQS, Pub/Sub, Service Bus) — when ops cost matters more than feature parity.
Pick once per system; switching mid-flight is expensive.
12. Cross-skill ties
1---2name: event-driven-architect3description: Event-driven architecture — event/command taxonomy, Protobuf schemas, topic naming, mandatory outbox, partitioning, idempotency, DLQs, schema evolution. Broker-agnostic (NATS/Kafka/RabbitMQ). Use when designing event flows or auditing consistency.4---56# Event-Driven Architecture78Patterns for services that communicate via asynchronous events. **Broker-agnostic** at the conceptual level; defaults inline per use case (NATS for lightweight, Kafka for log-based replay, RabbitMQ for command queues). **Protobuf schemas** for events ([protobuf-architect](../../encoding/protobuf-architect/SKILL.md)) so contracts get the same `buf breaking` discipline as gRPC. **Outbox pattern is mandatory** for any write that emits an event. Schemas, SQL, and tooling shapes in [RECIPES.md](RECIPES.md); pinned brokers and client libs in [STACK.md](STACK.md).910## 1. Event vs message vs command — the three shapes1112| Shape | Direction | Semantics | Example |13|---|---|---|---|14| **Event** | Past-tense, broadcast | "Something happened" — fact about the past, anyone can listen | `OrderPlaced`, `PaymentCaptured` |15| **Command** | Imperative, point-to-point | "Do this" — request to one specific handler | `CancelOrder`, `SendEmail` |16| **Message** | Generic envelope | Container for either — used when the distinction doesn't matter | (mostly an implementation detail) |1718- **Events are immutable past-tense facts.** `OrderPlaced` was placed; nothing changes that. Subscribers react however they want.19- **Commands have one intended handler.** Multiple handlers reacting to a command is almost always wrong — it's an event in disguise. Rename it.20- **Naming:** events are `<Noun><PastVerb>` (`OrderPlaced`, `ShipmentDispatched`); commands are `<Verb><Noun>` (`PlaceOrder`, `SendShipment`).21- **Choose the shape per use case**, not per technology. Both Kafka and RabbitMQ can carry either; the discipline is in the schema and contract.2223## 2. Schema — Protobuf2425Per [protobuf-architect](../../encoding/protobuf-architect/SKILL.md): events are `.proto` messages, code-generated, validated by `protovalidate`, and protected from breaking changes by `buf breaking` in CI.2627**Envelope contract** (every event):2829- `event_id` — UUID v7, sortable + unique. Subscribers dedupe on it.30- `occurred_at` — RFC 3339 timestamp. Replay tools sort by this.31- `aggregate_id` — the entity the event is about. Drives partitioning.32- `schema_version` — integer; bump on additive changes inside a topic version.3334**Payload discipline:**3536- **Minimal.** IDs and the few facts subscribers need — not the full aggregate state. Subscribers fetch via [sql-architect](../../databases/sql-architect/SKILL.md) repositories. Big payloads make schema evolution and replay expensive.37- **Field numbers reserved on delete** per [protobuf-architect §3](../../encoding/protobuf-architect/SKILL.md#3-field-numbering--reservation-discipline). Never reuse.38- **One file per resource's events** — `orders/v1/events.proto` holds every event the orders context emits.3940Canonical schema in [RECIPES §1](RECIPES.md#1-canonical-event-schema).4142## 3. Topic / subject naming4344Hierarchical, snake_case, versioned. Pick a convention and enforce it.4546```47<org>.<context>.<resource>.<version>.<event_name>48```4950- Matches the Buf-style package path from protobuf-architect.51- **Version is part of the topic name**, not just the schema. New major version → new topic. Run in parallel until consumers migrate.52- **Lowercase + dots** (NATS, Pub/Sub) or **lowercase + underscores** (Kafka). Pick one for your broker and stick to it.53- **Document the catalog** somewhere queryable — Confluent Schema Registry, BSR, or a simple `events.md` in the repo.5455Examples in [RECIPES §5](RECIPES.md#5-topic--subject-naming-reference).5657## 4. Outbox pattern — mandatory for "DB write + event emit"5859The dual-write problem: an HTTP handler writes a row and publishes an event. If the DB commits but the broker rejects, the event is lost — silent inconsistency. If the broker accepts but the DB rolls back, subscribers process a phantom event.6061**Outbox fixes this with a single transactional write.**62631. Handler `BEGIN TX` → `INSERT INTO aggregate` → `INSERT INTO outbox` → `COMMIT`.642. Separate publisher reads unpublished rows, sends them to the broker, marks them published.6566- **Outbox is a regular table** in the same DB as the aggregate. The write is atomic with the business write.67- **Publisher is separate** — a goroutine, a sidecar, a cron, or **CDC** (Debezium reading Postgres WAL). CDC is the most robust; goroutine is fine for small services.68- **At-least-once delivery** — the same event can be republished if the publisher crashes between PUBLISH and UPDATE. Consumers must be idempotent (§6).69- **Outbox table grows** — partition or purge published rows older than 7–30 days.70- **Why mandatory:** there is no working pattern that avoids both the lost-event and phantom-event failure modes without the outbox. Anything else (publish-before-commit, publish-after-commit) is broken under failure.7172Schema + publisher shapes in [RECIPES §2](RECIPES.md#2-outbox-table--publisher).7374## 5. Ordering and partitioning7576Event ordering is the single hardest part of event-driven systems. **Order is per-key, not global.**7778- **Order is preserved within a partition / subject**, but not across partitions. Kafka partitions by message key; NATS via subject hierarchy; RabbitMQ via consistent-hash exchanges.79- **Partition key is the aggregate ID.** All events for `order_id=abc` land on the same partition, processed in order by one consumer. Different orders process in parallel.80- **Globally-ordered events are a smell.** If you "need" global order, you actually need a single consumer (and you've lost scaling), or you're modeling the domain wrong.81- **Consumers process one partition at a time per instance.** Concurrent processing within a partition breaks ordering. Most client libs handle this; verify your config.8283## 6. Idempotency — consumer must dedupe8485Brokers deliver at least once. Consumers see the same event more than once under network failure, restart, or rebalance.8687- **Dedupe by `event_id`.** Each consumer keeps a small store (Redis with TTL, or a `processed_events` table) of recently-seen IDs. Reject duplicates.88- **Idempotent side effects** — design the handler so a duplicate is harmless: `INSERT ... ON CONFLICT DO NOTHING`, `UPDATE ... WHERE version = ?` (with optimistic concurrency).89- **TTL on dedupe store** — events older than the broker's retention can't be replayed anyway.90- **Exactly-once illusion**: idempotent consumer + at-least-once delivery = "effectively exactly-once" from the business perspective. Don't chase true exactly-once at the protocol level — far more expensive than just making consumers idempotent.9192Concrete handler + dedupe table in [RECIPES §3](RECIPES.md#3-consumer-idempotency).9394## 7. Dead-letter queues (DLQs)9596Some events can't be processed — schema mismatch, downstream service down too long, business invariant violation. Don't let them block the partition.9798- **Every consumer has a DLQ.** A topic / subject named `<original>.dlq` receives messages the consumer gave up on.99- **Retry policy first, DLQ second.** N attempts with exponential backoff (typical: 3 attempts), then DLQ.100- **DLQs are monitored.** Per [observability-architect](../../infra/observability-architect/SKILL.md): a Prometheus counter `<svc>_dlq_messages_total` with an alert on any non-zero value. A DLQ that quietly fills is a silent outage.101- **DLQ tooling** — operator scripts to inspect, replay, or discard. Both audited.102103Retry policy + tool CLI shape in [RECIPES §4](RECIPES.md#4-dlq-topic--replay-tooling).104105## 8. Backpressure106107When the consumer can't keep up with the producer, the system needs to slow down — gracefully.108109- **Prefetch / consumer concurrency limits.** Don't let one consumer instance buffer 10,000 in-flight messages.110- **Lag-based autoscaling.** Watch consumer-group lag; scale out the consumer pool when lag grows.111- **Reject upstream** when persistently overloaded — return `503 Service Unavailable` with `Retry-After` per [rest-api-architect §3](../../protocols/rest-api-architect/SKILL.md#3-status-codes). Better than building a backlog you can't drain.112- **No unbounded queues in memory.** A handler that reads from one topic and writes to another needs bounded size + a timeout.113114Per-broker tuning knobs in [RECIPES §6](RECIPES.md#6-backpressure-tuning-per-broker).115116## 9. Schema evolution117118Per [protobuf-architect §4](../../encoding/protobuf-architect/SKILL.md#4-versioning) — additive changes stay in the version; breaking changes go to a new `vN`.119120- **Additive:** new optional field, new enum value, new event type on a new topic. Safe.121- **Breaking:** field removal, type change, semantic change. Bump to `<topic>.v2`; both run side-by-side until consumers migrate.122- **`buf breaking` in CI** catches accidental breakage in the proto files. Per [protobuf-architect §8](../../encoding/protobuf-architect/SKILL.md#8-breaking-change-detection--buf-breaking-in-ci).123- **Consumer compatibility tests** — for each known consumer version, replay a sample event and assert it deserializes cleanly. Catches semantic-level breakage that's still wire-compatible.124125## 10. Saga / orchestration / choreography126127For multi-step workflows that span services. Two opposing patterns — comparison table in [RECIPES § 6](RECIPES.md#6-choreography-vs-orchestration).128129- **Start with choreography** — events flowing service-to-service, each subscriber reacts. Simpler.130- **Promote to orchestration** (Temporal, Camunda, AWS Step Functions) when the flow is genuinely complex — 5+ services, branching, retries, compensation.131- **Compensation actions** for partial failures: `OrderCancelled` undoes `PaymentCaptured` via `RefundIssued`. Domain-level, not technical rollback.132- **Correlation ID** propagated through every event in a saga — ties the chain together ([observability-architect §5](../../infra/observability-architect/SKILL.md#5-correlation)).133134## 11. Broker selection — when each fits135136The pattern in §1–10 works on any modern broker. Full strengths/best-for table in [RECIPES § 7](RECIPES.md#7-broker-selection-reference). Defaults:137138- **Lightweight: NATS JetStream** — single binary, conf-driven, easy to operate.139- **Log-based: Kafka** — when replay, retention, and analytics consumers matter.140- **Command queues / legacy fit: RabbitMQ** — when integration with non-Kafka non-NATS systems forces the choice.141- **Cloud-managed (SNS+SQS, Pub/Sub, Service Bus)** — when ops cost matters more than feature parity.142143Pick once per system; switching mid-flight is expensive.144145## 12. Cross-skill ties146147- [protobuf-architect](../../encoding/protobuf-architect/SKILL.md) — event schemas + `buf breaking` discipline.148- [grpc-architect](../../protocols/grpc-architect/SKILL.md) — synchronous counterpart; when a call should be RPC vs event.149- [sql-architect](../../databases/sql-architect/SKILL.md) — outbox lives in your domain DB; same transactional discipline.150- [ddd-architect §6](../../design/ddd-architect/SKILL.md#6-domain-events) — domain events as the natural source of integration events.151- [observability-architect](../../infra/observability-architect/SKILL.md) — consumer lag, DLQ depth, processing latency are first-class metrics.152- [grafana-architect](../../infra/grafana-architect/SKILL.md) — per-consumer-group lag dashboards; DLQ alerts.153- [rest-api-architect §8](../../protocols/rest-api-architect/SKILL.md#8-idempotency--idempotency-key-mandatory) — the HTTP `Idempotency-Key` discipline is the same idea consumers need internally.