Event-Driven Architecture
This begins where a domain event leaves the process. The architecture skill
owns the inside of a bounded context - returning events as values, one
aggregate per transaction. This owns the wire: what happens once an event
crosses a process or service boundary and someone you don't deploy with
consumes it.
The move across that boundary is heavy and rarely the default. Reach for it
when the coupling genuinely warrants it, not because events feel scalable.
Decision Tree
Does this cross a process/service boundary?
|-- No -> stay in-process; return events as values, dispatch in the same
| transaction. You do not need any of this. (see `architecture`)
|-- Yes, and the caller needs the answer now
| `-- synchronous request/response. A queue here only adds latency and
| failure modes. Async is not a resilience upgrade by default.
`-- Yes, and the work can happen after the fact
`-- async messaging. Now these bite, roughly in order:
|-- how does the event leave the context? -> references/event-propagation.md
|-- how is it published without being lost? -> references/reliable-publication.md
|-- what happens on redelivery or failure? -> references/delivery-semantics.md
|-- how does it change over time? -> references/schema-versioning.md
`-- who coordinates a multi-step flow? -> references/topology.md
When To Go Async At All
Default to synchronous request/response. Reach for a queue or broker only to
buy one of four things, and name which:
- temporal decoupling - producer and consumer need not be up at once
- load levelling - absorb spikes, consume at a steady rate
- fan-out - many independent consumers react to one fact
- long-running work - the caller must not block on it
If none of these is the reason, a direct call is simpler and easier to reason
about. "Might need to scale later" is a reason to keep the boundary clean, not
to add a broker now.
The Distinctions That Bite
Each of these is routinely blurred, and each blur is a production bug waiting to
happen. Hold them as explicit pairs.
- Domain event vs integration event - a rich in-process fact vs a
serialisable thing another service consumes. Whether they are one type or two
is a genuine design choice, not a rule - see
references/event-propagation.md.
- Event notification vs event-carried state transfer vs event sourcing -
three of Fowler's four (CQRS being the fourth, and arguably not about events),
orthogonal. Thin "it happened, call back" vs fat "here is the new state, keep
your own copy" vs "the log is the source of truth." You can do any without the
others; conflating them makes consistency bugs undiagnosable.
- At-least-once delivery vs exactly-once processing - exactly-once
delivery end-to-end is not achievable; you engineer effectively-once by
pairing at-least-once delivery with an idempotent consumer. "Exactly-once
processing" (Kafka's EOS term) is a real, defended term, not a myth -
"effectively-once" is a synonym for it, not a correction. Kafka EOS and SQS
FIFO hold only inside their own boundary - a write to an external DB or API
still needs your own idempotency or an outbox.
- Additive vs breaking schema change - adding an optional/defaulted field is
safe and evolves in place; removing, renaming, retyping, or re-meaning a
field is breaking and needs a new version, type, or topic. The subtlest
breaking change is a semantic one under the same name and type - no registry
catches it.
- Choreography vs orchestration - no central coordinator (emergent,
decoupled, hard to observe end-to-end) vs a process manager issuing commands
(explicit, debuggable, a coupling point that can rot into a god-service).
Trust vs control - see
references/topology.md.
- Queue vs log - a transient competing-consumer queue (RabbitMQ/SQS: max
parallelism, no replay, ordering lost under competing consumers) vs a retained
partitioned log (Kafka: replay, per-key order, but parallelism capped at
partitions and one slow message blocks its partition). You cannot have
unbounded per-key ordering and unbounded fan-out parallelism.
Two Things That Always Hold
Everything above is a choice with trade-offs. These two are not:
- Never serialise a live aggregate onto the wire. The wire payload is its
own type - an event contract, versioned - not your domain object leaking its
shape to every consumer.
- Anything that crosses a service boundary is a public contract. Treat an
integration event like a published API: independent consumers, versioned,
evolved by the rules in
references/schema-versioning.md. Inside your own
process you can refactor freely; across the boundary you cannot.
Reaching Out
- Consumer-driven contract testing (e.g. Pact) - see the
testing skill.
- Mechanically gating contract compatibility in CI (buf breaking, oasdiff,
semver checks) - see the
mechanical-enforcement skill's Boundary contracts.
- Parsing the wire payload back into a trusted domain type at the boundary -
parse-don't-validate, see
architecture / typescript.
- End-to-end observability across the async hops this skill keeps flagging ("where
is order 123 stuck?"): propagate W3C Trace Context (
traceparent /
tracestate) in message headers so one trace spans producer -> broker ->
consumer, and prefer OTel span links over parent-child (a consumer may
process minutes later, so nesting distorts trace duration). Adopt the OTel
messaging semantic conventions (messaging.system, messaging.destination.name,
PRODUCER/CONSUMER span kinds). The structured-log and correlation-ID foundation
is the architecture skill's Observability section.
1---2name: event-driven-architecture3description: Design messaging across service and process boundaries - when to go async, how events propagate between contexts, reliable publication (outbox/inbox, dual-write), delivery semantics (at-least-once, idempotent consumers, DLQ, ordering), and event/schema versioning. Use when work crosses a service boundary with queues, brokers, or streams (Kafka/RabbitMQ/SQS), integration events, or an evolving event schema.4---5
6# Event-Driven Architecture
7
8This begins where a domain event leaves the process. The `architecture` skill
9owns the inside of a bounded context - returning events as values, one
10aggregate per transaction. This owns the wire: what happens once an event
11crosses a process or service boundary and someone you don't deploy with
12consumes it.
13
14The move across that boundary is heavy and rarely the default. Reach for it
15when the coupling genuinely warrants it, not because events feel scalable.
16
17## Decision Tree
18
19```text
20Does this cross a process/service boundary?
21|-- No -> stay in-process; return events as values, dispatch in the same
22| transaction. You do not need any of this. (see `architecture`)
23|-- Yes, and the caller needs the answer now
24| `-- synchronous request/response. A queue here only adds latency and
25| failure modes. Async is not a resilience upgrade by default.
26`-- Yes, and the work can happen after the fact
27 `-- async messaging. Now these bite, roughly in order:
28 |-- how does the event leave the context? -> references/event-propagation.md
29 |-- how is it published without being lost? -> references/reliable-publication.md
30 |-- what happens on redelivery or failure? -> references/delivery-semantics.md
31 |-- how does it change over time? -> references/schema-versioning.md
32 `-- who coordinates a multi-step flow? -> references/topology.md
33```
34
35## When To Go Async At All
36
37Default to synchronous request/response. Reach for a queue or broker only to
38buy one of four things, and name which:
39
40- **temporal decoupling** - producer and consumer need not be up at once
41- **load levelling** - absorb spikes, consume at a steady rate
42- **fan-out** - many independent consumers react to one fact
43- **long-running work** - the caller must not block on it
44
45If none of these is the reason, a direct call is simpler and easier to reason
46about. "Might need to scale later" is a reason to keep the boundary clean, not
47to add a broker now.
48
49## The Distinctions That Bite
50
51Each of these is routinely blurred, and each blur is a production bug waiting to
52happen. Hold them as explicit pairs.
53
54- **Domain event vs integration event** - a rich in-process fact vs a
55 serialisable thing another service consumes. Whether they are one type or two
56 is a genuine design choice, not a rule - see `references/event-propagation.md`.
57- **Event notification vs event-carried state transfer vs event sourcing** -
58 three of Fowler's four (CQRS being the fourth, and arguably not about events),
59 orthogonal. Thin "it happened, call back" vs fat "here is the new state, keep
60 your own copy" vs "the log is the source of truth." You can do any without the
61 others; conflating them makes consistency bugs undiagnosable.
62- **At-least-once delivery vs exactly-once processing** - exactly-once
63 *delivery* end-to-end is not achievable; you engineer **effectively-once** by
64 pairing at-least-once delivery with an idempotent consumer. "Exactly-once
65 *processing*" (Kafka's EOS term) is a real, defended term, not a myth -
66 "effectively-once" is a synonym for it, not a correction. Kafka EOS and SQS
67 FIFO hold only inside their own boundary - a write to an external DB or API
68 still needs your own idempotency or an outbox.
69- **Additive vs breaking schema change** - adding an optional/defaulted field is
70 safe and evolves in place; removing, renaming, retyping, or *re-meaning* a
71 field is breaking and needs a new version, type, or topic. The subtlest
72 breaking change is a semantic one under the same name and type - no registry
73 catches it.
74- **Choreography vs orchestration** - no central coordinator (emergent,
75 decoupled, hard to observe end-to-end) vs a process manager issuing commands
76 (explicit, debuggable, a coupling point that can rot into a god-service).
77 Trust vs control - see `references/topology.md`.
78- **Queue vs log** - a transient competing-consumer queue (RabbitMQ/SQS: max
79 parallelism, no replay, ordering lost under competing consumers) vs a retained
80 partitioned log (Kafka: replay, per-key order, but parallelism capped at
81 partitions and one slow message blocks its partition). You cannot have
82 unbounded per-key ordering *and* unbounded fan-out parallelism.
83
84## Two Things That Always Hold
85
86Everything above is a choice with trade-offs. These two are not:
87
88- **Never serialise a live aggregate onto the wire.** The wire payload is its
89 own type - an event contract, versioned - not your domain object leaking its
90 shape to every consumer.
91- **Anything that crosses a service boundary is a public contract.** Treat an
92 integration event like a published API: independent consumers, versioned,
93 evolved by the rules in `references/schema-versioning.md`. Inside your own
94 process you can refactor freely; across the boundary you cannot.
95
96## Reaching Out
97
98- Consumer-driven contract testing (e.g. Pact) - see the `testing` skill.
99- Mechanically gating contract compatibility in CI (buf breaking, oasdiff,
100 semver checks) - see the `mechanical-enforcement` skill's Boundary contracts.
101- Parsing the wire payload back into a trusted domain type at the boundary -
102 parse-don't-validate, see `architecture` / `typescript`.
103- End-to-end observability across the async hops this skill keeps flagging ("where
104 is order 123 stuck?"): propagate **W3C Trace Context** (`traceparent` /
105 `tracestate`) in message headers so one trace spans producer -> broker ->
106 consumer, and prefer OTel span **links** over parent-child (a consumer may
107 process minutes later, so nesting distorts trace duration). Adopt the OTel
108 messaging semantic conventions (`messaging.system`, `messaging.destination.name`,
109 PRODUCER/CONSUMER span kinds). The structured-log and correlation-ID foundation
110 is the `architecture` skill's Observability section.