DEV Community

Tidding Ramsey
Tidding Ramsey

Posted on

Building a Bulletproof Idempotent Transaction Pipeline on AWS with CDK

Have you ever clicked "Send Money" on an app, hit a network lag spike, panicked, and tapped the button a second time then immediately wondered if you just sent your rent money twice?

As developers, preventing that exact scenario is one of our biggest responsibilities. In distributed systems, this is called idempotency: guaranteeing that no matter how many times a request is duplicated, the side effect (a database transaction, in this case) happens exactly once.

In this article, I'll walk through how I built a bulletproof, serverless, multi-layered idempotency pipeline on AWS using the Cloud Development Kit (CDK) and TypeScript — and the type-casting bug that almost took me down along the way.

🔗 Source code: [https://github.com/McTech6/aws-serverless-idempotency.git]

The Architecture

To guarantee strict idempotency, you can't rely on a single database lock. You need a system that gracefully drops duplicate requests at the edge, long before they ever reach your core ledger.

Here's the flow:

  1. Client sends a POST request to API Gateway with a unique x-idempotency-key header.
  2. An Issuer Lambda instantly accepts the request, pushes it onto an SQS FIFO queue, and returns 202 Accepted so the client isn't left hanging.
  3. A Worker Lambda pulls messages off the queue and checks a DynamoDB lock table to see if that key has already been processed.
  4. If it's a new key, the worker executes an ACID-compliant double-entry transaction (debit/credit) against Aurora Serverless v2 PostgreSQL.
  5. Finally, the worker writes the idempotency key to DynamoDB with a 7-day TTL, closing the loop.


High-level view: API Gateway → Issuer Lambda → SQS FIFO → Worker Lambda → DynamoDB lock table + Aurora ledger

Three layers of defense, each catching what the layer before it might miss:

  • SQS FIFO catches rapid-fire duplicates within its 5-minute dedup window.
  • DynamoDB catches duplicates for up to 7 days after that.
  • Aurora enforces the actual financial invariants (no negative balances, atomic debit/credit) no matter what gets through.

Step 1: The Idempotency Lock Table (DynamoDB)

First, a fast, highly-available datastore to remember every transaction processed recently. DynamoDB is a natural fit here.

I created a simple PAY_PER_REQUEST table using idempotencyKey as the partition key, with TTL enabled on an expiresAt attribute so the table cleans itself up automatically after 7 days — keeping storage costs near zero.

const idempotencyTable = new dynamodb.Table(this, 'IdempotencyTable', {
  partitionKey: { name: 'idempotencyKey', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  timeToLiveAttribute: 'expiresAt',
});
Enter fullscreen mode Exit fullscreen mode


The IdempotencyTable, deployed via CDK, with idempotencyKey as the partition key and TTL enabled.

Step 2: The Deduplication Buffer (SQS FIFO)

Why not write straight to the database? Because databases get overwhelmed during traffic spikes. SQS acts as a shock absorber between the edge and the ledger.

I chose an SQS FIFO queue over a standard queue for two reasons:

  • Strict ordering — using the sender's account ID as the MessageGroupId guarantees that if a user fires off two payments back-to-back, they're processed in the exact order they were sent.
  • Edge deduplication — SQS FIFO has a built-in 5-minute dedup window keyed on MessageDeduplicationId. If a client double-taps the same idempotency key within 5 minutes, SQS silently drops the second message before it ever triggers a Lambda.
const txnQueue = new sqs.Queue(this, 'TxnQueue', {
  fifo: true,
  contentBasedDeduplication: false,
  deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});
Enter fullscreen mode Exit fullscreen mode


Creating the FIFO queue with a dead-letter queue attached for messages that fail processing 3 times.


Sending a test message through the queue to confirm FIFO ordering and dedup behavior before wiring up the Lambda.

Step 3: The Edge Receiver (API Gateway + Issuer Lambda)

API Gateway routes POST /transactions to the Issuer Lambda.

The Issuer Lambda is intentionally thin. Its only job is to validate the payload, pull the x-idempotency-key out of the headers, pack everything into a JSON message, and drop it onto SQS. Returning 202 Accepted immediately keeps the client-facing app feeling fast, even though the real work hasn't happened yet.


The Issuer Lambda wired to API Gateway's POST route, ready to accept and forward transaction requests.

Step 4: The Core Ledger (Aurora Serverless)

For a financial ledger, NoSQL doesn't cut it. This needs strict relational constraints (like CHECK (amount > 0)) and ACID transactions to guarantee debit and credit rows are committed together or not at all.

I deployed an Aurora PostgreSQL Serverless v2 cluster with the RDS Data API enabled. This let the Worker Lambda execute SQL over secure HTTP without managing VPC connection pools directly.

const dbCluster = new rds.DatabaseCluster(this, 'LedgerCluster', {
  engine: rds.DatabaseClusterEngine.auroraPostgres({
    version: rds.AuroraPostgresEngineVersion.of('16.8', '16'),
  }),
  serverlessV2MinCapacity: 0.5,
  serverlessV2MaxCapacity: 1,
  enableDataApi: true,
  defaultDatabaseName: 'ledger',
  vpc,
});
Enter fullscreen mode Exit fullscreen mode


Provisioning the Aurora Serverless v2 cluster via cdk deploy, with the Data API enabled for HTTP-based queries.

Step 5: The Worker Logic

The Worker Lambda, triggered by SQS, is where everything comes together:

  1. Check DynamoDB — a GetItemCommand looks up the idempotencyKey. If it already exists, exit immediately. This is what protects the system for the full 7 days, long after SQS's 5-minute dedup window has expired.
  2. Execute the DB transaction — via the RDS Data API: BeginTransaction, insert the debit row, insert the credit row, CommitTransaction.
  3. Lock it in — write the idempotencyKey to DynamoDB so no future duplicate can slip through.


The Worker Lambda's DynamoDB check in action — this is the layer that catches duplicates after the SQS dedup window has passed.

A Hard-Learned Lesson (The Gotcha!)

While building this, I hit a strict type-casting error with the Aurora Data API.

When you pass a UUID parameter as a plain string (stringValue) through the Data API, PostgreSQL rejects it if the target column type is uuid. The Data API doesn't automatically coerce string parameters into uuid the way a native pg client connection might.

The fix: explicitly cast the parameter in the SQL string itself using ::uuid.

-- This will fail via Data API:
VALUES (:txId, :accId, :entryType, :amt, :curr)

-- This works beautifully:
VALUES (:txId::uuid, :accId, :entryType, :amt, :curr)
Enter fullscreen mode Exit fullscreen mode

Small fix, but it's the kind of thing that can cost you an hour of staring at a cryptic Data API error message if you don't know to look for it.

The Ultimate Test

To prove it worked, I sent a request with x-idempotency-key: my-unique-key-002. The database wrote 2 rows — one debit, one credit, exactly as expected.


First request with my-unique-key-002 — API Gateway returns 202, and Aurora ends up with a matching debit/credit pair.

I then immediately sent the exact same request again.


Same idempotency key, sent a second time. API Gateway still returns 202 — but nothing new gets written.

API Gateway happily returned another 202 Accepted, so the client had no idea anything was different. But when I queried the database? Still only 2 rows.


End-to-end test confirming the duplicate was silently swallowed — no double debit, no double credit.

The backend silently and gracefully swallowed the duplicate. Idempotency achieved.

Conclusion

Building idempotency into a serverless architecture requires a shift in mindset: you can't rely on a single point of validation. By layering SQS FIFO deduplication (for short-term spam) with DynamoDB locks (for long-term safety), and by decoupling the edge API from the core Aurora ledger, you get a system that scales without ever double-charging a user.

If you want to see the full CDK stack and Lambda code, check out the repo here: [https://github.com/McTech6/aws-serverless-idempotency.git]

Have you tackled idempotency in your own projects? I'd love to hear your favorite patterns in the comments.

Top comments (0)