Skip to content

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() raises exc.domain instead 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.