The saga pattern with Step Functions solves a problem every distributed system runs into sooner or later: a business process spans several services, and something breaks halfway through. The booking exists, the bike is reserved, but the payment gets declined. However, there is no database transaction covering all three steps. What now?
This topic keeps coming up for me because architecture discussions often swing between two extremes. One camp wants to force everything into a single database so it becomes “properly” transactional again. The other camp, on the other hand, simply hopes nothing goes wrong. In short, neither gets you very far. So in this final part of the series, I will show you step by step how to build a workflow with AWS Step Functions that cleans up after itself when something fails, with complete CDK code in TypeScript.
In short
A saga splits a distributed process into local steps and pairs each step with a compensating action that runs in reverse when an error occurs. Step Functions acts as the conductor and, as a result, makes retries, catches and ordering visible. The biggest pitfall is not the state machine itself but compensations that are not idempotent or that are allowed to fail.
What you will learn in this article
- When a saga makes sense and when a simple transaction is enough
- How to wire forward steps and compensations in Step Functions with CDK
- Why every compensation has to be idempotent and what that looks like in code
- How to configure Retry and Catch so only sensible errors are retried
- How to test the saga locally and make it fail on purpose after deployment
What is the saga pattern?
A saga is a sequence of local transactions. Each step changes data in exactly one service and is therefore complete on its own. For every step that changes something, there is a matching compensation: a business-level counter-action that undoes its effect. If step three hits an error, the saga runs the compensations for steps two and one in reverse order.
The word “business-level” matters here. After all, a compensation is not a technical rollback that pretends nothing ever happened. For example, a charged payment gets refunded; it does not vanish. Anyone looking at the data in the meantime briefly sees a state that will be corrected later. This is also called eventual consistency.
Orchestration or choreography
There are two ways to coordinate a saga. With choreography, each service reacts to events from the others, and there is no central flow. With orchestration, however, a conductor (the orchestrator) tells each participant what happens next. Choreography works well when a few services should stay loosely coupled, and it builds on patterns such as the outbox pattern. Once four or more steps with compensations come together, though, pure choreography quickly makes me lose track of which service is currently undoing what.
That is why I prefer an orchestrator for flows like that. On AWS, Step Functions is the obvious choice: the flow lives in one place as a state machine, every execution can be traced in the console, and Retry and Catch are built into the language. Moreover, AWS Prescriptive Guidance describes exactly this variant with Step Functions as the orchestrator.
When you do not need a saga
Before you start, here is an honest shortcut. If all the affected data lives in one DynamoDB table or a few tables in the same account, TransactWriteItems is often enough. It also writes several items atomically, with no compensations at all. A saga only pays off when steps cross boundaries that share no transaction: an external payment provider, another team with its own database, or a legacy system.
The example: a bike rental booking
For example, picture a fictional bike rental shop. A booking consists of four steps:
- CreateBooking creates the booking with status
PENDING. - ReserveBike reserves the bike if it is available.
- ChargePayment charges the amount with the (simulated) payment provider.
- ConfirmBooking sets the booking to
CONFIRMED.
Three compensations belong to it, running in reverse: RefundPayment refunds the payment, ReleaseBike frees the bike, and MarkBookingFailed sets the booking to FAILED. Deleting the booking would work too. I prefer to keep it with a status because you will want to understand later why a booking failed.
Forward steps on top, compensations below: each Catch jumps to the point from which the saga has to roll back.
Also notice where the catches jump to. If the reservation fails, there is nothing to refund and no bike to release, so the flow goes straight to MarkBookingFailed. If the payment or the confirmation fails, the chain starts at RefundPayment. The compensations are chained so that every entry point automatically includes all later cleanup steps.
🎬 Flow of the booking saga with compensation – this animated diagram only runs on my site: watch it on sascha-ms.de
Preparing the saga pattern with Step Functions
For this guide you need:
- Node.js 22 or newer and npm
- an AWS account with configured credentials (
aws configureor SSO) - the AWS CDK, here as a local dependency (tested with aws-cdk-lib 2.275)
- an account prepared with
cdk bootstrapand a Region, for example eu-central-1
This time I build the infrastructure with CDK, since the previous part used the Serverless Framework. Two things matter to me here. Firstly, the DynamoDB steps run as direct Step Functions service integrations, with no Lambda in between. As a result, it saves code, cold starts and money. Secondly, Lambda only shows up where real logic is needed, which here is the payment provider.
Step 1: Create the project
mkdir bike-booking-saga && cd bike-booking-saga npm init -y npm install aws-cdk-lib constructs @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm install -D typescript tsx esbuild aws-cdk @types/node @types/aws-lambda mkdir src lib bin Create a tsconfig.json and a cdk.json. In addition, the cdk.json tells the CDK how to start your app. You need esbuild locally so that NodejsFunction can bundle the handlers without Docker.
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "lib", "bin"] } { "app": "npx tsx bin/app.ts" } Idempotent compensations in code
Before we build the state machine, let us look at the place where sagas most often fail in practice: the participants themselves. Step Functions retries steps on errors, and a network timeout can also cause a call to arrive twice. Every action and every compensation therefore has to be idempotent. In other words, a second call with the same booking ID must not cause anything new. You can find more on the basics in my article on idempotency in AWS Lambda.
Step 2: Write the payment logic
The payment logic is deliberately simple: the simulated bank declines amounts over 300 euros. As a result, we get a reproducible failure case. Storage sits behind a small interface so we can test the logic without AWS.
// src/payment.ts import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, } from "@aws-sdk/lib-dynamodb"; export interface BookingInput { bookingId: string; bikeId: string; customerId: string; amountCents: number; } export interface PaymentRecord { bookingId: string; amountCents: number; status: "CHARGED" | "REFUNDED"; } // Business error: its name shows up in Step Functions as "Error" in the Catch. export class PaymentDeclinedError extends Error { constructor(message: string) { super(message); this.name = "PaymentDeclinedError"; } } export interface PaymentStore { // Creates the payment only if the booking does not have one yet. createIfAbsent(record: PaymentRecord): Promise<PaymentRecord>; get(bookingId: string): Promise<PaymentRecord | undefined>; markRefunded(bookingId: string): Promise<void>; } // Demo rule instead of a real payment provider: the "bank" declines anything over 300 euros. const LIMIT_CENTS = 30_000; export async function chargePayment( input: BookingInput, store: PaymentStore, ): Promise<PaymentRecord> { if (input.amountCents <= 0) { throw new PaymentDeclinedError("Amount must be positive"); } if (input.amountCents > LIMIT_CENTS) { throw new PaymentDeclinedError( `Amount of ${input.amountCents} cents exceeds the limit`, ); } // Idempotent: a second call with the same bookingId does not charge twice. return store.createIfAbsent({ bookingId: input.bookingId, amountCents: input.amountCents, status: "CHARGED", }); } export async function refundPayment( input: Pick<BookingInput, "bookingId">, store: PaymentStore, ): Promise<{ bookingId: string; refunded: boolean }> { const existing = await store.get(input.bookingId); // Never charged or already refunded: the compensation is a no-op. if (!existing || existing.status === "REFUNDED") { return { bookingId: input.bookingId, refunded: false }; } await store.markRefunded(input.bookingId); return { bookingId: input.bookingId, refunded: true }; } export class DynamoPaymentStore implements PaymentStore { constructor( private readonly tableName: string, private readonly doc = DynamoDBDocumentClient.from(new DynamoDBClient({})), ) {} async createIfAbsent(record: PaymentRecord): Promise<PaymentRecord> { try { await this.doc.send( new PutCommand({ TableName: this.tableName, Item: record, ConditionExpression: "attribute_not_exists(bookingId)", }), ); return record; } catch (err) { if ((err as Error).name === "ConditionalCheckFailedException") { const existing = await this.get(record.bookingId); if (existing) return existing; } throw err; } } async get(bookingId: string): Promise<PaymentRecord | undefined> { const res = await this.doc.send( new GetCommand({ TableName: this.tableName, Key: { bookingId } }), ); return res.Item as PaymentRecord | undefined; } async markRefunded(bookingId: string): Promise<void> { await this.doc.send( new UpdateCommand({ TableName: this.tableName, Key: { bookingId }, UpdateExpression: "SET #s = :refunded", ConditionExpression: "attribute_exists(bookingId)", ExpressionAttributeNames: { "#s": "status" }, ExpressionAttributeValues: { ":refunded": "REFUNDED" }, }), ); } } Three details matter. Firstly, the business error gets its own name. Exactly this name shows up in Step Functions as the error type, and you can react to it specifically in a Catch. Secondly, the charge uses a conditional write (attribute_not_exists), so a second call returns the existing payment instead of charging again. Finally, the refund is a no-op if there never was a payment. That is not an edge case; it is the normal case when the charge was declined.
Step 3: Lambda handlers
The handlers are just thin wrappers around the logic. Furthermore, the table name comes from an environment variable that the CDK stack sets.
// src/handlers.ts import { type BookingInput, chargePayment, DynamoPaymentStore, refundPayment, } from "./payment.js"; const store = new DynamoPaymentStore(process.env.PAYMENTS_TABLE ?? ""); export const charge = async (event: BookingInput) => chargePayment(event, store); export const refund = async (event: Pick<BookingInput, "bookingId">) => refundPayment(event, store); Step 4: Test the logic
With the built-in Node.js test runner, you check the most important properties before anything runs in AWS: duplicate calls, declines and idempotent refunds.
// src/payment.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { chargePayment, PaymentDeclinedError, type PaymentRecord, type PaymentStore, refundPayment, } from "./payment.js"; class InMemoryPaymentStore implements PaymentStore { readonly items = new Map<string, PaymentRecord>(); async createIfAbsent(record: PaymentRecord) { const existing = this.items.get(record.bookingId); if (existing) return existing; this.items.set(record.bookingId, record); return record; } async get(bookingId: string) { return this.items.get(bookingId); } async markRefunded(bookingId: string) { const item = this.items.get(bookingId); if (item) item.status = "REFUNDED"; } } const booking = { bookingId: "b-1", bikeId: "bike-7", customerId: "k-42", amountCents: 4_500, }; test("charges a valid amount", async () => { const store = new InMemoryPaymentStore(); const result = await chargePayment(booking, store); assert.equal(result.status, "CHARGED"); assert.equal(store.items.size, 1); }); test("a duplicate call does not charge twice", async () => { const store = new InMemoryPaymentStore(); await chargePayment(booking, store); await chargePayment(booking, store); assert.equal(store.items.size, 1); }); test("declines amounts that are too high with PaymentDeclinedError", async () => { const store = new InMemoryPaymentStore(); await assert.rejects( chargePayment({ ...booking, amountCents: 50_000 }, store), (err: Error) => err instanceof PaymentDeclinedError && err.name === "PaymentDeclinedError", ); assert.equal(store.items.size, 0); }); test("a refund without a prior payment is a no-op", async () => { const store = new InMemoryPaymentStore(); const result = await refundPayment({ bookingId: "unknown" }, store); assert.deepEqual(result, { bookingId: "unknown", refunded: false }); }); test("the refund is idempotent", async () => { const store = new InMemoryPaymentStore(); await chargePayment(booking, store); const first = await refundPayment(booking, store); const second = await refundPayment(booking, store); assert.equal(first.refunded, true); assert.equal(second.refunded, false); assert.equal(store.items.get("b-1")?.status, "REFUNDED"); }); npx tsc --noEmit npx tsx --test src/payment.test.ts Expected result: # pass 5 and # fail 0 at the end of the output.
Building the state machine with CDK
Now for the orchestrator. The stack creates three DynamoDB tables (bookings, bikes, payments), two Lambda functions and the state machine. It is easiest to read the code from the bottom up: the forward chain sits at the end and the compensations come before it, because every Catch has to point to a state that is already defined.
Step 5: Write the stack
// lib/bike-booking-saga-stack.ts import * as path from "node:path"; import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib"; import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; import * as lambda from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import * as sfn from "aws-cdk-lib/aws-stepfunctions"; import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks"; import type { Construct } from "constructs"; // Errors that are worth retrying (throttling, timeouts). const TRANSIENT_DYNAMO_ERRORS = [ "DynamoDB.ProvisionedThroughputExceededException", "DynamoDB.RequestLimitExceeded", "DynamoDB.ThrottlingException", "States.Timeout", ]; export class BikeBookingSagaStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const table = (name: string, key: string) => new dynamodb.Table(this, name, { partitionKey: { name: key, type: dynamodb.AttributeTypeSource:https://dev.to/sascha-ms/saga-pattern-with-step-functions-rolling-back-cleanly-1lfa Related posts

Render Personalized Images from One Editable Template with ModelFold Studio

A booking request form for a static Next.js site (no backend, no form service)

