DEV Community

Cover image for DynamoDB transactions: TransactWriteItems with Lambda
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

DynamoDB transactions: TransactWriteItems with Lambda

DynamoDB transactions are the tool for situations where several items have to be right together: a bike counts as rented, the customer has one more active rental on their account, and the rental exists as a record of its own. If one of these three steps is missing, you end up with a bike nobody can rent anymore, or a customer who suddenly rides three bikes at once. With TransactWriteItems, either everything happens or nothing does.

I care about this topic because teams often reach for DynamoDB transactions too late or too often. Too late, because many only notice after the first inconsistent record that three separate writes in a Lambda function are not a unit. Too often, because transactions consume twice the capacity and tend to collide with each other under concurrent load. In this article you build a small rental API step by step with Lambda, the Serverless Framework and TypeScript, and you learn how to evaluate the error details of a canceled transaction properly.

In short

With TransactWriteItems you write up to 100 items atomically, all or nothing, and with ConditionCheck you can also verify items you do not change. The key insight: when DynamoDB cancels a transaction, the CancellationReasons list tells you position by position which condition failed. The biggest pitfall is that the SDK does not retry canceled transactions on its own, and every action costs double capacity, even if the transaction fails.

What you will learn in this article

  • When you need DynamoDB transactions and when a simple condition is enough
  • Writing a rental with four actions atomically in one Lambda function
  • Turning CancellationReasons into domain errors and fitting HTTP status codes
  • How to retry conflicts on purpose and answer duplicate requests without double bookings
  • Judging cost, limits and IAM permissions for transactions

What are DynamoDB transactions?

DynamoDB offers two transaction APIs. TransactWriteItems groups up to 100 write actions on up to 100 distinct items, spread across one or more tables in the same AWS account and Region. TransactGetItems reads up to 100 items as a consistent snapshot. Together, the items in one transaction may not exceed 4 MB.

Atomic means that either all actions are applied, or DynamoDB discards the whole request. That makes transactions clearly different from BatchWriteItem. A batch is just a bulk order where some writes may go through while others come back as “unprocessed”. For bulk imports the batch is therefore the better choice; for business units of work, use the transaction.

The four actions in TransactWriteItems

A write transaction gives you four actions: Put creates or replaces an item, Update changes individual attributes, Delete removes an item, and ConditionCheck only evaluates a condition without writing anything. Each action can carry its own ConditionExpression, a condition that must hold at write time. If a single condition is false, the whole transaction is canceled.

One rule surprises almost everybody the first time: each item may appear only once per transaction. So you cannot check an item with ConditionCheck first and then change it with Update. Instead, the check belongs directly in the update’s condition. Also, transactions only work on tables, not on indexes.

Isolation and what other readers see

Between a transaction and single operations such as GetItem, PutItem or UpdateItem, DynamoDB guarantees serializable isolation. A single GetItem therefore sees an item either before or after the transaction, never half written. Query and Scan, on the other hand, only read “read committed”: if you query several items while a transaction is running, some of them may already show the new state. When you need a coherent view of several items, use TransactGetItems.

Two more points belong in every architecture decision. First, the changes of a transaction reach global secondary indexes and DynamoDB Streams gradually. A stream consumer may therefore see the three records of a rental in different batches, mixed with other transactions. Second, with global tables the ACID guarantees only apply in the Region you write to. Other Regions can briefly show a partially replicated transaction.

When do you need DynamoDB transactions?

The rule of thumb I work with: if a business rule only concerns one item, a conditional write is enough. Only when a rule has to hold across several items is a transaction worth it. Lowering the stock of a product by one, as long as it is greater than zero, takes a single UpdateItem with a condition. I showed how that looks with a version number against lost updates in the article on DynamoDB optimistic locking.

Typical cases for a transaction, on the other hand:

  • A state changes on several items at once, for example bike “rented” and rental “open”.
  • A counter on one item limits what may happen on another item, such as at most two open rentals per customer.
  • Unique values like email addresses, which you secure with an additional placeholder item and attribute_not_exists.
  • An item may only be written if another item is in a certain state, without changing that other item.

