ADR-0022: Command Objects and the Decider Aggregate Style¶
Status¶
Accepted (2026-07-30)
Amended by ADR 0030
(DomainCommand relocates from eventsource.commands.base to
eventsource.domain.command; the decider pattern and command-object design
below are unchanged).
Amended by ADR 0042. The decider
pattern and command-object design below are unchanged. ADR 0042 unifies
DeciderAggregate's provenance stamping with create_event()'s via a
shared _provenance_updates() helper, so the ambient tenant-context
fallback now applies to every command type the decider handles, not only
DomainCommand subclasses.
Amended by ADR 0056
(initial_state takes no arguments; decide obtains the aggregate id from the
command, which names the aggregate it targets. The rest of Decision 2 — eager
state initialization, execute()'s stamping and precedence, atomic rejection —
is unchanged).
Context¶
The library shipped two aggregate styles (hand-written _apply on
AggregateRoot; @handles on DeclarativeAggregate). The decider pattern —
the domain as pure decide/evolve functions — was documented in
docs/explanation/decider-pattern.md as a userland recipe with two structural
problems: AggregateRoot._state is None until the first event (a naive
decider rejects its first command), and version stamping required a manual
model_copy per event. Separately, DomainEvent carries
correlation_id/causation_id/actor_id/tenant_id but nothing originated
the chain: with_causation() links event→event only. In CQRS the originator
of an event chain is the command. Benchmarks (2026-07-30) showed the decider's
overhead is ~1.5x on the command path (~8 µs/order, dominated by pydantic event
construction paid by all styles) and ~1.06x on replay: maintainability, not
performance, decides.
Decision¶
DomainCommand(entities ring,commands/besideevents/): frozen pydantic model withcommand_id,issued_at,correlation_id,actor_id,tenant_id. Commands are never persisted — a rejected command leaves no trace. No registry, no serialization, no command bus. Commands have nocausation_idfield;caused_by(event)copies the event'scorrelation_idso saga-issued commands continue the workflow chain by correlation.DeciderAggregatesubclassesAggregateRoot: abstract staticinitial_state/decide/evolve; eager state initialization (state is neverNone);execute(command)runsdecideto completion, then stamps each event with onemodel_copy— alwaysaggregate_versionandaggregate_type; forDomainCommands alsocausation_id=command_id,correlation_id,actor_id,tenant_id. Precedence: fields inevent.model_fields_setare never overwritten. Tenant resolution: command value → tenant context → untouched. Rejections are atomic: no version bump, no uncommitted events.- Structural typing, opt-in provenance:
decide/execute/create_eventaccept any object as a command;isinstance(command, DomainCommand)is what unlocks provenance.create_event(command=...)gives the imperative and declarative styles identical stamping. CommandRejectedErroris the conventional (not required) rejection type: one catchable exception meaning "the domain said no".- The decider is the primary showcased style: examples, tutorials, and general-purpose fixtures lead with it; imperative and declarative each keep one worked reference example.
Consequences¶
- Every event can be traced to the command that caused it and the actor who
issued it;
causation_idreferences acommand_idthat is resolvable only if the application logs its commands, but it still groups the events of one command and marks them command-caused. - The command path pays one
model_copyper event (~1–2 µs); replay is unaffected. Rejected alternatives: an event-specdecidecontract (faster but non-standard signature, awkward assertions) and version-at-append (ADR-scale churn to save ~2 µs/event). - ADR-0001 (async-first) stands:
decide/evolveare sync pure functions like_apply; I/O boundaries remain async. ADR-0012 stands: commands have no registry by design. ADR-0018 stands: this ADR documents the command-then-context tenant resolution order as an extension. ADR-0019 stands: commands are entities-ring; no port changes.