System design · AWS

Event-driven checkout on AWS: EventBridge, SQS, and the dual-write problem

A production pattern for order intake that survives retries, duplicate events, and a payment provider that will call you twice. The architecture is simple. The failure modes are not.

API GatewayLambdaEventBridgeSQSDynamoDBStep Functions

Checkout is the first place a “clean” microservice diagram meets money. The API must acknowledge the client in tens of milliseconds, the payment provider will retry, inventory must not be sold twice, and finance will ask why an order exists without a capture. I design this as an event-driven pipeline on AWS — not because events are fashionable, but because they force you to name the boundaries where consistency actually lives.

The shape of the system

The browser talks to a synchronous edge: API Gateway plus a thin command Lambda. That Lambda does three things only: authenticate the caller, write an OrderCreated record with a client-supplied idempotency key, and publish an event. Everything else — payment capture, inventory reservation, email, analytics — is downstream and asynchronous.

Command path (synchronous) vs reaction path (asynchronous)
Client
  → API Gateway (JWT / WAF)
    → Checkout command Lambda
      → DynamoDB TransactWrite
         • orders#{orderId}          (status = PENDING)
         • idempotency#{clientKey}   (condition: attribute_not_exists)
      → EventBridge: checkout.order.created
         → SQS: payments
         → SQS: inventory
         → SQS: notifications

The dual-write you must not make

The classic failure is: write to DynamoDB, then publish to EventBridge. If the publish fails, you have an order that nobody will process. If you publish first and the write fails, you have an event for an order that does not exist. Do not “fix” this with a best-effort retry in the request path. You have moved the consistency problem into the client’s timeout.

Idempotency is a data model, not a header

  • The client sends Idempotency-Key on every POST. Treat it as unique per caller, not globally unique forever — partition it by buyer id.
  • Store the key, the request hash, and the order id together. A retry with the same key and same body returns the original response. A retry with the same key and a different body is a 409.
  • Payment webhooks get their own key: provider + event id. Stripe, bKash, and SSLCOMMERZ will all deliver the same event more than once.
  • SQS consumers must be idempotent on order id + event type. At-least-once delivery is the contract, not a bug.

Where I put the slow work

Payment capture is a Step Functions workflow, not a Lambda that calls the PSP inline. Authorization can succeed, capture can fail, and a human may refund. A state machine with a wait-for-callback task token maps cleanly onto that. Inventory reservation sits on its own SQS queue with a visibility timeout longer than the PSP round-trip, so a poison message cannot block checkout acknowledgements.

What I would not do

  • A shared “OrderService” database that every consumer writes to. You will spend the next year on lock contention.
  • SNS fan-out with no queue. One slow email template should not block inventory.
  • Synchronous orchestration from the API Lambda. You will hit the 29-second API Gateway ceiling the first busy Eid sale.

The design is boring on purpose: one transactional write, one outbox, one event bus, one queue per consumer. Boring is what survives a payment retry at 2 a.m.