Transactions are not a good idea for bulk imports, for workflows spanning several services, or for data that may be updated later through events anyway. If a payment, an email or a third-party system is involved besides DynamoDB, a database transaction will not help you. That is what the saga pattern with Step Functions or the outbox pattern are for.

The example: a rental at a bike-sharing service

Our fictional bike rental has stations, bikes, customers and rentals. Everything lives in a single table with the partition key pk; the prefixes STATION#, BIKE#, CUSTOMER# and RENTAL# separate the item types. When someone starts a rental, four rules must hold at the same time:

  1. The station is open. It is only checked, not changed, so this is a ConditionCheck.
  2. The customer is active and has fewer than two open rentals. The counter goes up by one.
  3. The bike is available and parked at exactly this station. It switches to “rented”.
  4. The rental does not exist yet. It is created with Put.

Diagram: Lambda sends TransactWriteItems with four actions, result is all written or nothing plus CancellationReasons

Four actions, two possible outcomes: everything written or nothing.

The order of the actions is no accident. When DynamoDB cancels the transaction, it returns a list of cancellation reasons in exactly this order. Position 0 belongs to the station, position 1 to the customer, and so on. The entire error handling builds on that.

🎬 Animation: transaction with four actions – this animated diagram only runs on my site: watch it on sascha-ms.de

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account with permissions for Lambda, API Gateway, DynamoDB, IAM and CloudFormation
  • The Serverless Framework v4 (npm i -g serverless); version 4 requires signing in or a license key, and it is free to use for small organizations
  • The AWS CLI for test data and curl to try things out
  • Basic DynamoDB knowledge: partition key, UpdateItem, conditions

Step by step: DynamoDB transactions with Lambda

Step 1: Create the project

Create an empty folder and install SDK v3 plus the tooling for TypeScript and tests. The package @aws-sdk/lib-dynamodb contains the document client, which lets you write plain JavaScript values instead of DynamoDB’s type notation ({ "S": "…" }).

mkdir bike-rental-transactions && cd bike-rental-transactions
npm init -y
npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm i -D typescript vitest @types/node @types/aws-lambda
mkdir src test
Enter fullscreen mode Exit fullscreen mode

The tsconfig.json is deliberately short. Serverless Framework v4 bundles TypeScript with esbuild during deployment, so the compiler is only used for type checking here.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build the transaction for starting a rental

All functions from steps 2 to 6 live in the file src/rental-service.ts. I separate building the transaction from running it. The function buildStartRentalTransaction is pure: it takes the table name and the input and returns the finished command object. That makes it easy to test, and you see all four actions in one place.

Two details deserve a second look. On the customer I set ReturnValuesOnConditionCheckFailure: "ALL_OLD". If the condition fails, DynamoDB then includes the current customer item in the cancellation reason. Later you can tell from it whether the customer is blocked or has only reached their limit. And the rental gets attribute_not_exists(pk): the same rental can never be created twice.

import { TransactionCanceledException } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  GetCommand,
  TransactWriteCommand,
  type TransactWriteCommandInput,
} from "@aws-sdk/lib-dynamodb";

export const MAX_ACTIVE_RENTALS = 2;

export interface StartRentalInput {
  rentalId: string; // comes from the Idempotency-Key header (UUID, max. 36 characters)
  customerId: string;
  bikeId: string;
  stationId: string;
  startedAt: string;
}

export interface ReturnRentalInput {
  rentalId: string;
  returnedAt: string;
}

export type RentalError =
  | "STATION_CLOSED"
  | "CUSTOMER_BLOCKED"
  | "RENTAL_LIMIT_REACHED"
  | "BIKE_NOT_AVAILABLE"
  | "RENTAL_ALREADY_EXISTS"
  | "RENTAL_NOT_OPEN"
  | "CONFLICT"
  | "UNKNOWN";

