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.

  • nameevent-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.

~/.claude/skills/event-driven-architecture/SKILL.md

--- name: event-driven-architecture description: Designs event-driven systems that stay correct under at-least-once delivery, separating events from commands, choosing notification vs.… license: MIT ---

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

KindSemanticsExampleRouting
EventFact, many subscribers, producer does not care who listensPaymentCapturedTopic / pub-sub
CommandRequest, exactly one handler, may be rejectedCapturePaymentQueue to one service
QueryRequest for data, synchronousGET /payments/42HTTP/gRPC
Event stylePayloadUse whenCost
NotificationIDs only ({ orderId })Consumers need fresh data or payload is sensitive/largeConsumers call back to the producer (coupling, load)
Event-carried state transferRelevant state ({ orderId, lines, total, address })Consumers build local read models, producer must not be calledLarger payloads, schema discipline
Event sourcingEvents are the source of truthAudit-heavy domains needing full historyHigh 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

StyleHowUse when
ChoreographyEach service reacts to events and emits its own2–3 steps, few participants, simple compensation
OrchestrationA coordinator (state machine) sends commands and tracks state4+ steps, branching, timeouts, need to see workflow state

Rules:

  • Every step that can be undone has a compensating action (ReleaseStock for ReserveStock, RefundPayment for CapturePayment). 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

ChangeCompatible?How
Add optional fieldYes (backward and forward)Consumers ignore unknown fields
Add required fieldNo for old producersAdd as optional with default first
Remove fieldNo for consumers reading itDeprecate, confirm no readers, then remove
Rename fieldNoAdd new, dual-write, migrate consumers, remove old
Change type/meaningNoNew event type or new major version
New enum valueOften breaks consumers with exhaustive switchesDocument “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 correlationId and 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

  1. Kill the service between DB commit and publish: the event still arrives (outbox works).
  2. Deliver the same message twice: state changes once (inbox works).
  3. Fail a middle saga step: compensations run and the system ends consistent.
  4. 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 106 lines · 4.1 KB
# 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.

esc