跳到正文
DEV Community· Sascha Mirow-Stanitzok·· 4 小時前AI 評分52

AWS Step Functions Saga 模式:如何在失敗時執行補償回滾

Saga pattern with Step Functions: rolling back cleanly

AI 導讀

文章示範如何用 AWS Step Functions 和 TypeScript CDK 建立 Saga 工作流程,在分散式流程失敗時按反向次序執行補償操作。單車租借示例涵蓋建立預約、預留單車、付款及確認,以及退款、釋放單車和標記失敗等補償。文章強調補償操作須具冪等性,並按可恢復錯誤設定 Retry 和 Catch;若補償最終失敗,流程會進入專用錯誤狀態供人工處理。

正文

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:

  1. CreateBooking creates the booking with status PENDING.
  2. ReserveBike reserves the bike if it is available.
  3. ChargePayment charges the amount with the (simulated) payment provider.
  4. 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.

State machine: four forward steps on top, catch arrows to the compensations below, success and failure end states

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 configure or SSO)
  • the AWS CDK, here as a local dependency (tested with aws-cdk-lib 2.275)
  • an account prepared with cdk bootstrap and 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.AttributeType.STRING },
        billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
        removalPolicy: RemovalPolicy.DESTROY, // example only
      });

    const bookings = table("Bookings", "bookingId");
    const bikes = table("Bikes", "bikeId");
    const payments = table("Payments", "bookingId");

    const fn = (name: string, handler: string) =>
      new NodejsFunction(this, name, {
        entry: path.join(__dirname, "../src/handlers.ts"),
        handler,
        runtime: lambda.Runtime.NODEJS_22_X,
        architecture: lambda.Architecture.ARM_64,
        memorySize: 256,
        timeout: Duration.seconds(10),
        environment: { PAYMENTS_TABLE: payments.tableName },
      });

    const chargeFn = fn("ChargeFn", "charge");
    const refundFn = fn("RefundFn", "refund");
    payments.grantReadWriteData(chargeFn);
    payments.grantReadWriteData(refundFn);

    const bookingIdAttr = tasks.DynamoAttributeValue.fromString(
      sfn.JsonPath.stringAt("$.bookingId"),
    );

    // ---------- Final states ----------
    const bookingConfirmed = new sfn.Succeed(this, "BookingConfirmed");
    const bookingFailed = new sfn.Fail(this, "BookingFailed", {
      error: "BookingFailed",
      cause: "Booking was rolled back",
    });
    const compensationFailed = new sfn.Fail(this, "CompensationFailed", {
      error: "CompensationFailed",
      cause: "Compensation failed, check manually",
    });

    // ---------- Compensations (in reverse) ----------
    const markBookingFailed = new tasks.DynamoUpdateItem(this, "MarkBookingFailed", {
      table: bookings,
      key: { bookingId: bookingIdAttr },
      updateExpression: "SET #s = :failed",
      expressionAttributeNames: { "#s": "status" },
      expressionAttributeValues: {
        ":failed": tasks.DynamoAttributeValue.fromString("FAILED"),
      },
      resultPath: sfn.JsonPath.DISCARD,
    });
    markBookingFailed
      .addRetry({ errors: TRANSIENT_DYNAMO_ERRORS, maxAttempts: 5, backoffRate: 2 })
      .addCatch(compensationFailed, { resultPath: "$.compensationError" })
      .next(bookingFailed);

    const releaseBike = new tasks.DynamoUpdateItem(this, "ReleaseBike", {
      table: bikes,
      key: { bikeId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt("$.bikeId")) },
      updateExpression: "REMOVE reservedBy",
      // Only release our own reservation, never one held by another booking.
      conditionExpression: "reservedBy = :b",
      expressionAttributeValues: { ":b": bookingIdAttr },
      resultPath: sfn.JsonPath.DISCARD,
    });
    releaseBike
      .addRetry({ errors: TRANSIENT_DYNAMO_ERRORS, maxAttempts: 5, backoffRate: 2 })
      // Bike is not (or no longer) ours: nothing to release, continue with the next step.
      .addCatch(markBookingFailed, {
        errors: ["DynamoDB.ConditionalCheckFailedException"],
        resultPath: sfn.JsonPath.DISCARD,
      })
      .addCatch(compensationFailed, { resultPath: "$.compensationError" })
      .next(markBookingFailed);

    const refundPayment = new tasks.LambdaInvoke(this, "RefundPayment", {
      lambdaFunction: refundFn,
      payload: sfn.TaskInput.fromObject({ bookingId: sfn.JsonPath.stringAt("$.bookingId") }),
      resultSelector: { "refunded.$": "$.Payload.refunded" },
      resultPath: "$.refund",
    });
    refundPayment
      .addRetry({ errors: ["States.ALL"], interval: Duration.seconds(2), maxAttempts: 5, backoffRate: 2 })
      .addCatch(compensationFailed, { resultPath: "$.compensationError" })
      .next(releaseBike);

    // ---------- Forward steps ----------
    const createBooking = new tasks.DynamoPutItem(this, "CreateBooking", {
      table: bookings,
      item: {
        bookingId: bookingIdAttr,
        bikeId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt("$.bikeId")),
        customerId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt("$.customerId")),
        status: tasks.DynamoAttributeValue.fromString("PENDING"),
      },
      resultPath: sfn.JsonPath.DISCARD,
    });
    createBooking.addRetry({ errors: TRANSIENT_DYNAMO_ERRORS, maxAttempts: 3 });

    const reserveBike = new tasks.DynamoUpdateItem(this, "ReserveBike", {
      table: bikes,
      key: { bikeId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt("$.bikeId")) },
      updateExpression: "SET reservedBy = :b",
      // Semantic lock: free, or already reserved by exactly this booking.
      conditionExpression:
        "attribute_exists(bikeId) AND (attribute_not_exists(reservedBy) OR reservedBy = :b)",
      expressionAttributeValues: { ":b": bookingIdAttr },
      resultPath: sfn.JsonPath.DISCARD,
    });
    reserveBike
      .addRetry({ errors: TRANSIENT_DYNAMO_ERRORS, maxAttempts: 3 })
      .addCatch(markBookingFailed, { resultPath: "$.error" });

    const chargePayment = new tasks.LambdaInvoke(this, "ChargePayment", {
      lambdaFunction: chargeFn,
      payload: sfn.TaskInput.fromObject({
        bookingId: sfn.JsonPath.stringAt("$.bookingId"),
        bikeId: sfn.JsonPath.stringAt("$.bikeId"),
        customerId: sfn.JsonPath.stringAt("$.customerId"),
        amountCents: sfn.JsonPath.numberAt("$.amountCents"),
      }),
      resultSelector: { "status.$": "$.Payload.status" },
      resultPath: "$.payment",
    });
    chargePayment
      .addRetry({ errors: ["Lambda.TooManyRequestsException"], interval: Duration.seconds(1), maxAttempts: 3, jitterStrategy: sfn.JitterType.FULL })
      // Declined or unclear: nothing charged yet, or refundable idempotently.
      .addCatch(refundPayment, { resultPath: "$.error" });

    const confirmBooking = new tasks.DynamoUpdateItem(this, "ConfirmBooking", {
      table: bookings,
      key: { bookingId: bookingIdAttr },
      updateExpression: "SET #s = :confirmed",
      conditionExpression: "#s = :pending",
      expressionAttributeNames: { "#s": "status" },
      expressionAttributeValues: {
        ":confirmed": tasks.DynamoAttributeValue.fromString("CONFIRMED"),
        ":pending": tasks.DynamoAttributeValue.fromString("PENDING"),
      },
      resultPath: sfn.JsonPath.DISCARD,
    });
    confirmBooking
      .addRetry({ errors: TRANSIENT_DYNAMO_ERRORS, maxAttempts: 3 })
      .addCatch(refundPayment, { resultPath: "$.error" });

    const definition = createBooking
      .next(reserveBike)
      .next(chargePayment)
      .next(confirmBooking)
      .next(bookingConfirmed);

    const stateMachine = new sfn.StateMachine(this, "BikeBookingSaga", {
      definitionBody: sfn.DefinitionBody.fromChainable(definition),
      stateMachineType: sfn.StateMachineType.STANDARD,
      timeout: Duration.minutes(5),
      tracingEnabled: true,
    });

    new CfnOutput(this, "StateMachineArn", { value: stateMachine.stateMachineArn });
    new CfnOutput(this, "BikesTable", { value: bikes.tableName });
    new CfnOutput(this, "BookingsTable", { value: bookings.tableName });
  }
}
// bin/app.ts
import { App } from "aws-cdk-lib";
import { BikeBookingSagaStack } from "../lib/bike-booking-saga-stack.js";

