Step Functions: Saga Pattern for Distributed Order Transactions
Scenario
Your e-commerce order workflow involves three microservices: Inventory (reserves stock), Payment (charges the customer), and Fulfilment (ships the order). If Fulfilment fails after Payment succeeds, you must refund the customer and release the inventory. You need the Saga pattern — each step has a corresponding compensating action that rolls back if a later step fails.
- Create three Lambda functions: reserve-inventory, charge-payment, create-shipment — and three compensating functions: release-inventory, refund-payment.
- Create a Step Functions Standard state machine (for exactly-once semantics) with the Saga workflow. Each step uses Catch to route errors to the correct compensating chain.
- Enable X-Ray tracing on the state machine.
- Create the Step Functions execution IAM role with permissions to invoke all five Lambda functions.
- Create an EventBridge rule to trigger the state machine whenever an OrderPlaced event arrives on a custom event bus.
- Output the state machine ARN and the execution role ARN.
Additional Context
Saga vs 2PC: Two-phase commit (2PC) requires all participants to hold locks until a coordinator commits — it doesn't work across independently deployed services. The Saga pattern splits a distributed transaction into a sequence of local transactions, each with a compensating "undo" action. It's eventually consistent but far more resilient to partial failures.
Standard vs Express workflows: Standard workflows run up to one year, support exactly-once execution, and have full execution history. Express workflows are high-throughput (up to 100,000/second) but run for at most 5 minutes and have at-least-once semantics. For financial sagas, use Standard — you need exactly-once and a durable audit trail.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "state_machine_name" { type = string; default = "order-saga" }
variable "event_bus_name" { type = string; default = "orders-bus-prod" }
variable "environment" { type = string; default = "prod" }main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
# Lambda execution role shared by all five Lambda functions.
data "aws_iam_policy_document" "lambda_assume" {
statement { effect = "Allow"; actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] } }
}
resource "aws_iam_role" "lambda" {
name = "${var.state_machine_name}-lambda-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# All 5 Lambda functions (transaction steps + compensating actions).
locals {
functions = toset(["reserve-inventory","charge-payment","create-shipment","release-inventory","refund-payment"])
}
resource "aws_cloudwatch_log_group" "fn" {
for_each = local.functions
name = "/aws/lambda/${var.state_machine_name}-${each.key}-${var.environment}"
retention_in_days = 30
}
resource "aws_lambda_function" "fn" {
for_each = local.functions
function_name = "${var.state_machine_name}-${each.key}-${var.environment}"
runtime = "python3.12"
handler = "handler.lambda_handler"
role = aws_iam_role.lambda.arn
filename = "${path.module}/handler.zip"
timeout = 30
depends_on = [aws_cloudwatch_log_group.fn]
tags = local.tags
}
# Step Functions IAM role — invoke all five Lambda functions.
data "aws_iam_policy_document" "sfn_assume" {
statement { effect = "Allow"; actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["states.amazonaws.com"] } }
}
resource "aws_iam_role" "sfn" {
name = "${var.state_machine_name}-sfn-role"
assume_role_policy = data.aws_iam_policy_document.sfn_assume.json
}
resource "aws_iam_role_policy" "sfn" {
name = "invoke-lambdas"
role = aws_iam_role.sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"; Action = "lambda:InvokeFunction"
Resource = [for k, v in aws_lambda_function.fn : v.arn] },
{ Effect = "Allow"; Action = ["xray:PutTraceSegments","xray:PutTelemetryRecords"]; Resource = "*" },
{ Effect = "Allow"; Action = ["logs:CreateLogDelivery","logs:GetLogDelivery","logs:UpdateLogDelivery",
"logs:DeleteLogDelivery","logs:ListLogDeliveries","logs:PutLogEvents","logs:PutResourcePolicy",
"logs:DescribeResourcePolicies","logs:DescribeLogGroups"]; Resource = "*" }
]
})
}
# Step Functions state machine with Saga pattern.
resource "aws_sfn_state_machine" "order_saga" {
name = "${var.state_machine_name}-${var.environment}"
role_arn = aws_iam_role.sfn.arn
type = "STANDARD"
# Enable X-Ray tracing for detailed execution insights.
tracing_configuration { enabled = true }
definition = jsonencode({
Comment = "Order Saga: Reserve Inventory → Charge Payment → Create Shipment"
StartAt = "ReserveInventory"
States = {
ReserveInventory = {
Type = "Task"
Resource = aws_lambda_function.fn["reserve-inventory"].arn
Next = "ChargePayment"
Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
}
ChargePayment = {
Type = "Task"
Resource = aws_lambda_function.fn["charge-payment"].arn
Next = "CreateShipment"
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "ReleaseInventoryOnPaymentFail"
ResultPath = "$.error"
}]
}
CreateShipment = {
Type = "Task"
Resource = aws_lambda_function.fn["create-shipment"].arn
Next = "OrderSucceeded"
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "RefundPaymentOnShipFail"
ResultPath = "$.error"
}]
}
# Compensating transaction chain — shipment failed, undo payment then inventory.
RefundPaymentOnShipFail = {
Type = "Task"
Resource = aws_lambda_function.fn["refund-payment"].arn
Next = "ReleaseInventoryOnShipFail"
Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
}
ReleaseInventoryOnShipFail = {
Type = "Task"
Resource = aws_lambda_function.fn["release-inventory"].arn
Next = "SagaFailed"
Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
}
# Compensating transaction — payment failed, only undo inventory.
ReleaseInventoryOnPaymentFail = {
Type = "Task"
Resource = aws_lambda_function.fn["release-inventory"].arn
Next = "SagaFailed"
}
OrderSucceeded = { Type = "Succeed" }
SagaFailed = {
Type = "Fail"
Error = "SagaCompensated"
Cause = "Order saga compensated due to downstream failure."
}
}
})
tags = local.tags
}
# EventBridge rule — triggers the state machine on OrderPlaced events.
data "aws_cloudwatch_event_bus" "orders" { name = var.event_bus_name }
resource "aws_iam_role" "eb_sfn" {
name = "${var.state_machine_name}-eb-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "events.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy" "eb_sfn" {
name = "start-sfn"
role = aws_iam_role.eb_sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Action = "states:StartExecution"; Resource = aws_sfn_state_machine.order_saga.arn }]
})
}
resource "aws_cloudwatch_event_rule" "order_placed" {
name = "order-saga-trigger-${var.environment}"
event_bus_name = data.aws_cloudwatch_event_bus.orders.name
event_pattern = jsonencode({ source = ["orders.service"]; detail-type = ["OrderPlaced"] })
}
resource "aws_cloudwatch_event_target" "sfn" {
rule = aws_cloudwatch_event_rule.order_placed.name
event_bus_name = data.aws_cloudwatch_event_bus.orders.name
target_id = "order-saga-sfn"
arn = aws_sfn_state_machine.order_saga.arn
role_arn = aws_iam_role.eb_sfn.arn
}outputs.tf
output "state_machine_arn" { value = aws_sfn_state_machine.order_saga.arn }
output "execution_role_arn" { value = aws_iam_role.sfn.arn }
output "lambda_arns" {
value = { for k, v in aws_lambda_function.fn : k => v.arn }
}Workflow Commands
terraform init && terraform apply
# Manually start an execution to test the happy path
aws stepfunctions start-execution \
--state-machine-arn $(terraform output -raw state_machine_arn) \
--name "test-$(date +%s)" \
--input '{"orderId":"ORD-001","customerId":"CUST-123","items":[{"sku":"SKU-001","qty":2}]}'
# Check execution status
aws stepfunctions list-executions \
--state-machine-arn $(terraform output -raw state_machine_arn) \
--status-filter RUNNING
# View execution history (includes each state transition)
EXEC_ARN=$(aws stepfunctions list-executions --state-machine-arn $(terraform output -raw state_machine_arn) --query 'executions[0].executionArn' --output text)
aws stepfunctions get-execution-history --execution-arn $EXEC_ARN --query 'events[*].{Type:type,Timestamp:timestamp}'
terraform destroyApply complete! Resources: 18 added, 0 changed, 0 destroyed.
Outputs:
execution_role_arn = "arn:aws:iam::123456789012:role/order-saga-sfn-role"
lambda_arns = {
"charge-payment" = "..."
"create-shipment" = "..."
"refund-payment" = "..."
"release-inventory" = "..."
"reserve-inventory" = "..."
}
state_machine_arn = "arn:aws:states:us-east-1:123456789012:stateMachine:order-saga-prod"
