Choreography with EventBridge

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:

  1. createOrder accepts an HTTP POST, writes the order to DynamoDB with status CREATED, emits OrderCreated.
  2. reserveInventory listens for OrderCreated. If the SKU is available, it emits InventoryReserved. If not, it emits InventoryFailed.
  3. chargePayment listens for InventoryReserved. If the card clears, it emits PaymentCharged. If declined, it emits PaymentFailed.
  4. notifyShipping listens for PaymentCharged and emits ShippingScheduled.

Plus one compensating handler:

  1. releaseInventory listens for PaymentFailed and 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.