Aggregate decisions
A state transition has a rule: which states it is legal from, and what it changes. Left in the handler, that rule scatters — the guard in one place, the write in another, sometimes duplicated and easy to forget. A decider puts it back on the aggregate: a pure method that validates the transition and returns the merge-patch to persist.
A decision on the aggregate¶
A decider is an ordinary method on the aggregate. It reads the current state, raises if the transition is illegal, and returns the update DTO — no I/O, no ports:
class Order(Document, AggregateRoot):
status: str = "pending"
def confirm(self) -> "OrderUpdate":
"""Decide the ``pending`` -> ``confirmed`` transition (illegal from any other status).
A pure decision method on the aggregate: it guards the transition and returns the merge-patch
the repository persists under the order's revision. The rule lives here once — the saga step
and the ``_on_confirm`` emitter both key on it instead of re-encoding it.
"""
if self.status != "pending":
raise exc.domain(f"cannot confirm an order in status {self.status!r}")
return OrderUpdate(status="confirmed")
@event_emitter(fields={"status"})
def _on_confirm(before, after: Self, diff: JsonDict) -> DomainEvent | None: # type: ignore[no-untyped-def]
if after.status == "confirmed" and before.status != "confirmed":
return OrderConfirmed(aggregate_id=after.id)
return None
confirm() is the single home for the pending → confirmed rule. The @event_emitter beside it still fires OrderConfirmed when the status actually changes — the decider owns when the transition is allowed, the emitter owns what event it produces.
Load, decide, apply¶
A handler runs the decision through AggregateRepository (forze_kits.aggregates): load the aggregate (so its behavior can run), decide by calling the method, apply the patch under the aggregate's revision:
async def _confirm(ctx: ExecutionContext, s: CheckoutCtx) -> CheckoutCtx:
# Pivot. A pre-commit failure (e.g. payment declined) compensates `reserve`.
if s.simulate_failure:
log.error("payment declined — failing the pivot step")
raise exc.domain("payment declined")
# Load the aggregate, let it decide the transition, persist the patch under its rev (OCC).
# confirm() single-sources the pending->confirmed rule (+ guards it); the resulting status
# change fires @event_emitter -> OrderConfirmed, dispatched in THIS step's transaction (the
# command flow), which the outbox bridge stages.
orders = aggregate_repository(ctx, ORDER_SPEC)
order = await orders.load(s.order_id)
await orders.apply(order, order.confirm())
# Transactional outbox: flush the staged event within the same transaction.
await ctx.outbox.command(OUTBOX_SPEC).flush()
log.info(
"order confirmed — OrderConfirmed staged to outbox", order_id=str(s.order_id)
)
return s
apply() persists the merge-patch as update(id, rev, patch), so a concurrent write that moved the revision is rejected — the same optimistic-concurrency guard as any other write (concurrency conflicts). The re-applied patch fires the aggregate's emitters, and the resulting domain events dispatch in the same transaction; the repository never dispatches them itself.
Why move it down¶
The hand-written alternative — command.update(id, rev, OrderUpdate(status="confirmed")) straight in the handler — works, but the rule lives nowhere: nothing guards that the order was pending, and the transition is re-encoded at every site that needs it (the step that writes it, the emitter that reacts to it). The decider:
- single-sources the rule — one
confirm(), called from every site, instead of a literal patch repeated per call; - guards the transition — an illegal
confirm()raisesexc.domaininstead of silently writing a bad state; - stays visible to the correctness brand — the guard runs on the operation path, so a simulation that drives an illegal transition observes the rejection (unlike a pure
@invariant, which the mock enforces before the write even lands).
Reach for a decider when a write is a transition with a rule; a plain command.update is still right for a field edit that carries no domain rule. When the rule spans more than one record, its cross-record counterpart is a system invariant.