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