Use cases and sagas

A command mutates exactly one aggregate. But some business actions must change several aggregates together, atomically — all of them or none. That is a use_case: a short saga expressed as a state machine, where each state runs one operation and hands off to the next, and the whole run commits or rolls back as a unit.

Chowk's Checkout is the canonical example. Placing an order and adding its first line are two separate commands on two separate aggregates (Order and OrderLine) — but a checkout is meaningless if only half of it happens. So the saga runs both in one transaction:

sub-domains/ordering/use_cases/checkout.vishwakarma
use_case Checkout {
  visibility public
  input      CheckoutInput
  output     CheckoutOutput
  systems    [OrderingSystem]
  transactional
  doc        "Place an order and add its first line in one transaction."

  machine {
    initial place_order

    state place_order {
      command PlaceOrder
      input {
        customer_id:      "input.customer_id"
        total:            "input.total"
        shipping_address: "input.shipping_address"
      }
      output_as order
      goto add_line
    }

    state add_line {
      command AddOrderLine
      input {
        order_id:   "result.order.id"
        product_id: "input.product_id"
        quantity:   "input.quantity"
        unit_price: "input.unit_price"
      }
      goto done
    }

    state done {
      terminal
      result "{ 'order_id': result.order.id }"
    }
  }
}

input and output — the saga's own message

Unlike a command (whose inputs are loose fields), a use_case takes one input object and returns one output object. Chowk declares both alongside the saga:

sub-domains/ordering/use_cases/checkout.vishwakarma
object CheckoutInput "Everything needed to open an order with its first line." {
  customer_id      uuid    required
  product_id       uuid    required
  quantity         int32   required
  unit_price       Money   required
  total            Money   required
  shipping_address Address required
}

object CheckoutOutput "The id of the order that was opened." {
  order_id uuid required
}

CheckoutInput gathers everything the whole flow needs up front; CheckoutOutput is the single result the caller gets back. The saga's job is to route the input through the states and assemble the output.

The state machine

The machine block is the flow itself. It names an initial state and then a set of states, each of which:

  • runs one operationcommand PlaceOrder, command AddOrderLine;
  • maps its inputs from data already in scope (below);
  • optionally binds its output with output_as, so a later step can read it;
  • hands off with goto <next>, until a terminal state ends the run.
stateDiagram-v2
  [*] --> place_order
  place_order --> add_line: goto (output_as order)
  add_line --> done: goto
  done --> [*]: terminal

Data flow between states

Every state's input block maps operation inputs from two sources, and this is the heart of how a saga threads data through:

  • input.* reads the saga's own input object. place_order pulls customer_id, total, and shipping_address straight from CheckoutInput.
  • result.<name>.* reads a prior step's output, captured by that step's output_as. place_order binds its result as order (output_as order), so add_line can set order_id: "result.order.id" — the id of the order that was just created.

That is how the second command learns the id produced by the first: the place_order state exposes its output as order, and add_line reads result.order.id from it. Finally the done state is terminal and assembles the saga's output with result "{ 'order_id': result.order.id }".

transactional — all or nothing

The transactional keyword is what makes the saga atomic. Both commands run inside a single database transaction: if add_line fails, the Order that place_order created is rolled back too. The caller never sees a half-checked-out state — an order with no lines, or a line pointing at an order that was never persisted. This is exactly why a multi-aggregate action becomes a use_case rather than being forced into one oversized command: the one-aggregate-per-command rule stays intact, and the transaction boundary sits where it belongs — around the whole flow.

When to reach for a use case

Use a plain command whenever a single aggregate is enough — that is the common case, and it is simpler. Reach for a use_case when, and only when:

  • a single action must mutate more than one aggregate, and
  • those changes must be atomic (all commit or all roll back), or the flow has multiple ordered steps with data passed between them.

If a flow instead reacts to something that already happened — asynchronously, in another sub-domain — that is not a saga but a subscription.

Overlay vs runtime

A use_case executes a flow; a business_process documents one. Chowk models checkout both ways: the PlaceAndPayOrder business process describes the flow in the architecture overlay — its steps, the capabilities they require, where it can fail — while this Checkout use case is the machine that actually runs it. The overlay is the plan, and it stays honest even for flows not built yet; the use case is the running saga. Reach for a business process to describe an experience, and a use case to execute a multi-aggregate transaction.