export class RentalRejectedError extends Error {
  constructor(public readonly reason: RentalError) {
    super(reason);
    this.name = "RentalRejectedError";
  }
}

export const keys = {
  station: (id: string) => ({ pk: `STATION#${id}` }),
  customer: (id: string) => ({ pk: `CUSTOMER#${id}` }),
  bike: (id: string) => ({ pk: `BIKE#${id}` }),
  rental: (id: string) => ({ pk: `RENTAL#${id}` }),
};

// Order matters: CancellationReasons arrive in exactly this order.
export function buildStartRentalTransaction(
  tableName: string,
  input: StartRentalInput,
): TransactWriteCommandInput {
  return {
    // No own ClientRequestToken: the SDK sets one per call, and startedAt changes on every
    // client retry (same token + different parameters = IdempotentParameterMismatch).
    TransactItems: [
      {
        ConditionCheck: {
          TableName: tableName,
          Key: keys.station(input.stationId),
          ConditionExpression: "isOpen = :true",
          ExpressionAttributeValues: { ":true": true },
        },
      },
      {
        Update: {
          TableName: tableName,
          Key: keys.customer(input.customerId),
          UpdateExpression: "SET activeRentals = activeRentals + :one",
          ConditionExpression: "accountStatus = :active AND activeRentals < :max",
          ExpressionAttributeValues: { ":one": 1, ":active": "active", ":max": MAX_ACTIVE_RENTALS },
          ReturnValuesOnConditionCheckFailure: "ALL_OLD",
        },
      },
      {
        Update: {
          TableName: tableName,
          Key: keys.bike(input.bikeId),
          UpdateExpression: "SET bikeStatus = :rented, rentalId = :rentalId",
          ConditionExpression: "bikeStatus = :available AND stationId = :stationId",
          ExpressionAttributeValues: {
            ":rented": "rented",
            ":available": "available",
            ":rentalId": input.rentalId,
            ":stationId": input.stationId,
          },
        },
      },
      {
        Put: {
          TableName: tableName,
          Item: {
            ...keys.rental(input.rentalId),
            rentalStatus: "open",
            customerId: input.customerId,
            bikeId: input.bikeId,
            stationId: input.stationId,
            startedAt: input.startedAt,
          },
          ConditionExpression: "attribute_not_exists(pk)",
        },
      },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The return as a second transaction

The return is the mirror image: rental to “returned”, bike available again, the customer’s counter minus one. The condition rentalStatus = :open prevents a return request sent twice from lowering the counter twice. On the bike I additionally check that it really still belongs to this rental.

export function buildReturnRentalTransaction(
  tableName: string,
  rental: { rentalId: string; customerId: string; bikeId: string },
  returnedAt: string,
): TransactWriteCommandInput {
  return {
    TransactItems: [
      {
        Update: {
          TableName: tableName,
          Key: keys.rental(rental.rentalId),
          UpdateExpression: "SET rentalStatus = :returned, returnedAt = :at",
          ConditionExpression: "rentalStatus = :open",
          ExpressionAttributeValues: { ":returned": "returned", ":open": "open", ":at": returnedAt },
        },
      },
      {
        Update: {
          TableName: tableName,
          Key: keys.bike(rental.bikeId),
          UpdateExpression: "SET bikeStatus = :available REMOVE rentalId",
          ConditionExpression: "rentalId = :rentalId",
          ExpressionAttributeValues: { ":available": "available", ":rentalId": rental.rentalId },
        },
      },
      {
        Update: {
          TableName: tableName,
          Key: keys.customer(rental.customerId),
          UpdateExpression: "SET activeRentals = activeRentals - :one",
          ConditionExpression: "activeRentals > :zero",
          ExpressionAttributeValues: { ":one": 1, ":zero": 0 },
        },
      },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

Evaluating CancellationReasons in DynamoDB transactions

When a transaction fails, the SDK throws a TransactionCanceledException. Many examples online stop right there and return a blanket “conflict”. That throws away the most useful information DynamoDB gives you: the exception contains the field CancellationReasons, a list with one entry per action, in the same order as your request.

Every entry has a Code. Actions without an error get the text "None" (really as a string, not null). The most important other codes:

Code Meaning Retry?
ConditionalCheckFailed The condition of this action was false. No, business error
TransactionConflict Another transaction is working on the same item right now. Yes, with backoff
ThrottlingError An on-demand table or index is scaling up. Yes, with backoff
ProvisionedThroughputExceeded Provisioned capacity is not enough. Yes, but check capacity
ValidationError Invalid request, for example an item larger than 400 KB. No, code bug
ItemCollectionSizeLimitExceeded An item collection with a local index is too large. No, check the data model

Step 4: Translate cancellation reasons into domain errors

The function explainStartCancellation destructures the list into the four positions. A conflict takes precedence, because the other codes are not meaningful in that case. After that I check the rental first, since “already exists” points to a repeated request. For the customer, the returned item decides the error message.

interface Reason {
  Code?: string;
  Item?: Record<string, unknown>;
}

// Maps the position-based CancellationReasons to a domain error.
export function explainStartCancellation(reasons: Reason[] = []): RentalError {
  if (reasons.some((r) => r.Code === "TransactionConflict")) return "CONFLICT";
  const [station, customer, bike, rental] = reasons;
  if (rental?.Code === "ConditionalCheckFailed") return "RENTAL_ALREADY_EXISTS";
  if (station?.Code === "ConditionalCheckFailed") return "STATION_CLOSED";
  if (customer?.Code === "ConditionalCheckFailed") {
    return customer.Item?.accountStatus === "active" ? "RENTAL_LIMIT_REACHED" : "CUSTOMER_BLOCKED";
  }
  if (bike?.Code === "ConditionalCheckFailed") return "BIKE_NOT_AVAILABLE";
  return "UNKNOWN";
}

export function explainReturnCancellation(reasons: Reason[] = []): RentalError {
  if (reasons.some((r) => r.Code === "TransactionConflict")) return "CONFLICT";
  if (reasons[0]?.Code === "ConditionalCheckFailed") return "RENTAL_NOT_OPEN";
  return "UNKNOWN";
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Retry conflicts on purpose

This is the pitfall that causes the most trouble in practice: the AWS SDKs do not automatically retry a TransactionCanceledException. That is correct, too, because a ConditionalCheckFailed does not improve with retries. A TransactionConflict, however, does, because the other transaction finishes after a few milliseconds. So I only retry conflicts, at most three times and with a random wait (full jitter), so that two Lambda instances do not get in each other’s way again in lockstep.

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// The SDK does not retry TransactionCanceledException – we only retry real conflicts.
export async function sendWithConflictRetry(
  client: Pick<DynamoDBDocumentClient, "send">,
  input: TransactWriteCommandInput,
  explain: (reasons?: Reason[]) => RentalError,
  maxAttempts = 3,
): Promise<void> {
  for (let attempt = 1; ; attempt++) {
    try {
      await client.send(new TransactWriteCommand(input));
      return;
    } catch (error) {
      if (!(error instanceof TransactionCanceledException)) throw error;
      const reason = explain(error.CancellationReasons as Reason[] | undefined);
      if (reason !== "CONFLICT" || attempt >= maxAttempts) throw new RentalRejectedError(reason);
      await sleep(Math.random() * 50 * 2 ** attempt); // full jitter
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Duplicate requests without double bookings

Mobile networks are unreliable, and apps like to send requests a second time. That is why the client sends an Idempotency-Key header with a UUID, which I use directly as the rental ID. If the same request arrives again, the Put action fails on attribute_not_exists(pk). The service then reads the existing rental and answers with 200 instead of 201, as long as customer and bike match.

export class RentalService {
  constructor(
    private readonly client: Pick<DynamoDBDocumentClient, "send">,
    private readonly tableName: string,
  ) {}

  async startRental(input: StartRentalInput): Promise<{ replayed: boolean }> {
    try {
      await sendWithConflictRetry(
        this.client,
        buildStartRentalTransaction(this.tableName, input),
        explainStartCancellation,
      );
      return { replayed: false };
    } catch (error) {
      // Same key again: the rental already exists.
      if (error instanceof RentalRejectedError && error.reason === "RENTAL_ALREADY_EXISTS") {
        const existing = await this.getRental(input.rentalId);
        if (existing?.customerId === input.customerId && existing.bikeId === input.bikeId) {
          return { replayed: true };
        }
      }
      throw error;
    }
  }

  async returnRental(input: ReturnRentalInput): Promise<void> {
    const rental = await this.getRental(input.rentalId);
    if (!rental) throw new RentalRejectedError("RENTAL_NOT_OPEN");
    await sendWithConflictRetry(
      this.client,
      buildReturnRentalTransaction(
        this.tableName,
        { rentalId: input.rentalId, customerId: rental.customerId, bikeId: rental.bikeId },
        input.returnedAt,
      ),
      explainReturnCancellation,
    );
  }

  async getRental(rentalId: string) {
    const result = await this.client.send(
      new GetCommand({ TableName: this.tableName, Key: keys.rental(rentalId), ConsistentRead: true }),
    );
    return result.Item as
      | { customerId: string; bikeId: string; rentalStatus: string }
      | undefined;
  }
}
Enter fullscreen mode Exit fullscreen mode

You may wonder why I do not simply use the ClientRequestToken of TransactWriteItems. With this token, a call becomes idempotent for ten minutes: a repeat with the same token returns success without writing again. The token has two catches, though. After ten minutes, the same request counts as new again. And if you send the same token with different parameters, DynamoDB answers with IdempotentParameterMismatchException. That is exactly what would happen here, because startedAt gets a new timestamp on every retry. The SDK sets a token per call automatically anyway and uses it to protect its own retries. For business-level idempotency I prefer to rely on the condition on the rental item, which holds permanently.

Step 7: The Lambda handler

The handler is thin: it validates header and body, calls the service and maps domain errors to HTTP status codes. A blocked customer gets 403, all state problems get 409. A conflict that still exists after three attempts becomes 503, so the client tries again later.

// src/handler.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { RentalRejectedError, RentalService, type RentalError } from "./rental-service";

const service = new RentalService(
  DynamoDBDocumentClient.from(new DynamoDBClient({})),
  process.env.TABLE_NAME ?? "",
);

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const STATUS: Record<RentalError, number> = {
  STATION_CLOSED: 409,
  CUSTOMER_BLOCKED: 403,
  RENTAL_LIMIT_REACHED: 409,
  BIKE_NOT_AVAILABLE: 409,
  RENTAL_ALREADY_EXISTS: 409,
  RENTAL_NOT_OPEN: 409,
  CONFLICT: 503,
  UNKNOWN: 500,
};

const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
  statusCode,
  headers: { "content-type": "application/json" },
  body: JSON.stringify(body),
});

export async function startRental(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const rentalId = event.headers["idempotency-key"] ?? "";
  if (!UUID.test(rentalId)) return json(400, { error: "Idempotency-Key must be a UUID" });

  const body = JSON.parse(event.body ?? "{}") as Partial<Record<"customerId" | "bikeId" | "stationId", string>>;
  if (!body.customerId || !body.bikeId || !body.stationId) {
    return json(400, { error: "customerId, bikeId and stationId are required" });
  }

  try {
    const { replayed } = await service.startRental({
      rentalId,
      customerId: body.customerId,
      bikeId: body.bikeId,
      stationId: body.stationId,
      startedAt: new Date().toISOString(),
    });
    return json(replayed ? 200 : 201, { rentalId, replayed });
  } catch (error) {
    if (error instanceof RentalRejectedError) return json(STATUS[error.reason], { error: error.reason });
    throw error;
  }
}

export async function returnRental(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const rentalId = event.pathParameters?.rentalId ?? "";
  try {
    await service.returnRental({ rentalId, returnedAt: new Date().toISOString() });
    return json(200, { rentalId, rentalStatus: "returned" });
  } catch (error) {
    if (error instanceof RentalRejectedError) return json(STATUS[error.reason], { error: error.reason });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 8: serverless.yml with matching IAM permissions

There is no separate IAM action for TransactWriteItems. Permissions are granted per action: dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, and for the ConditionCheck the action dynamodb:ConditionCheckItem. With the condition key dynamodb:EnclosingOperation you allow the write permissions only inside a transaction. A bug that accidentally issues a single UpdateItem then fails immediately at IAM instead of silently bending data.

# serverless.yml
service: bike-rental-transactions
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs22.x
  region: eu-central-1
  memorySize: 512
  timeout: 10
  logRetentionInDays: 30
  environment:
    TABLE_NAME: !Ref RentalsTable
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:ConditionCheckItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
          Resource: !GetAtt RentalsTable.Arn
          Condition:
            ForAnyValue:StringEquals:
              dynamodb:EnclosingOperation:
                - TransactWriteItems
        - Effect: Allow
          Action:
            - dynamodb:GetItem
          Resource: !GetAtt RentalsTable.Arn

functions:
  startRental:
    handler: src/handler.startRental
    events:
      - httpApi:
          method: POST
          path: /rentals
  returnRental:
    handler: src/handler.returnRental
    events:
      - httpApi:
          method: POST
          path: /rentals/{rentalId}/return

resources:
  Resources:
    RentalsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-${sls:stage}-rentals
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: pk
            AttributeType: S
        KeySchema:
          - AttributeName: pk
            KeyType: HASH
        PointInTimeRecoverySpecification:
          PointInTimeRecoveryEnabled: true
Enter fullscreen mode Exit fullscreen mode

Step 9: Tests with Vitest

Because building and evaluating are pure functions, the most important tests need neither a table nor a mocking library. For the service, an object with a send function is enough, which Vitest recreates with vi.fn().

// test/rental-service.test.ts
import { TransactionCanceledException } from "@aws-sdk/client-dynamodb";
import { GetCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
import { describe, expect, it, vi } from "vitest";
import {
  RentalService,
  buildStartRentalTransaction,
  explainStartCancellation,
} from "../src/rental-service";

const input = {
  rentalId: "6f1c2a9e-4b7d-4c1e-9a3f-2d8e5b7c1a00",
  customerId: "c-17",
  bikeId: "b-042",
  stationId: "s-hbf",
  startedAt: "2026-11-10T08:00:00.000Z",
};

const canceled = (codes: string[], customerItem?: Record<string, unknown>) =>
  new TransactionCanceledException({
    $metadata: {},
    message: "Transaction cancelled",
    CancellationReasons: codes.map((Code, i) => (i === 1 && customerItem ? { Code, Item: customerItem as never } : { Code })),
  });

describe("buildStartRentalTransaction", () => {
  it("contains four actions in a fixed order", () => {
    const tx = buildStartRentalTransaction("rentals", input);
    expect(tx.TransactItems?.map((item) => Object.keys(item)[0])).toEqual([
      "ConditionCheck",
      "Update",
      "Update",
      "Put",
    ]);
  });

  it("protects the rental against duplicates", () => {
    const put = buildStartRentalTransaction("rentals", input).TransactItems?.[3]?.Put;
    expect(put?.ConditionExpression).toBe("attribute_not_exists(pk)");
    expect(put?.Item?.pk).toBe(`RENTAL#${input.rentalId}`);
  });
});

describe("explainStartCancellation", () => {
  it("detects a closed station", () => {
    expect(explainStartCancellation([{ Code: "ConditionalCheckFailed" }, { Code: "None" }, { Code: "None" }, { Code: "None" }])).toBe("STATION_CLOSED");
  });

  it("distinguishes rental limit and blocked customer using ALL_OLD", () => {
    const limit = [{ Code: "None" }, { Code: "ConditionalCheckFailed", Item: { accountStatus: "active" } }];
    const blocked = [{ Code: "None" }, { Code: "ConditionalCheckFailed", Item: { accountStatus: "blocked" } }];
    expect(explainStartCancellation(limit)).toBe("RENTAL_LIMIT_REACHED");
    expect(explainStartCancellation(blocked)).toBe("CUSTOMER_BLOCKED");
  });

  it("treats TransactionConflict as a conflict", () => {
    expect(explainStartCancellation([{ Code: "None" }, { Code: "None" }, { Code: "TransactionConflict" }, { Code: "None" }])).toBe("CONFLICT");
  });
});

describe("RentalService.startRental", () => {
  it("retries a conflict and then succeeds", async () => {
    const send = vi
      .fn()
      .mockRejectedValueOnce(canceled(["None", "None", "TransactionConflict", "None"]))
      .mockResolvedValueOnce({});
    const service = new RentalService({ send } as never, "rentals");

    await expect(service.startRental(input)).resolves.toEqual({ replayed: false });
    expect(send).toHaveBeenCalledTimes(2);
    expect(send.mock.calls[0][0]).toBeInstanceOf(TransactWriteCommand);
  });

  it("does not retry when the bike is already rented", async () => {
    const send = vi.fn().mockRejectedValue(canceled(["None", "None", "ConditionalCheckFailed", "None"]));
    const service = new RentalService({ send } as never, "rentals");

    await expect(service.startRental(input)).rejects.toMatchObject({ reason: "BIKE_NOT_AVAILABLE" });
    expect(send).toHaveBeenCalledTimes(1);
  });

  it("answers a repeated request with the same key as a replay", async () => {
    const send = vi.fn(async (command: unknown) => {
      if (command instanceof GetCommand) {
        return { Item: { customerId: input.customerId, bikeId: input.bikeId, rentalStatus: "open" } };
      }
      throw canceled(["None", "ConditionalCheckFailed", "ConditionalCheckFailed", "ConditionalCheckFailed"], {
        accountStatus: "active",
      });
    });
    const service = new RentalService({ send } as never, "rentals");

    await expect(service.startRental(input)).resolves.toEqual({ replayed: true });
  });
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Expected result: Vitest reports eight passed tests in one test file.

Step 10: Deploy and try it out

After deploying, you create a station, a bike and a customer. The table name is fixed in serverless.yml; serverless deploy shows the API address at the end under “endpoints”.

serverless deploy

TABLE=bike-rental-transactions-dev-rentals
# Address from the output of "serverless deploy" (endpoints)
API="https://abc123.execute-api.eu-central-1.amazonaws.com"

aws dynamodb put-item --table-name "$TABLE" --item '{"pk":{"S":"STATION#s-hbf"},"isOpen":{"BOOL":true}}'
aws dynamodb put-item --table-name "$TABLE" --item '{"pk":{"S":"BIKE#b-042"},"bikeStatus":{"S":"available"},"stationId":{"S":"s-hbf"}}'
aws dynamodb put-item --table-name "$TABLE" --item '{"pk":{"S":"CUSTOMER#c-17"},"accountStatus":{"S":"active"},"activeRentals":{"N":"0"}}'

KEY=$(uuidgen | tr 'A-Z' 'a-z')
curl -i -X POST "$API/rentals" \
  -H "content-type: application/json" \
  -H "idempotency-key: $KEY" \
  -d '{"customerId":"c-17","bikeId":"b-042","stationId":"s-hbf"}'

curl -i -X POST "$API/rentals/$KEY/return"
Enter fullscreen mode Exit fullscreen mode

The first call returns 201 with {"rentalId":"…","replayed":false}. If you send exactly the same command again, you get 200 with "replayed":true, and the customer’s counter stays at 1. With a new key for the same bike, the API answers with 409 and BIKE_NOT_AVAILABLE. Finally, serverless remove deletes everything again.

Common mistakes with DynamoDB transactions

The same item twice in one transaction

A ConditionCheck on the customer followed by an Update on the same customer leads to a ValidationException. The fix is almost always to move the check into the update’s ConditionExpression, just like the customer limit in the example.

Every cancellation becomes a generic error

If you ignore CancellationReasons, you can only show users “Something went wrong”, and in the worst case you retry business errors endlessly. Give every position a meaning and keep the order of actions stable. A test that pins down the order takes two minutes to write and spares you a hard-to-find mix-up later.

A hot item in every transaction

A global counter such as “total bikes rented” that is written in every transaction causes constant TransactionConflict cancellations under load. Statistics like that belong on an asynchronous path, for example via DynamoDB Streams. Keep only those items in the transaction whose rules really have to hold together.

Stream consumers expect the whole transaction

Since stream records of a transaction arrive at different times, a consumer must not assume that bike, customer and rental show up together in one batch. React to one item, for example the rental item, and read the rest if you need it.

Cost, limits and security

Transactions carry no surcharge, but internally DynamoDB performs two operations per item: one to prepare and one to commit. For items under 1 KB, the three writing actions from our example therefore consume six write units instead of three, and the ConditionCheck on the station costs capacity as well. This capacity is consumed even when the transaction fails on a condition, so every retry after a conflict costs again. Whether that matters for your volume is best calculated with the current prices of your Region. You can double-check whether DynamoDB is the right choice at all with the AWS service finder.

Once more, the most important limits in brief: at most 100 actions and 4 MB per transaction, no item twice, no indexes, only one account and one Region. In monitoring, the CloudWatch metric TransactionConflict is worth watching. If it rises permanently, that hints at a hot item in the data model. On the security side, the IAM condition shown above helps, and point-in-time recovery should be enabled for business tables anyway.

My take

I consider DynamoDB transactions one of the underrated tools in the Serverless toolbox. They save you a lot of compensation logic you would otherwise have to write yourself. At the same time, I regularly see transactions in code reviews that could just as well be a single conditional write. That costs double and makes the code harder to read.

My suggestion for practice: write the business rules down as sentences first, like the four rules for the rental above. If each rule only concerns one item, you do not need a transaction. If rules depend on each other across items, use one, keep it small and invest the ten minutes in a clean evaluation of the cancellation reasons. Your users then get understandable error messages instead of a meaningless “409 Conflict”.

FAQ

Does DynamoDB support transactions?

Yes. With TransactWriteItems and TransactGetItems you write or read up to 100 items atomically, even across several tables in the same account and Region.

How much does a DynamoDB transaction cost?

There is no surcharge, but each item consumes double capacity, because DynamoDB prepares and commits internally. The capacity is consumed even when the transaction is canceled.

What is the difference between BatchWriteItem and TransactWriteItems?

A batch can partially succeed and has no conditions per action. A transaction succeeds completely or not at all and may contain conditions. For bulk imports, the batch is cheaper.

Does the AWS SDK retry canceled transactions?

No. You have to evaluate a TransactionCanceledException yourself and retry deliberately with backoff on TransactionConflict. For TransactionInProgressException, by contrast, the SDK’s normal retry logic applies.

Your next step

Search your code for Lambda functions that issue two or more PutItem or UpdateItem calls in a row. For each one, ask yourself: what happens if the second call fails? If the answer is “then the data is no longer consistent”, that is a candidate for a transaction. You will find more patterns like this in the series Serverless patterns in practice.

I would like to know: where do you use DynamoDB transactions, and which cancellation reasons do you see most often in your logs? Feel free to share in the comments.

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.


👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

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

Top comments (0)