Skip to content

    Concepts05

    Sagas, policies and processes

    Why Bounda has policies and processes but no saga module, and why compensation is a command the handler sends rather than a hook the runtime calls.

    “Saga” means three different things depending on whose documentation you read. In the paper that named it, it is a long transaction written as a sequence of shorter ones, each with a compensation that undoes its effect. In Axon and NServiceBus it is a class with state that listens to messages and decides what to do next. In the microservices literature it is both at once, split into a choreographed and an orchestrated flavour. Bounda keeps the word for the first meaning and gives the other two their own names. This page explains that split and the decision that follows from it: there is no compensation hook.

    Bounda has two kinds of module that react to events, and neither is called a saga.

    • A policy answers one event with commands and remembers nothing. Its module is a handler, with an optional on and delay; there is no state to declare.
    • A process is a process manager. It always lives on a main aggregate, has one instance per instance of that aggregate, and keeps state between events in a stream of its own. It listens to other aggregates’ events too, and correlate says which instance each one belongs to. It can have deadlines and a timeout.

    A saga is a pattern built with them: a business transaction of several steps where each step that can be undone has a compensation. Choreography builds it from policies that answer each other’s events, with no coordinator. Orchestration builds it from a process that holds the steps in one place. Chris Richardson’s saga pattern draws the same line. And the converse holds: not every policy or process is a saga. A policy that sends a confirmation email has nothing to compensate.

    Naming the pattern after what it is used for, not after a class, keeps the choice between choreography and orchestration open. Each module’s guide says when to use it: which one. For a saga, the rule of thumb is memory. A few independent steps fit policies, where each file is simple and no file says how the whole flow goes (the choreographed version). A flow that has to remember something, such as which payment belongs to the order or a deadline that moves, fits a process.

    A saga framework could give each step an on-failed.ts, called by the runtime when the step fails. Bounda does not, because the two ways a step can be told no already reach a handler, and a hook would be a third channel for the same thing.

    A command that says no answers its caller. Inside a policy or a process, await commands.x() resolves with the command’s answer, and a rejection is a value of it, not an exception. The handler that sent the step holds the answer in the same run and compensates on the spot. In the storefront checkout, a payment that settles for an order that is no longer open is given back:

    order/processes/order-lifecycle/payment/on-payment-settled.tsTypeScript
    export const handler = async ({ event, aggregateId, commands }: Process.HandlerArgs) => {
    const paid = await commands.markOrderPaid({ orderId: aggregateId });
    if (paid.rejected === "NotOpen") {
    await commands.cancelPayment({ paymentId: event.aggregateId, reason: "order no longer open" });
    }
    return { paymentDeadline: null };
    };

    Both commands commit together with the rest of the step, or neither does (what await commands.x() means). rejected is typed by the codes markOrderPaid declares, so the compensation is checked by the compiler.

    A failure that happens later, elsewhere, is an event. A declined card is not the answer to a command the process sent: the provider reports it through a webhook, declinePayment appends PaymentDeclined, and the process reacts to it with on-payment-declined.ts, as to any other event. When to make a “no” a rejection and when an event is the subject of Not every no is a fact.

    What is left is a step that throws: a provider that cannot be reached, a bug. That is not an answer of the domain, and compensating it would be wrong. The runtime retries it under the same idempotencyKey, and dead-letters it when it cannot succeed. This is the order Azure’s Compensating Transaction pattern recommends too: retry what may be transient, compensate only when going forward is impossible.

    So compensation is just another command. It is not registered ahead of time, run in reverse order, or called by the runtime. It is a command the handler sends because of what it was told, and like any command it decides from the state of its own aggregate (a compensation is a command that decides from state).

    A saga has no isolation: another saga, a customer or the outside world can act between two of its steps. Richardson’s QCon San Francisco 2017 slides list countermeasures for it. One is the version file: record the operations that arrived, a cancellation included, so that create then cancel ends where cancel then create does. He adds that it “sounds suspiciously like event sourcing”.

    In Bounda it is not a countermeasure to add. An aggregate’s stream already records the compensation, so the late step finds it in the state. A customer cancels, then pays with the link they still had open: settlePayment on a cancelled payment settles it and asks for the refund in the same decision. cancelPayment and settlePayment commute, and the money goes back once in either order (commutative compensations). The compensation is a fact of the stream, not an entry in a separate file that has to be kept in step with it.

    • Axon calls a saga an event listener that manages one business transaction: an instance with state, started and ended by events, found through an association property, which may take compensating actions. In Bounda’s terms that is a process.
    • NServiceBus describes a saga as a message-driven state machine with persisted state, whose instance a message finds through a mapped property, and which can request timeouts. Again, a process.
    • Temporal has no saga construct: the saga pattern is workflow code that registers a compensation before each step and runs them in reverse order when one fails. It shares Bounda’s view that compensation is ordinary code. It differs in the model: a stack of undo actions kept by the workflow, where Bounda has each compensation decide from its aggregate’s state, so the order matters only when one compensation depends on another.
    • Isolation. Nothing locks an aggregate for the length of a saga. A semantic lock is state you model, such as an order in paying that cancelOrder refuses, and it narrows a race rather than closing it (the semantic lock is state).
    • A compensation that fails for good. It is not compensated in turn. It becomes a dead letter, for an operator to fix and retry; what happens to the instance meanwhile is When a process fails.
    • Effects outside the store. A compensation decides inside the store; giving money back happens in a policy that reacts to the event it stored, with its idempotencyKey, at least once.
    • Steps in another store. A store, one database, one PostgreSQL schema or one Durable Object, is the unit a handler can await a command in. A step that runs in another store or another system cannot answer with a rejection; its answer comes back as an event.