“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.
Two modules, one pattern
Section titled “Two modules, one pattern”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 optionalonanddelay; 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
correlatesays which instance each one belongs to. It can have deadlines and atimeout.
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.
Why compensation has no hook
Section titled “Why compensation has no hook”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:
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).
Event sourcing is the version file
Section titled “Event sourcing is the version file”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.
How other frameworks draw the lines
Section titled “How other frameworks draw the lines”- 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.
What stays outside
Section titled “What stays outside”- Isolation. Nothing locks an aggregate for the length of a saga. A semantic lock is state you
model, such as an order in
payingthatcancelOrderrefuses, 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
awaita command in. A step that runs in another store or another system cannot answer with a rejection; its answer comes back as an event.
Where to read more
Section titled “Where to read more”- Sagas and compensation, the storefront checkout built step by step, and the storefront example it comes from.
- What a command answers, the result a
policy or process gets from
await commands.x(). - Not every no is a fact, for when a “no” is an event.
- Hector Garcia-Molina and Kenneth Salem, Sagas, 1987.
- Chris Richardson, Pattern: Saga, his talk Using sagas to maintain data consistency in a microservice architecture and his QCon San Francisco 2017 slides.
- Azure Architecture Center, Compensating Transaction pattern.
- Axon’s saga implementation, NServiceBus’s sagas and Temporal’s saga pattern.