The Decider Pattern on Top of AggregateRoot¶
Aggregate Styles covers the two dispatch styles the library
ships: a hand-written _apply on AggregateRoot and @handles methods on
DeclarativeAggregate. Both are object-oriented — commands are methods that mutate the
aggregate, and business rules live inside those methods next to the infrastructure they
touch. There is a third way to organize the same aggregate that the library does not
ship as a class but fully supports: the decider pattern, where the entire domain is
three pure functions and the aggregate class shrinks to a thin adapter.
This document shows the pattern working against the real AggregateRepository
machinery via the library's DeciderAggregate, and quantifies what it costs. The
verdict up front: the decider is behaviorally identical to the imperative style, costs
nothing measurable on the replay path that dominates aggregate loading, costs about 50%
extra on the (already microsecond-scale) command path, and buys you domain logic that
can be unit-tested with plain asserts and no async machinery.
The pattern¶
A decider is three pure functions over plain values:
def initial_state() -> OrderState: ...
def decide(command: OrderCommand, state: OrderState) -> list[OrderEvent]: ...
def evolve(state: OrderState, event: OrderEvent) -> OrderState: ...
initial_state takes no arguments. The state before anything has happened is one
value for the aggregate type, not one per aggregate id — the aggregate a command
targets is named by the command, which is where identity belongs and is why the
fold never needs it. Every event decide returns gets its aggregate_id from the
command.
decide answers "given what has happened, is this request allowed, and what new facts
does it produce?" — it either returns events or raises a rejection. evolve answers
"given a fact, what is the next state?" — it is the fold that replay, projections, and
decide's own view of current state are all built from. Neither function touches
self, versions, the event store, or anything async. That purity is the entire point:
the business rules become data-in/data-out and testable in isolation.
Commands become values too — small frozen models rather than method calls:
class PlaceOrder(BaseModel):
order_id: UUID
customer_id: UUID
total: float
class ShipOrder(BaseModel):
order_id: UUID
tracking_number: str
OrderCommand = PlaceOrder | ShipOrder
With commands and state both being pydantic models, match can dispatch on the
(command, state) pair structurally, and decide reads like a state-transition table
— each case is one legal (or explicitly illegal) transition:
def decide(command: OrderCommand, state: OrderState) -> list[OrderEvent]:
match command, state:
case PlaceOrder(order_id=oid, customer_id=cid, total=total), OrderState(status="draft"):
return [OrderPlaced(aggregate_id=oid, customer_id=cid, total=total)]
case PlaceOrder(), _:
raise ValueError("Order already placed")
case ShipOrder(order_id=oid, tracking_number=tn), OrderState(status="placed"):
return [OrderShipped(aggregate_id=oid, tracking_number=tn)]
case ShipOrder(), _:
raise ValueError("Order must be placed before shipping")
Pydantic v2 models support class patterns with keyword capture out of the box, so this
works today with no library changes. evolve is the same shape the library already
uses everywhere — a match over event types returning model_copy(update={...}):
def evolve(state: OrderState, event: OrderEvent) -> OrderState:
match event:
case OrderPlaced(customer_id=cid, total=total):
return state.model_copy(update={
"customer_id": cid, "total": total, "status": "placed",
})
case OrderShipped():
return state.model_copy(update={"status": "shipped"})
case _:
return state
The case _ makes evolve total: an event with no branch is a no-op rather than an
error, which is the same silent-skip behavior a hand-written _apply has (see
Aggregate Styles for why DeclarativeAggregate defaults
its unregistered_event_handling to strict, and how to loosen it).
The imperative shell: DeciderAggregate¶
Everything in the library — AggregateRepository, snapshotting, the testing harness —
is written against AggregateRoot, so the decider plugs in through a small adapter
class. The library ships that adapter as DeciderAggregate: subclass it, implement the
three static methods above, and set aggregate_type:
from eventsource import DeciderAggregate
class Order(DeciderAggregate[OrderState, OrderCommand]):
aggregate_type = "Order"
@staticmethod
def initial_state() -> OrderState:
return initial_state()
@staticmethod
def decide(command: OrderCommand, state: OrderState) -> list[DomainEvent]:
return decide(command, state)
@staticmethod
def evolve(state: OrderState, event: DomainEvent) -> OrderState:
return evolve(state, event)
There is no _apply, no _get_initial_state, and no hand-rolled decider_state
property to guard against None. DeciderAggregate.__init__ calls
initial_state() eagerly, so state is a real OrderState from the
moment the aggregate is constructed — the very first PlaceOrder matches
(PlaceOrder(), OrderState(status="draft")) correctly, with no fallback to remember.
That fallback was the one genuine integration gotcha of the pattern on this library,
and DeciderAggregate closes it structurally rather than asking every implementer to
reproduce the guard.
Callers issue commands as values through execute(), inherited from
DeciderAggregate:
order_id = uuid4()
order = repo.create_new(order_id)
order.execute(PlaceOrder(order_id=order_id, customer_id=uuid4(), total=100.0))
await repo.save(order)
order.execute(ShipOrder(order_id=order_id, tracking_number="TRACK-001"))
await repo.save(order)
Wired into the README Quick Start (same store, bus, repository, subscription manager, and projection), this produces byte-for-byte identical output to the imperative version.
What it costs¶
Measured on the Quick Start Order aggregate: the imperative README implementation
versus the decider shell above, pure in-memory with no store or bus involved, so the
numbers isolate the domain-dispatch code itself. CPython 3.13, median of 7 runs;
absolute numbers will vary by machine, the ratios are the point.
| Path | Imperative | Decider | Ratio |
|---|---|---|---|
| Command: new order, place + ship (2 events) | ~15.0 µs/order | ~22.6 µs/order | 1.50x |
| Replay: rebuild from 1000-event history | ~1.22 µs/event | ~1.29 µs/event | 1.06x |
The asymmetry is the finding. Replay is a wash because per-event cost there is
apply_event bookkeeping plus one match, which both styles share; evolve adds only
a property access and, for the creation event, a model_copy where the imperative
branch constructs fresh state. Replay is the path that scales with stream length and
dominates loading an aggregate without a snapshot, so the decider does not slow down
the part that grows.
The command path pays 1.5x, and micro-benchmarking each piece in isolation shows the ~7.5 µs premium per place+ship pair decomposing into five small costs, largest first:
- Event re-stamping —
with_aggregate_version()is a full pydanticmodel_copyof each event (~1–2 µs apiece), paid because puredecidecannot know the version; the imperative style constructs the event once with the version inline. - Command objects — each command is a pydantic model (~0.9 µs to construct) where an imperative method takes plain arguments.
matchdispatch — the tuple class-pattern indecidebenches at roughly 3x an equivalentisinstancechain (≈940 ns vs ≈300 ns per command pair), and keyword-capture patterns inevolveat roughly 2x. Class patterns compile to isinstance checks plus__match_args__lookups, attribute loads, and equality tests.model_copyvs fresh construction —evolve'smodel_copy(update={...})on the creation event (~2.0 µs) is slower than the imperative branch's plainOrderState(...)(~1.3 µs).- The initial-state fallback — constructing a throwaway draft state (~0.9 µs) before
the first event, where the imperative style just checks
self.version > 0.
For scale, constructing a single DomainEvent subclass instance costs ~5–10 µs —
pydantic validation dominates both styles — and any real event-store append is orders
of magnitude above all of this. The premium is invisible in any system that persists
events; it would only matter in a tight in-memory simulation loop, and the first two
items (the majority of it) disappear if a future DeciderAggregate helper lets the
shell pass the version into event construction instead of re-stamping.
What it buys¶
The purchase is testability and reviewability of the domain in isolation:
def test_cannot_ship_draft_order():
state = initial_state()
with pytest.raises(ValueError, match="must be placed"):
decide(ShipOrder(order_id=order_id, tracking_number="X"), state)
def test_place_then_ship():
state = initial_state()
for cmd in (
PlaceOrder(order_id=order_id, customer_id=cid, total=100.0),
ShipOrder(order_id=order_id, tracking_number="X"),
):
for event in decide(cmd, state):
state = evolve(state, event)
assert state.status == "shipped"
No aggregate instance, no repository, no event loop, no fixtures. Given/when/then tests
are literally "fold these events, decide this command, assert on the result". The
decide function doubles as an exhaustive inventory of legal transitions — the kind of
thing the @handles registry gives you for apply dispatch, extended to command
dispatch.
When to use it¶
Reach for the decider when the transition rules are the complicated part — many
commands, state-dependent acceptance, rules you want product owners or property-based
tests to exercise without infrastructure. Stay imperative (or declarative) when the
aggregate is mostly plumbing with trivial rules; the three static methods are extra
ceremony there, and DeclarativeAggregate's creation-event enforcement and
unregistered-event modes are features DeciderAggregate does not replicate.
The two styles also compose: nothing stops execute from living alongside ordinary
command methods during a migration, because DeciderAggregate is just an
AggregateRoot subclass and every downstream consumer only sees that contract.
Command provenance: DomainCommand¶
decide() and execute() accept any object as a command — plain pydantic models work
fine, as in every example above. Subclassing DomainCommand instead
(eventsource.domain.command) is opt-in, and it buys the events decide() produces
some free bookkeeping: DeciderAggregate.execute() stamps causation_id (the
command's command_id), correlation_id, actor_id, and tenant_id onto every event
it applies, using isinstance(command, DomainCommand) to detect that the extra
provenance is available.
Stamping never overwrites a field decide() set explicitly — execute() checks
event.model_fields_set per field before assigning, so an event that already carries
its own correlation_id (for example, one built by caused_by()) keeps it. Tenant
resolution falls back in order: the command's tenant_id, then the ambient tenant
context, then untouched. DomainCommand.caused_by(event) copies an event's
correlation_id onto a new command, which is how a saga continues an existing
workflow chain instead of starting a new one — commands deliberately carry no
causation_id of their own; that linkage is by correlation.
Commands are never persisted — a command rejected by raising (conventionally
CommandRejectedError, though any exception works) leaves no trace in the event
store, and execute() runs decide() to completion before applying anything, so a
rejection leaves the aggregate untouched. See
ADR-0022 for the full rationale,
including why commands have no registry, no serialization, and no command bus.
How the shell works underneath¶
DeciderAggregate is not magic — it is exactly the adapter you would hand-write, with
the two gotchas below solved so you don't have to. Understanding what it does makes the
guarantees above concrete, and this is the shape the pattern had before
DeciderAggregate shipped:
class Order(AggregateRoot[OrderState]):
aggregate_type = "Order"
def _get_initial_state(self) -> OrderState:
return initial_state()
@property
def decider_state(self) -> OrderState:
# AggregateRoot._state is None until the first event; a decider
# needs a real initial state to match against before that.
return self._state if self._state is not None else initial_state()
def _apply(self, event: DomainEvent) -> None:
self._state = evolve(self.decider_state, event)
def execute(self, command: OrderCommand) -> None:
for event in decide(command, self.decider_state):
self.apply_event(event.with_aggregate_version(self.get_next_version()))
The decider_state fallback. AggregateRoot._state is None until the first
event is applied, and nothing in the library ever calls _get_initial_state() for you
— as Aggregate Styles notes, it exists to satisfy the abstract
contract and it is your code that decides whether to invoke it. The imperative style
dodges the None by guarding creation on self.version > 0 and having the creation
branch construct state from scratch. A decider cannot dodge it: decide needs a real
OrderState to match status="draft" against before the first event exists. Without
the fallback, the very first PlaceOrder matches (PlaceOrder(), None), falls through
to the rejection case, and fails with "Order already placed". DeciderAggregate solves
this by calling initial_state() in __init__, so state is never None in the
first place.
It also shifts where the creation invariant lives: version > 0 is an
infrastructure fact, status == "draft" is a domain fact. The decider forces the
domain phrasing, which is arguably more honest but means your state model must
actually encode "not yet created" (here, the "draft" status).
Version stamping via model_copy. Pure functions cannot know the next
aggregate_version — that is optimistic-concurrency bookkeeping, not domain logic. So
decide returns events without a meaningful version, and the shell stamps each one
with a model_copy before handing it to apply_event, which then validates the
version as usual. DeciderAggregate._stamp() does this centrally (alongside the
DomainCommand provenance fields above), so implementers never write the stamping
code themselves.