const app = new App();
new BikeBookingSagaStack(app, "BikeBookingSagaStack");

Getting Retry and Catch right

Step Functions defines the evaluation order clearly: the service checks the Retry list first, and only when no retrier matches or the attempts are used up does the Catch list apply. Without a matching Catch, however, the whole execution fails. Therefore, I derive two rules from that.

Firstly, in forward steps you only retry errors where a second attempt has a chance of succeeding, such as throttling or timeouts. After all, a declined payment will not get friendlier on the third attempt. By the way, LambdaInvoke in the CDK adds a retrier for transient Lambda errors such as Lambda.ServiceException by default (six attempts, starting after two seconds, factor two). So you do not have to write it yourself. I covered how exponential backoff with jitter behaves in detail in this article.

Secondly, compensations may be retried more generously because they are idempotent. For RefundPayment, for example, I catch everything with States.ALL and try five times. If a compensation still fails for good, the execution ends in its own final state, CompensationFailed. That is the one case where a human has to take a look, and that is exactly why it should be distinguishable from a normally declined booking.

Failure case What happens Final state
Bike already taken Catch at ReserveBike, booking becomes FAILED BookingFailed
Payment declined Refund (no-op), release bike, booking FAILED BookingFailed
Confirmation fails Refund, release bike, booking FAILED BookingFailed
Refund fails for good Compensation aborted, alarm needed CompensationFailed

