AWS Step Functions: Saga Pattern Implementation
Published on · Updated on
Replaced generic rollback advice with an illustrative ASL saga, idempotent retries, uncertain-outcome reconciliation, and operator recovery guidance.
A saga coordinates a business operation made of local transactions in separate services. With AWS Step Functions, a state machine can orchestrate those operations and choose what to do when one fails. Each participant still commits its own data; Step Functions does not create one ACID transaction across the services.
That distinction changes what “rollback” means. A compensating transaction is a new business action, such as releasing a stock reservation or voiding a payment authorization. It may take time, fail, or need a person to resolve it. A reliable saga therefore needs forward retries for temporary failures, compensation for business failures, and reconciliation when a timeout leaves the result unknown.
This guide builds a focused payment-authorization saga: reserve inventory, authorize payment, and release the reservation if payment is declined. The example also routes uncertain outcomes and failed compensation into a durable recovery case. The participant functions and recovery store are application code you must implement; the ASL definition shows how to coordinate them.
When to use Step Functions for a saga
Use orchestration when a central workflow should make the sequence and recovery path visible. Step Functions tracks the orchestration state and invokes participants; each service remains responsible for its local transaction and source-of-truth data. Choreography, where services advance the process by publishing and reacting to events, can reduce central coordination for a small number of loosely coupled participants. It becomes harder to see the full path as participants and dependencies grow. AWS compares these variants in its saga pattern guidance.
A saga fits when a business operation crosses independently owned services and can tolerate a period in which their views differ. It is a poor fit when the work must be one indivisible database transaction, or when the business cannot define a safe action after partial completion. First decide whether to retry forward, compensate, or escalate each meaningful failure; do not assume every step has a true inverse.
Participant and workflow responsibilities
- Step Functions: coordinates transitions and applies retry and catch rules. Standard workflows persist execution state and history; Express workflows have different semantics and need configured CloudWatch Logs for execution history.
- Participant service: atomically records its local change and the idempotency key for that operation, where its datastore supports that transaction. It returns the same result for a duplicate key.
- Reconciliation handler: checks the participant or provider by the stable operation identifier when a caller cannot tell whether an earlier request committed.
- Recovery handler: durably records unresolved work so a separate monitor or case queue can alert an operator. An execution that merely ends in a
Failstate is not a recovery process.
Step Functions does not lock an inventory item or serialize separate executions that target the same order. The inventory service must enforce availability and concurrency rules in its own write path—for example, by conditionally reserving only stock that remains available. Parallel states can run branches concurrently; they are not a lock.
Design the steps and their compensations
Write down each forward operation, what durable evidence proves it completed, and what to do if the next operation fails. For the example, the order service creates a pending order before starting the workflow. The reservation service records a reservation, and the payment service authorizes—but does not capture—the payment. A definite payment decline leads to a release of the reservation. Capturing funds, shipping, and refunds are outside this small example; a production order workflow must define their own forward and compensation paths.
| Step | Participant result to retain | Failure action |
|---|---|---|
| Reserve inventory | Reservation ID and reservation status | Query by the same operation ID if the result is uncertain; do not assume a timeout means no reservation. |
| Authorize payment | Provider authorization ID and status | On a confirmed decline, release the reservation. On an unknown result, query the provider before deciding. |
| Release inventory | Confirmed release result | Retry safely; if it remains unresolved, write a recovery case for an operator. |
Use an immutable sagaId for this business attempt and derive a stable key for each action, such as sagaId:reserve-inventory or sagaId:authorize-payment. A participant should atomically persist its local result with the key and return that result on a duplicate request. If an external provider supports idempotency keys, send the same key to it. A Step Functions execution name helps identify an execution, but it does not replace idempotency at the service that owns the side effect.
A local database write cannot atomically commit a remote provider request. If a payment provider does not support idempotency keys, persist an outbound intent and a unique merchant reference if the provider can look it up. After a timeout, reconcile that reference before deciding whether another request is safe. If the provider cannot confirm the result, keep the outcome unknown and escalate it; do not treat a missing response as a decline or create a new authorization.
Keep the IDs and participant results needed by later steps in workflow state. Do not put payment card data, credentials, or other secrets in execution input, where they can appear in execution history or logs. Pass a safe order reference and let the payment service retrieve its tokenized payment method from an appropriately protected store.
Example: reserve inventory and authorize payment
The state machine below uses the optimized synchronous Lambda integration. Replace the example function ARNs with your functions and grant the state machine role permission to invoke them. The reserve and authorize tasks time out after 40 seconds; configure their Lambda functions with a shorter limit, such as 30 seconds, so the workflow can observe the function result or timeout. Reconciliation tasks have 20-second state limits and recovery-case recording has a 30-second state limit; set each Lambda timeout below its task limit too. The input contains a previously persisted orderId, stable sagaId, and item list:
{
"sagaId": "checkout-123-attempt-1",
"orderId": "order-123",
"items": [{ "sku": "SKU-9", "quantity": 1 }]
}
The Lambda contracts are part of the example: ReserveInventory returns a reservation ID; ReconcileReservation returns a status and includes a reservation ID when status is RESERVED. NOT_RESERVED means the participant can prove the original request did not and will not create a reservation; a request that is still processing or cannot yet be found is UNKNOWN. AuthorizePayment returns an authorization ID or raises the exact application error PaymentDeclined only when no authorization was created. ReconcilePayment returns a status and includes an authorization ID when status is AUTHORIZED. DECLINED and NOT_AUTHORIZED are definitive outcomes for the original attempt; a pending, delayed, or unqueryable result is UNKNOWN. Reconciliation checks durable participant/provider state by sagaId, not by starting a new payment attempt. ReleaseInventory is idempotent and returns {"released": true} only after confirming the reservation is released. RecordManualRecovery classifies the state input, writes the identifiers and failure context to a durable case keyed by sagaId, returns its caseId, and is safe to invoke more than once.
{
"Comment": "Reserve stock and authorize payment as local saga transactions.",
"StartAt": "ReserveInventory",
"States": {
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:ReserveInventory",
"Payload": {
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"items.$": "$.items"
}
},
"ResultSelector": {
"reservationId.$": "$.Payload.reservationId"
},
"ResultPath": "$.reservation",
"TimeoutSeconds": 40,
"Retry": [{
"ErrorEquals": [
"Lambda.ClientExecutionTimeoutException",
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2,
"JitterStrategy": "FULL"
}],
"Catch": [{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.failure",
"Next": "ReconcileReservation"
}],
"Next": "AuthorizePayment"
},
"ReconcileReservation": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:ReconcileReservation",
"Payload": {
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId"
}
},
"ResultSelector": {
"outcome.$": "$.Payload"
},
"ResultPath": "$.reservationCheck",
"TimeoutSeconds": 20,
"Catch": [{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.reconciliationFailure",
"Next": "RecordManualRecovery"
}],
"Next": "CheckReservation"
},
"CheckReservation": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.reservationCheck.outcome.status",
"StringEquals": "RESERVED",
"Next": "UseReconciledReservation"
},
{
"Variable": "$.reservationCheck.outcome.status",
"StringEquals": "NOT_RESERVED",
"Next": "ReservationUnavailable"
}
],
"Default": "RecordManualRecovery"
},
"UseReconciledReservation": {
"Type": "Pass",
"Parameters": {
"reservationId.$": "$.reservationCheck.outcome.reservationId"
},
"ResultPath": "$.reservation",
"Next": "AuthorizePayment"
},
"AuthorizePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:AuthorizePayment",
"Payload": {
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"reservationId.$": "$.reservation.reservationId"
}
},
"ResultSelector": {
"authorizationId.$": "$.Payload.authorizationId"
},
"ResultPath": "$.authorization",
"TimeoutSeconds": 40,
"Retry": [{
"ErrorEquals": [
"Lambda.ClientExecutionTimeoutException",
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2,
"JitterStrategy": "FULL"
}],
"Catch": [
{
"ErrorEquals": ["PaymentDeclined"],
"ResultPath": "$.failure",
"Next": "MarkPaymentDeclined"
},
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.failure",
"Next": "ReconcilePayment"
}
],
"Next": "PaymentAuthorized"
},
"ReconcilePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:ReconcilePayment",
"Payload": {
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId"
}
},
"ResultSelector": {
"outcome.$": "$.Payload"
},
"ResultPath": "$.paymentCheck",
"TimeoutSeconds": 20,
"Catch": [{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.reconciliationFailure",
"Next": "RecordManualRecovery"
}],
"Next": "CheckPayment"
},
"CheckPayment": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.paymentCheck.outcome.status",
"StringEquals": "AUTHORIZED",
"Next": "UseReconciledAuthorization"
},
{
"Variable": "$.paymentCheck.outcome.status",
"StringEquals": "DECLINED",
"Next": "MarkReconciledPaymentRejection"
},
{
"Variable": "$.paymentCheck.outcome.status",
"StringEquals": "NOT_AUTHORIZED",
"Next": "MarkReconciledPaymentRejection"
}
],
"Default": "RecordManualRecovery"
},
"MarkPaymentDeclined": {
"Type": "Pass",
"Result": {
"status": "DECLINED"
},
"ResultPath": "$.paymentOutcome",
"Next": "ReleaseInventory"
},
"MarkReconciledPaymentRejection": {
"Type": "Pass",
"Parameters": {
"status.$": "$.paymentCheck.outcome.status"
},
"ResultPath": "$.paymentOutcome",
"Next": "ReleaseInventory"
},
"UseReconciledAuthorization": {
"Type": "Pass",
"Parameters": {
"authorizationId.$": "$.paymentCheck.outcome.authorizationId"
},
"ResultPath": "$.authorization",
"Next": "PaymentAuthorized"
},
"PaymentAuthorized": {
"Type": "Pass",
"Parameters": {
"status": "PAYMENT_AUTHORIZED",
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"reservationId.$": "$.reservation.reservationId",
"authorizationId.$": "$.authorization.authorizationId"
},
"ResultPath": "$.result",
"Next": "SagaCompleted"
},
"ReleaseInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:ReleaseInventory",
"Payload": {
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"reservationId.$": "$.reservation.reservationId"
}
},
"ResultSelector": {
"released.$": "$.Payload.released"
},
"ResultPath": "$.compensation",
"Retry": [{
"ErrorEquals": [
"Lambda.ClientExecutionTimeoutException",
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2,
"JitterStrategy": "FULL"
}],
"Catch": [{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.compensationFailure",
"Next": "RecordManualRecovery"
}],
"Next": "PaymentAuthorizationRejected"
},
"PaymentAuthorizationRejected": {
"Type": "Pass",
"Parameters": {
"status": "PAYMENT_NOT_AUTHORIZED",
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"paymentStatus.$": "$.paymentOutcome.status",
"reservationId.$": "$.reservation.reservationId",
"reservationReleased.$": "$.compensation.released"
},
"ResultPath": "$.result",
"Next": "SagaCompleted"
},
"ReservationUnavailable": {
"Type": "Pass",
"Parameters": {
"status": "RESERVATION_NOT_CREATED",
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId"
},
"ResultPath": "$.result",
"Next": "SagaCompleted"
},
"RecordManualRecovery": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:RecordManualRecovery",
"Payload.$": "$"
},
"ResultSelector": {
"caseId.$": "$.Payload.caseId"
},
"ResultPath": "$.recovery",
"TimeoutSeconds": 30,
"Retry": [{
"ErrorEquals": [
"Lambda.ClientExecutionTimeoutException",
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 2,
"MaxAttempts": 5,
"BackoffRate": 2,
"JitterStrategy": "FULL"
}],
"Next": "ManualRecoveryPending"
},
"ManualRecoveryPending": {
"Type": "Pass",
"Parameters": {
"status": "MANUAL_RECOVERY_REQUIRED",
"sagaId.$": "$.sagaId",
"orderId.$": "$.orderId",
"caseId.$": "$.recovery.caseId"
},
"ResultPath": "$.result",
"Next": "SagaCompleted"
},
"SagaCompleted": {
"Type": "Succeed"
}
}
}
In JSONPath workflows, ResultSelector trims a service response, and ResultPath places it beside the existing input. The explicit $.failure catcher paths retain sagaId, orderId, and the reservation ID for reconciliation or compensation. Without a ResultPath, a catcher replaces the input with the error object, which can discard the identifiers needed to recover. AWS documents these data-flow rules for ResultPath.
A Step Functions execution with status SUCCEEDED means the state machine reached its Succeed state; it does not mean the customer’s payment or order succeeded. Inspect result.status: PAYMENT_AUTHORIZED means the authorization step completed, PAYMENT_NOT_AUTHORIZED means stock was released after a definitive no-authorization outcome, RESERVATION_NOT_CREATED means inventory confirmed it created no reservation, and MANUAL_RECOVERY_REQUIRED means a durable case was recorded while business work remains unresolved. A payment decline that was compensated is a completed saga outcome, not an orchestration error. The caller still needs to map these results to the pending order record and expose a useful status to the customer. A production flow that captures payment or fulfills the order must add those participant transactions, their stable operation keys, and the appropriate void, refund, or cancellation paths.
Retries, timeouts, and compensation
The Retry blocks target transient Lambda service errors and use bounded exponential backoff with jitter. They do not retry a confirmed PaymentDeclined business outcome. The same policy is safe only because every participant and compensation uses the same stable action key on every attempt. AWS lists the Lambda service exception names and retry fields in its Step Functions best practices; tune the retry count and total time against your service deadline and provider limits.
A timeout is an observation about the caller, not proof that the remote action failed. The payment provider might have authorized the request before the response was lost. The example routes all caught authorization errors other than a definite decline to ReconcilePayment; it releases stock only after the provider reports a definitive decline or no authorization. If reconciliation also fails or returns UNKNOWN, the workflow writes a durable recovery case rather than guessing. The same rule applies to inventory: inspect the reservation service before deciding whether anything needs compensation.
Compensate completed steps in reverse order, using the identifiers returned by those steps. A release operation is not a database rollback; it is another local transaction and can itself fail. If a workflow has multiple successful steps before failure, each compensation should be idempotent, and the recovery record should preserve the saga ID, failed step, known results, participant identifiers, and errors needed to resume safely.
There is no cross-execution lock in this state machine. If two checkouts compete for the last unit, inventory must serialize the reservations in its own data store. Keep each local commit and its idempotency record atomic where possible, and decide how a conflict is reported to the saga.
Choose a workflow type and recovery process
Standard and Express execution semantics
Standard workflows use an exactly-once workflow model by default and support long-running executions with execution history. A configured Retry or a redrive can run an unsuccessful Task again. Asynchronous Express workflows use at-least-once execution, so a step can run more than once; synchronous Express workflows use at-most-once execution and return the result when the workflow completes. Express workflows are limited to five minutes and rely on CloudWatch Logs for execution history. These are orchestration semantics, not a distributed ACID guarantee: the participant still needs idempotency and reconciliation for repeated or uncertain external effects.
For a business saga that can remain open, needs detailed history, or may need operator recovery, Standard is often the practical fit. Use Express when the workflow fits its duration and integration limits and the application is designed for its execution semantics. Check the current workflow-type table and service quotas before choosing.
Preserve identifiers and control concurrent writes
Keep workflow payloads small and stable. Store only the IDs and results later tasks need; put larger documents in durable storage and pass a reference. If a participant uses optimistic concurrency or conditional writes, retry a conflict only after re-reading current state and applying a domain rule. A blind retry can turn a genuine inventory conflict into overselling or overwrite another operation.
Make unresolved work visible
Monitor failed, timed-out, and long-running executions alongside business-level signals such as orders still pending beyond their expected duration, compensation failures, and recovery cases that remain unassigned. Alert an owner when a case is created or remains unresolved past its response target. Include sagaId and orderId in structured participant logs, while keeping sensitive payment data out of workflow input and logs. For Express, configure CloudWatch Logs; for Standard, use execution history and metrics, and add logs or traces where they help diagnose participant behavior.
A recovery task must durably save a case before the workflow returns MANUAL_RECOVERY_REQUIRED. Alert when that task itself fails or the execution fails outside a catcher. Step Functions catchers apply to Task, Parallel, and Map states; they cannot catch every top-level execution failure, and States.ALL does not match every terminal error. See AWS’s error-handling rules, then configure an execution-failure signal and document who owns replay, reconciliation, and closure.
Standard workflow executions that failed, were aborted, or timed out may be eligible for redrive for 14 days. Redrive resumes at the unsuccessful step with the original input and preserves successful step results; it can run that failed Task again. An execution that ended in a Fail state re-enters that state and fails again, so redrive does not rerun earlier recovery tasks. Redrive is useful for eligible task failures, but it does not repair participant state or remove the need for idempotency.
Test the failure paths
Test more than the successful path. At minimum, exercise a definite inventory rejection, payment decline after reservation, a timeout after the participant may have committed, duplicate invocation with the same key, reconciliation returning UNKNOWN, compensation retry and failure, recovery-case write failure, and a concurrent request for the last unit. Verify both the state-machine transition and the participant's durable records. A caught error is not proof that the compensation occurred.
The Step Functions TestState API can test individual states, including which Retry or Catch applies and how ResultPath shapes the data. Use mocked outcomes for state logic, then run integration tests against isolated participant services to prove idempotency, reconciliation, and compensation behavior. The site’s guide to resilient serverless state management covers idempotent operations and sagas in a broader state-management context. Its guide to cross-service resilience explains why timeouts, retries, queues, and payment reconciliation need explicit failure policies.
Operational checklist
- Give each saga and each participant action a stable, persisted idempotency key.
- Retry only plausibly transient failures, with a bounded policy and backoff.
- Reconcile by the original operation ID when a timeout makes the remote result uncertain.
- Compensate confirmed completed work in reverse order; treat each compensation as a new fallible transaction.
- Write unresolved cases durably, alert an owner, and retain the identifiers needed to investigate or resume.
- Use the owning service’s data store to enforce concurrency; Step Functions coordinates execution but does not lock business resources.
A Step Functions saga makes the sequence and failure paths explicit. The business guarantee comes from the participant contracts around it: durable local commits, idempotent operations, safe compensation, and an operator path for outcomes the system cannot determine automatically.