Skip to content

0056. initial_state() Is Nullary; the Command Carries the Aggregate Id

A decider is (decide, evolve, initialState, isTerminal), and initialState is a value — the fold's identity element. This library declared it as a function of the aggregate id, which made every decider state model carry an id field whose only reader was decide, building events.

Status

Accepted.

ADR Impact

ADR Status
0022 Amended. Its Decision 2 lists initial_state/decide/evolve as the three abstract static methods, with initial_state taking the aggregate id. initial_state now takes no arguments. Everything else in that record — eager state initialization, execute()'s stamping and precedence, atomic rejection, structural typing, CommandRejectedError — is unchanged.
0042 Stands. Provenance stamping is untouched; aggregate_id was never a stamped field and still is not.
0046 Stands, and this extends its reasoning to a second field. aggregate_type has one source, the aggregate class. aggregate_id has one source too — the command — rather than being copied into state so decide can read it back out.

Context

DomainEvent.aggregate_id is required, and decide is a pure static function with no self, so the events decide returns have to get that id from one of its two arguments. The original design routed it through the state: initial_state(aggregate_id) received the id, stashed it in a field, and decide read state.order_id when constructing events. ADR 0022 recorded that as an explicit contract note.

That works, and it is wrong in a way that costs something. The state of a decider is the fold of one aggregate's events; the value before any event is not a fact about any particular aggregate, it is one value for the whole aggregate type. Threading an id through it makes the identity element a family of identity elements indexed by something the fold never uses, and forces a required, defaultless field into every decider state model — the one field that could not be recovered by replaying the stream, sitting in a model whose entire point is that it is recoverable by replaying the stream.

The other argument was already carrying the id in practice. A command is a request to do something to a particular aggregate; the caller has to name it, and the getting-started guide's own commands already declared account_id before this change. So the id was being supplied twice on every command path — once to the constructor, once into the state — and read from the copy.

Decision

DeciderAggregate.initial_state() takes no arguments.

decide(command, state) obtains the aggregate id from the command. A command names the aggregate it targets; that is what distinguishes it from an event, which is already attached to a stream. Decider state models therefore carry no id field, and every field they do carry is derivable from the event stream.

This is a breaking change to a public domain-ring API and ships with no shim, per the pre-1.0 policy: no two-argument overload, no *args, no signature sniffing. A subclass deletes the parameter from initial_state, drops the id field from its state model, adds the target id to each command, and captures it in the decide match arm instead of reading it off state.

DeciderScenario loses its aggregate_id= constructor argument for the same reason: its only job was feeding initial_state, and the scenario now has no identity of its own. Its initial_state= argument is a zero-argument callable.

The instance-level machinery is untouched. _get_initial_state(), _apply(), and __init__ still have self.aggregate_id — only the static abstract method's signature changes.

Consequences

The decider's three functions now match the pattern as it is described everywhere else, which matters for a style this library recommends by default: a reader who knows the decider pattern no longer has to account for a local deviation, and a reader learning it here is not taught one.

State models get smaller and, more usefully, get an invariant: everything in a decider state is a fold of the events. A field that cannot be reconstructed from the stream is now a defect rather than a convention.

Commands get a required id field they mostly already had. Where they did not, the cost is one line per command and one capture per match arm — and the argument is not ceremony: a command with no target was already relying on the caller having routed it to the right aggregate instance, with nothing in the command itself recording that intent.

The aggregate does not verify that a command's id matches its own. execute() stamps aggregate_version and aggregate_type but leaves aggregate_id to decide, so a command carrying the wrong id produces events attributed to another stream. The alternative — stamping aggregate_id in _stamp() the way the other two are stamped — would make the command's id redundant again and reintroduce exactly the two-sources problem ADR 0046 removed for aggregate_type. Application code that accepts ids from outside should check the id it routes on against the id it constructs the command with, which is a check it wants regardless.