Semantic lock instead of isolation

A saga has no isolation like a database transaction. Two bookings can want the same bike at the same time. One answer is a semantic lock: the reservedBy field marks the bike as taken, and the condition in the ReserveBike step lets only one booking through. Consequently, the second one runs into a ConditionalCheckFailedException and is cleanly marked as failed. Similarly, the PENDING status on the booking is another such lock: other parts of the system can see that this process has not been decided yet.

The same caution applies in the other direction when releasing. ReleaseBike only removes the reservation if it belongs to its own booking. Otherwise, a failed saga could take the bike away from another, successful booking. If the condition fails, there is nothing to do, so the Catch moves straight on.

Deploying and testing the saga

Step 6: Synthesize and deploy

npx cdk synth --quiet
npx cdk deploy

After deployment, the stack outputs the ARN of the state machine and the table names. Store them in variables; the values below are placeholders:

SM_ARN="arn:aws:states:eu-central-1:123456789012:stateMachine:BikeBookingSaga..."
BIKES_TABLE="BikeBookingSagaStack-Bikes..."
BOOKINGS_TABLE="BikeBookingSagaStack-Bookings..."

# Create a bike that can be booked
aws dynamodb put-item --table-name "$BIKES_TABLE" \
  --item '{"bikeId":{"S":"bike-7"}}'

Step 7: The success case

aws stepfunctions start-execution \
  --state-machine-arn "$SM_ARN" \
  --name b-1001 \
  --input '{"bookingId":"b-1001","bikeId":"bike-7","customerId":"k-42","amountCents":4500}'

aws dynamodb get-item --table-name "$BOOKINGS_TABLE" \
  --key '{"bookingId":{"S":"b-1001"}}' --query "Item.status.S"

Expected result: "CONFIRMED". Also, I deliberately set the execution name to the booking ID. Step Functions does not allow a second start with the same name per state machine while the execution is running or still retained. That adds protection against duplicate bookings if the caller starts again after a timeout.

Step 8: Provoke failures

Now for the interesting part. Start a second booking for the same bike, which is still reserved, and a third one with an amount above the limit for a free bike:

# Bike already taken: ends in BookingFailed
aws stepfunctions start-execution --state-machine-arn "$SM_ARN" \
  --name b-1002 \
  --input '{"bookingId":"b-1002","bikeId":"bike-7","customerId":"k-43","amountCents":3000}'

# Payment declined: compensation runs, bike becomes free again
aws dynamodb put-item --table-name "$BIKES_TABLE" --item '{"bikeId":{"S":"bike-8"}}'
aws stepfunctions start-execution --state-machine-arn "$SM_ARN" \
  --name b-1003 \
  --input '{"bookingId":"b-1003","bikeId":"bike-8","customerId":"k-44","amountCents":50000}'

