ADR-0065. An Event Cannot Name an Aggregate Other Than the One Emitting It¶
aggregate_id was the one auto-populated field a caller could override, and
the override survived to the store, where it decides which stream the event
lands in. Emitting an event that names a different aggregate now raises
AggregateIdMismatchError.
Status¶
Accepted.
ADR Impact
| ADR | Status |
|---|---|
| 0046 | Extended. 0046 made the aggregate the single source for aggregate_type and left aggregate_id alone, because aggregate_id genuinely comes from the caller at construction. That stays true — this is not a second declaration site being collapsed. What is added is that the event's copy of it may not disagree with the aggregate's. |
| 0048 | Extended. 0048 established that a divergence invisible to a save/load round-trip is reported at the point it occurs rather than silently accepted, and applied it to a divergent aggregate_type. This is the same rule one field over. |
| 0022 | Stands. The decider path gets the guard through _stamp, which is where 0022 already put stamping. |
Context¶
An event's aggregate_id is its stream key. create_event() stamps it from
the aggregate, but an explicit keyword argument overrode that — and unlike
aggregate_type, the overridden value was never replaced downstream. It
reached the store intact.
The result is an event appended to a stream that disowns it. The aggregate that
emitted it never reads that stream. The aggregate the event names never
receives it, because it was appended under a different expected_version
lineage than that aggregate's own. Neither side can see the disagreement: load
the emitter and the event is absent; load the target and it is absent there
too. Every test passes, because a round-trip of either aggregate is internally
consistent. This is precisely the failure event sourcing is supposed to
preclude, and the library offered no guard against it.
The shape that produces it is ordinary: a command names a target
(ShipOrder(order_id=...)), and a handler copies that id onto the event it
builds. The named id is routing information — it selects which aggregate to
load — not a value to copy onto an event emitted from somewhere else.
Decision¶
Reject it, on the aggregate, from event.aggregate_id.
Reading the event's own id rather than a per-aggregate declaration of what may
be targeted is what makes this work for every aggregate with no opt-in, no new
base class, and no field to remember to set. The guard sits at three points on
one funnel: apply_event(is_new=True) is the backstop and catches
hand-constructed events; create_event() and DeciderAggregate._stamp() run
ahead of it so the message can name the command being handled.
Replay (is_new=False) is not checked. Rehydration reads a stream whose events
agree with it by construction, and checking there would turn a historical
mistake into an unloadable aggregate.
Rejected: a per-aggregate targeting declaration¶
A mixin (or class attribute) by which an aggregate declares which ids it may target. It fails earlier and can name the command type, which is a better message — but it requires every aggregate to opt in, and an aggregate that forgets is exactly the one with the bug. A guard that needs a declaration protects the codebases that did not need protecting.
Rejected: restamping the foreign id silently¶
Symmetrical with what aggregate_type used to do, and rejected for the reason
0048 gives: a caller who passed an id meant something by it, and quietly
substituting another turns a visible mistake into an invisible one.
Consequences¶
Code that relied on the override to emit an event about another aggregate now raises. There is no shim, per the pre-1.0 no-shim policy. The correct form is to load the named aggregate and emit from it — which is also the form that gets the target's version lineage and optimistic-concurrency check right, both of which the override skipped.
Cross-aggregate workflows are unaffected as long as they cross through the store: emit from one aggregate, project or subscribe, then load and emit from the other. The guard rejects one aggregate writing into another's stream, not one aggregate causing another to act.