I’m starting a series on production-grade backend patterns on AWS. Each post pairs with a working repo you can clone and run on LocalStack (14-day Ultimate trial, no card required). First up: the saga pattern, implemented through choreography. The orchestration version of the same flow comes in the next post.
The repo is at github.com/danieljohnmorris/orchestration-vs-choreography.
The flow
A small order pipeline, four steps:
createOrderaccepts an HTTP POST, writes the order to DynamoDB with statusCREATED, emitsOrderCreated.reserveInventorylistens forOrderCreated. If the SKU is available, it emitsInventoryReserved. If not, it emitsInventoryFailed.chargePaymentlistens forInventoryReserved. If the card clears, it emitsPaymentCharged. If declined, it emitsPaymentFailed.notifyShippinglistens forPaymentChargedand emitsShippingScheduled.
Plus one compensating handler:
releaseInventorylistens forPaymentFailedand rolls back the reservation.
No service knows about the others. They only know what events to listen for and what events to emit.
The wiring
One EventBridge bus, five Lambdas, four rules connecting source events to target Lambdas. The CDK stack (choreography/lib/stack.ts):
const bus = new EventBus(this, 'SagaBus', { eventBusName: EVENT_BUS_NAME });
const rule = (id: string, detailType: string, target: NodejsFunction) =>
new Rule(this, id, {
eventBus: bus,
eventPattern: { source: [EVENT_SOURCE], detailType: [detailType] },
targets: [new LambdaFunction(target)],
});
rule('OrderCreatedRule', DETAIL_TYPE.ORDER_CREATED, reserveInventory);
rule('InventoryReservedRule', DETAIL_TYPE.INVENTORY_RESERVED, chargePayment);
rule('PaymentChargedRule', DETAIL_TYPE.PAYMENT_CHARGED, notifyShipping);
rule('PaymentFailedRule', DETAIL_TYPE.PAYMENT_FAILED, releaseInventory);
Event payloads are typed with zod (choreography/lib/events.ts). Each downstream schema extends the previous one and adds one field. By the time notifyShipping runs, the event carries every field added by createOrder, reserveInventory, and chargePayment. No service has to look anything up. That only works because the events are well-formed, which the zod parse at the top of each handler enforces:
export const handler: EventBridgeHandler<string, unknown, void> = async (event) => {
const reserved = InventoryReserved.parse(event.detail);
// ...
};
Compensation
When chargePayment decides the card is bad, inventory has already been reserved upstream by reserveInventory. Something has to release that reservation. In an orchestrator, that’s a Catch block. In choreography, it’s another handler subscribed to a failure event:
// charge-payment.ts on failure
await publish(DETAIL_TYPE.PAYMENT_FAILED, { ...reserved, reason: 'card declined' });
// release-inventory.ts on PaymentFailed
await ddb.send(new UpdateItemCommand({
TableName: ORDERS_TABLE,
Key: marshall({ orderId: failed.orderId }),
UpdateExpression: 'SET #s = :s',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: marshall({ ':s': 'INVENTORY_RELEASED' }),
}));
The compensating action is another rule on the bus. Nothing else in the system needs to know it exists.
The cost: there is no single place that describes the whole flow. To understand what happens after PaymentFailed, you have to search the codebase for the rule that subscribes to it.
Running it on LocalStack
I deployed and ran this stack on LocalStack Ultimate. I did not test the free Hobby tier, so I can’t confirm what works there.
cp .env.example .env
# add LOCALSTACK_AUTH_TOKEN
docker compose up -d
pnpm install
pnpm --filter ./choreography run deploy
pnpm --filter ./choreography run trigger
Trigger one happy-path order and three downstream Lambdas fire in sequence. The final record:
{
"orderId": "94f7a421-3866-4b93-9361-57cc6eb908e1",
"status": "SHIPPING_SCHEDULED",
"reservationId": "cf9677b2-eadb-4e94-b1fb-5bdf422ffc06",
"paymentId": "e1bc6733-9c6c-444e-aca0-3bf30ec31727",
"trackingNumber": "TRK-FB5EEAF2"
}
Force the out-of-stock path with sku: "OUT-OF-STOCK":
{ "status": "INVENTORY_FAILED", "failureReason": "out of stock" }
Force the decline path with customerId: "CUST-DECLINE":
{ "status": "INVENTORY_RELEASED", "failureReason": "card declined", "reservationId": "ed8254ab-..." }
The reservation existed and was released by releaseInventory reacting to the PaymentFailed event.
Bundling gotcha
One thing worth flagging if you fork the repo. The first deploy failed with getRandomValues2 is not a function at runtime. The cause was @aws-sdk/lib-dynamodb pulling in @smithy/core, whose internal UUID generator gets mangled when esbuild bundles it for CJS Lambda output. The fix was to drop lib-dynamodb, use the low-level @aws-sdk/client-dynamodb with marshall from @aws-sdk/util-dynamodb, and externalise @aws-sdk/* and @smithy/* from the bundle. The Lambda runtime provides them.
new NodejsFunction(this, name, {
// ...
bundling: {
target: 'node20',
externalModules: ['@aws-sdk/*', '@smithy/*'],
},
});
If a future SDK release fixes the bundling, the externals can be dropped.