aws dynamodb get-item --table-name "$BIKES_TABLE" \
  --key '{"bikeId":{"S":"bike-8"}}'

Expected result: both executions show FAILED in the console with the error BookingFailed. In the graph view of b-1003, you can also see how the execution turns into the compensation chain after ChargePayment. Afterwards, bike bike-8 no longer has a reservedBy field, and the booking is set to FAILED.

Common mistakes

  • Catch without resultPath: Without it, the error output overwrites the entire input. The compensation then no longer knows the bookingId. That is why every Catch attaches the error under $.error.
  • Compensation with a hard condition: If ReleaseBike fails because the bike was never reserved, the saga gets stuck. Therefore, handle this case deliberately.
  • Retrying business errors: States.ALL in the Retry of a forward step also retries declined payments. As a result, it costs time and state transitions without changing anything.
  • Express instead of Standard: Asynchronously started Express workflows run steps at least once, and you only see their history through CloudWatch Logs. For sagas that involve money, I therefore use Standard workflows.

Cost and security

According to the AWS pricing page, Standard workflows cost 0.025 US dollars per 1,000 state transitions (US East), and 4,000 transitions per month are free. A successful booking in this example needs around five to six transitions, while a failed one needs a few more. At 100,000 bookings per month, you end up at roughly 15 US dollars for Step Functions, plus DynamoDB in on-demand mode and two Lambda invocations per booking. Retries count as additional transitions, which is one more reason to retry only where it makes sense. For the Lambda side, you can do the math with my Lambda cost calculator.

The CDK helps with security: the DynamoDB tasks automatically get IAM permissions only for their specific action and table, and the Lambda functions can only access the payments table. Real payment data does not belong in the execution input, since it is visible in the history. Instead, pass only IDs and amounts. And set up a CloudWatch alarm on failed executions with the error CompensationFailed, so stuck compensations do not go unnoticed.

My take: when the saga pattern is worth it

I consider the saga pattern one of the most important patterns in serverless architectures because it exposes an uncomfortable truth: distributed systems have no global undo. If you ignore that, you still end up building the compensations, just at night, by hand and under pressure. Step Functions, on the other hand, turns the flow into something you can read in a review, trace in the console and test on purpose.

Since late October, an AI agent can also start a saga like this and query its progress. How to set that up with clear limits is covered in Step Functions agent tools via AgentCore Gateway.

At the same time, I push back on the reflex to wrap every three-step flow in a saga. Every compensation is code that rarely runs and is therefore rarely tested. So if TransactWriteItems is enough, use it. If you would rather describe a flow in code than in a state machine, take a look at Lambda durable functions as an alternative. And if you are unsure which service fits which flow, the AWS service finder helps you sort it out.

FAQ

What is the difference between a saga and two-phase commit?

With two-phase commit, a coordinator locks all participants until everyone has agreed and then writes atomically. A saga commits every step locally right away and undoes it with a compensation when needed. It needs no shared locks, but in return it is only eventually consistent.

Do I always need Step Functions for a saga?

No. You can also build a saga through choreography with events via EventBridge or SNS, or in code with Lambda durable functions. Step Functions is a good fit when the flow has several steps and you want to see and monitor it in one place.

Standard or Express workflow for a saga?

For sagas with business consequences such as payments, I recommend Standard workflows. They run each step exactly once unless you configure retries, can run for up to a year and keep the execution history. Express workflows are cheaper for very many short flows, but depending on how they are invoked, they run steps at least once or at most once.

What happens if a compensation fails?

Then manual help is needed. Retry compensations generously, and if all attempts fail, end the execution in a dedicated error state with an alarm. That way, no half-rolled-back process goes unnoticed.

Your next step

Pick a flow in your system that currently spans two or more services and write down one line for each step: what is the compensation, and is it idempotent? If you cannot come up with a counter-action for a step, you have already gained the most important insight before writing a single line of code.

This wraps up the series “Serverless patterns in practice” for now. Which pattern are you still missing? Let me know in the comments, and it might become part seven.

More in this series: Overview “Serverless patterns in practice” · Previous part: Strangler Fig Pattern: Migrate to Lambda Step by Step · Next part: DynamoDB TTL: build seat holds that expire

How to check the retry and catch rules of such a saga automatically, without deploying it, is what I show in Test Step Functions with the TestState API.

Sources

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

來源:DEV Community · dev.to