Event-Driven Architecture skill for Claude Code
Events that survive duplicates, crashes and reordering. Claude designs event flows that stay correct under at-least-once delivery. State changes and their events commit together through a transactional outbox, consumers deduplicate with an inbox table, multi-step workflows become sagas with explicit compensations and timeouts, and event schemas evolve compatibly so a producer deploy never breaks a consumer.
- name
event-driven-architecture - size193 lines · ≈2,000 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs event-driven systems that stay correct under at-least-once delivery, separating events from commands, choosing notification vs. event-carried state transfer, publishing reliably with the transactional outbox, consuming idempotently with an inbox table, coordinating multi-step workflows with sagas and compensations, and evolving event schemas without breaking consumers. Use when introducing a message broker or event bus, when dual writes to a database and a queue lose messages, when designing sagas or async workflows, or when changing an event schema that other services consume. Try asking
- We save an order and then publish to RabbitMQ, and sometimes the event is lost. Fix it
- Design a saga for checkout across inventory, payment and shipping services
- How do I add a field to an event that five services already consume?
Use it when
- Introducing a broker or event bus between services
- Dual writes to a database and a queue lose or duplicate messages
- Designing async multi-step workflows with failure handling
- Changing an event schema with existing consumers
Skip it when
- You need Kafka topic, partition and consumer-group tuning: use kafka-streaming
- You only need a background job queue inside one app: use background-jobs
How to check the result
Killing the producer between commit and publish still delivers the event, delivering a message twice changes state once, and a failed saga step triggers its compensations.
The common mistake it prevents
Writing to the database and then publishing to the broker as two separate steps, which loses events on a crash or sends events for rolled-back data.
Correct event-driven systems assume every message can arrive late, twice, or out of order, and still converge. Done well: state changes and their events commit atomically (outbox), consumers are idempotent (inbox), long workflows have explicit compensations, and schemas evolve compatibly.
Ground rules
- Never write to the database and publish to a broker as two independent steps. Use an outbox (or CDC) so both happen or neither does.
- Every consumer is idempotent. At-least-once delivery is the norm; exactly-once is a property you build, not one you are given.
- Events are facts in past tense, owned by the producer (
OrderPlaced). Commands are requests to one handler (ReserveStock). Do not disguise commands as events. - Ordering is only guaranteed within a partition/key. Design for that scope explicitly.
- Every event has an ID, type, version, source, occurred-at timestamp, and a correlation ID.
Workflow
- [ ] 1. List the business facts (events) and requests (commands)
- [ ] 2. Choose event style per flow: notification vs. state transfer
- [ ] 3. Define the envelope and schema with a version
- [ ] 4. Publish via transactional outbox (or CDC)
- [ ] 5. Make consumers idempotent; set retry and DLQ policy
- [ ] 6. Design sagas for multi-step workflows with compensations
- [ ] 7. Plan schema evolution and replay
- [ ] 8. Add observability: lag, DLQ depth, end-to-end latency
1–2. Messages and styles
| Kind | Semantics | Example | Routing |
|---|---|---|---|
| Event | Fact, many subscribers, producer does not care who listens | PaymentCaptured | Topic / pub-sub |
| Command | Request, exactly one handler, may be rejected | CapturePayment | Queue to one service |
| Query | Request for data, synchronous | GET /payments/42 | HTTP/gRPC |
| Event style | Payload | Use when | Cost |
|---|---|---|---|
| Notification | IDs only ({ orderId }) | Consumers need fresh data or payload is sensitive/large | Consumers call back to the producer (coupling, load) |
| Event-carried state transfer | Relevant state ({ orderId, lines, total, address }) | Consumers build local read models, producer must not be called | Larger payloads, schema discipline |
| Event sourcing | Events are the source of truth | Audit-heavy domains needing full history | High complexity; only with a clear need |
Default: event-carried state transfer for integration events between services, with only the fields consumers need.
3. Envelope
{
"id": "01J8Z4V7K1Q2ZP0M5N3R8T6W9X",
"type": "orders.order_placed",
"version": 2,
"source": "orders-service",
"occurredAt": "2026-03-14T10:22:31.512Z",
"correlationId": "req-7f3a",
"causationId": "cmd-91b2",
"partitionKey": "order-4821",
"data": { "orderId": "order-4821", "customerId": "cus-77", "totalCents": 12900, "currency": "EUR" }
}
CloudEvents is a reasonable standard envelope if the ecosystem uses it. Separate internal domain events (inside a service) from published integration events (a stable contract).
4. Transactional outbox
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
In the same transaction as the business change:
BEGIN;
UPDATE orders SET status = 'placed' WHERE id = $1;
INSERT INTO outbox (id, aggregate_id, type, payload) VALUES ($2, $1, 'orders.order_placed', $3);
COMMIT;
A relay publishes unpublished rows and marks them; or use CDC (Debezium reading the WAL) to stream the outbox table. Relay SQL, ordering caveats, and cleanup are in references/outbox-and-sagas.md; read it when implementing the relay or a saga.
5. Idempotent consumers
CREATE TABLE inbox (
consumer text NOT NULL,
message_id uuid NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id)
);
await db.transaction(async (tx) => {
const inserted = await tx.query(
'INSERT INTO inbox (consumer, message_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING 1',
['shipping', msg.id]);
if (inserted.rowCount === 0) return; // duplicate: already processed
await createShipment(tx, msg.data); // side effects in the same transaction
});
await ack(msg); // ack only after commit
- Alternatives: natural idempotence (upsert by business key,
SET status = 'shipped') or version checks (WHERE version < $eventVersion) for state-transfer events. - Side effects outside the DB (emails, third-party calls) need their own idempotency key (see idempotency-retries).
- Retry transient failures with backoff; after N attempts send to a dead-letter queue with the error and original headers. Alert on DLQ depth; provide a redrive tool.
- Poison messages must not block a partition forever.
6. Sagas
| Style | How | Use when |
|---|---|---|
| Choreography | Each service reacts to events and emits its own | 2–3 steps, few participants, simple compensation |
| Orchestration | A coordinator (state machine) sends commands and tracks state | 4+ steps, branching, timeouts, need to see workflow state |
Rules:
- Every step that can be undone has a compensating action (
ReleaseStockforReserveStock,RefundPaymentforCapturePayment). Compensation is a new business action, not a rollback. - Order steps so the hardest-to-compensate (sending goods, charging cards) comes last.
- Persist saga state; handle timeouts explicitly (e.g., cancel if payment not confirmed in 15 min).
- Workflow engines (Temporal, AWS Step Functions, and similar) are worth it when orchestration logic grows beyond a few states.
7. Schema evolution
| Change | Compatible? | How |
|---|---|---|
| Add optional field | Yes (backward and forward) | Consumers ignore unknown fields |
| Add required field | No for old producers | Add as optional with default first |
| Remove field | No for consumers reading it | Deprecate, confirm no readers, then remove |
| Rename field | No | Add new, dual-write, migrate consumers, remove old |
| Change type/meaning | No | New event type or new major version |
| New enum value | Often breaks consumers with exhaustive switches | Document “ignore unknown values”; treat as breaking unless guaranteed |
Use a schema registry (Avro/Protobuf/JSON Schema) with compatibility mode enforced in
CI when available. For breaking changes, publish v2 alongside v1 until all consumers
migrate.
Retention and replay: decide how long events are retained and whether consumers can rebuild state by replaying from the beginning. Replays must be idempotent too.
8. Observability
- Consumer lag per group/partition, DLQ depth, publish-to-consume latency (from
occurredAt), outbox backlog age. - Propagate
correlationIdand trace context in message headers so a request can be followed across services.
Gotchas
- Publishing inside the DB transaction before commit sends events for rolled-back data.
- Publishing after commit without an outbox loses events on crash.
- Consumers that call the producer synchronously for every notification recreate tight coupling with extra latency.
- Kafka consumer auto-commit can mark messages processed before the work finishes.
- Global ordering is almost never needed; when it seems needed, partition by the entity whose order matters.
- An event bus does not remove coupling: consumers depend on the event schema. Treat it as a public API.
Verification
- Kill the service between DB commit and publish: the event still arrives (outbox works).
- Deliver the same message twice: state changes once (inbox works).
- Fail a middle saga step: compensations run and the system ends consistent.
- Deploy a consumer on the old schema with a producer on the new one: no errors.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/outbox-and-sagas.md
# Outbox relay and saga implementation details
Read this when implementing an outbox relay, choosing between polling and CDC, or
building a saga orchestrator.
## Polling relay (Postgres)
Run one or more relay workers. `FOR UPDATE SKIP LOCKED` lets several workers share the
table without double-publishing the same row concurrently:
```sql
-- inside a transaction
SELECT id, aggregate_id, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each row to the broker and wait for broker acks, then:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;
```
Properties and caveats:
- If the relay crashes after publishing but before the UPDATE commits, rows are
published again on restart. This is why consumers must be idempotent.
- Holding the transaction open during broker publishing keeps row locks; keep batches
small (≤ 100) and set a publish timeout.
- Ordering: with multiple workers and `SKIP LOCKED`, events for the same aggregate can be
published out of order. If per-aggregate order matters, either run a single relay,
or shard relays by `hash(aggregate_id) % N`, and use `aggregate_id` as the broker
partition key.
- Poll interval: 100 ms–1 s. Add `LISTEN/NOTIFY` to wake the relay early if latency
matters.
- Cleanup: delete or archive rows older than N days where `published_at IS NOT NULL`,
in batches, to keep the partial index small.
## CDC relay (Debezium)
- Debezium reads the Postgres WAL via logical replication and streams outbox inserts to
Kafka; the outbox event router transform maps rows to topics and keys.
- Pros: no polling load, preserves commit order, low latency.
- Cons: operating Kafka Connect and replication slots; an inactive replication slot
retains WAL and can fill the disk. Monitor slot lag.
- With CDC, rows can be deleted immediately after insert in the same transaction (the
WAL still carries the insert), keeping the table empty.
## Choosing
| Situation | Choose |
|---|---|
| Low to moderate volume, no Kafka Connect | Polling relay |
| High volume, Kafka already in place, strict ordering | Debezium CDC |
| Managed cloud queue (SQS, Pub/Sub) | Polling relay, or the platform's CDC offering if available |
## Saga orchestrator state
Persist each saga instance:
```sql
CREATE TABLE saga_instances (
id uuid PRIMARY KEY,
type text NOT NULL, -- 'order_fulfillment'
state text NOT NULL, -- 'awaiting_payment', 'compensating', 'completed', 'failed'
data jsonb NOT NULL,
deadline_at timestamptz, -- for timeouts
version int NOT NULL DEFAULT 0, -- optimistic locking
updated_at timestamptz NOT NULL DEFAULT now()
);
```
Handling each reply/event:
1. Load the instance by correlation ID; verify the event is expected in the current state
(ignore duplicates and late arrivals for states already passed).
2. Compute the transition and the next command(s).
3. In one transaction: update state with `WHERE version = $v` and insert the next
commands into the outbox.
4. A timer job scans `deadline_at < now()` for instances stuck in a waiting state and
triggers the timeout transition.
## Example: order fulfillment (orchestrated)
```
PlaceOrder
→ ReserveStock ok → AuthorizePayment
fail → RejectOrder
→ AuthorizePayment ok → CapturePayment (after ship confirmation)
fail → ReleaseStock → RejectOrder
→ ShipOrder ok → CapturePayment → CompleteOrder
fail → VoidAuthorization → ReleaseStock → CancelOrder
Timeout waiting for payment (15 min) → ReleaseStock → CancelOrder
```
Authorize first and capture last: authorization is cheap to void, a capture requires a
refund.
## Testing
- Unit test the saga transition function as a pure state machine: (state, event) →
(new state, commands).
- Integration test with a real broker container: duplicate deliveries, out-of-order
replies, and a timeout path.
- Chaos check: kill the orchestrator mid-saga; on restart it resumes from persisted state.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.