Same order saga as the previous post in this series, implemented through orchestration instead of choreography. Same four handlers, same DynamoDB table, same three terminal states. The control flow moves from EventBridge rules to a Step Functions state machine.
Repo: github.com/danieljohnmorris/orchestration-vs-choreography, in the orchestration/ subfolder.
What changes
The handlers stop publishing events and start returning values. The state machine collects each step’s output and passes it to the next as input. So createOrder returns an Order, the state machine hands that to reserveInventory, which returns a ReservedOrder, and so on.
// orchestration/handlers/reserve-inventory.ts
export const handler = async (input: Order): Promise<ReservedOrder> => {
const order = Order.parse(input);
// ... reserve in DynamoDB ...
return { ...order, reservationId };
};
For failure, the handler throws a typed error. The state machine catches it.
if (order.sku === OUT_OF_STOCK_SKU) {
// ... mark order as INVENTORY_FAILED in DynamoDB ...
throw new InventoryUnavailable(order.sku);
}
InventoryUnavailable.name is 'InventoryUnavailable', which is the string the state machine matches on in a Catch.
The state machine
The CDK definition (orchestration/lib/stack.ts):
const createTask = new LambdaInvoke(this, 'CreateOrder', {
lambdaFunction: createOrder, payloadResponseOnly: true,
});
// ... reserveTask, chargeTask, shipTask, releaseTask ...
chargeTask.addCatch(releaseTask.next(paymentFailed), {
errors: ['PaymentDeclined'],
resultPath: '$.error',
});
reserveTask.addCatch(inventoryFailed, {
errors: ['InventoryUnavailable'],
resultPath: '$.error',
});
const definition = createTask
.next(reserveTask)
.next(chargeTask)
.next(shipTask);
The happy path is linear: create → reserve → charge → ship. The catches branch off: a PaymentDeclined on the charge step routes to releaseInventory first, then to a Fail state. An InventoryUnavailable on the reserve step goes straight to a Fail state, because no compensation is needed - nothing was reserved.
The whole flow is in this one definition. To trace what happens after a payment failure, you read the next state, not search a separate rule file.
Running it on LocalStack
LocalStack Ultimate runs full Step Functions locally with the execution history that makes the visual flow useful. The free Hobby tier has limited Step Functions support; I haven’t tested that path.
cp .env.example .env
# add LOCALSTACK_AUTH_TOKEN
docker compose up -d
pnpm install
pnpm --filter ./orchestration run deploy
pnpm --filter ./orchestration run trigger
The trigger script starts an execution and polls until it finishes. The happy-path output:
Started: arn:aws:states:eu-west-2:000000000000:execution:ovsc-orchestration-saga:7f443281-...
Status: SUCCEEDED
Output: {"orderId":"cdf6a096-2074-4d3a-a159-cd677aca60d1", ...,
"trackingNumber":"TRK-601FAFE2"}
The out-of-stock path:
Started: arn:aws:states:eu-west-2:000000000000:execution:ovsc-orchestration-saga:6b74dd9f-...
Status: FAILED
Cause: Inventory unavailable
The decline path runs the compensation through the Catch:
Started: arn:aws:states:eu-west-2:000000000000:execution:ovsc-orchestration-saga:9a755b37-...
Status: FAILED
Cause: Payment declined and inventory released
DynamoDB state for the declined order shows status: INVENTORY_RELEASED with reservationId set, matching the choreography version’s terminal state for the same input.
What you can do that you couldn’t before
A few capabilities show up that the choreography version doesn’t have, at least not without extra plumbing:
- One execution ARN identifies the whole saga. With choreography, a request fans out into multiple Lambda invocations with no shared parent ID unless you wire one through every event payload.
- The execution history is queryable.
DescribeExecutionreturns input, output, and the full state transition log. With choreography you have to stitch the same view from CloudWatch logs across five functions. - Failed executions can be retried from the start. Step Functions has
StartExecutionwith a redrive option. Retrying a choreographed flow means re-emitting the original event into a bus that may have already done some of the work. - The flow is editable in one file. Adding a
ShippingFailedCatch on the ship task that releases payment is one new branch in the same CDK definition.
The trade-off is that Step Functions has its own cost model (per state transition, on Standard workflows) and its own quirks (state input/output handling, the JSONPath syntax for resultPath, the difference between Standard and Express workflows). The orchestration code is shorter and the flow is clearer, but you’ve taken on a new service to know.