Everything we have built in this module is choreography: each component reacts to what it sees and nobody has the full picture. It works splendidly for independent facts —sending an email, loading analytics, generating a thumbnail— and it breaks as soon as the steps depend on one another and you have to undo what has been done if something fails halfway.

MercadoFresco's order process is exactly that case: charge the card, reserve the stock, ask the warehouse to prepare the box —which takes between two minutes and an hour and is confirmed by a person with a barcode scanner—, assign delivery and confirm to the customer. If the fish arrives in poor condition and the warehouse cannot prepare the order, the charge has to be refunded and the stock released. With loose events nobody knows where the process was or what needs compensating; the only way is a hand-written state table, a process to watch it and a heap of odd cases that never get tested.

AWS Step Functions is AWS's serverless orchestrator. You define the process as a state machine in JSON, and the service takes care of running each step, storing the state between steps, retrying with exponential backoff, catching errors, waiting hours or days if needed, and leaving a complete history of every execution. Here Luis builds mercadofresco-procesar-pedido with its full compensation path.

Cost warning. Standard workflows cost 0.025 USD per 1,000 state transitions; express ones, by number of executions and by GB-second. A badly designed loop with a short Wait can generate hundreds of thousands of transitions in one night. At the end you have the cleanup. Data is fictitious.

Contents

  1. Choreography versus orchestration
  2. Standard workflows and express workflows
  3. Amazon States Language: the structure
  4. The eight state types
  5. Data flow between states
  6. Integrations and service patterns
  7. The task token for the warehouse's human step
  8. Error handling: Retry and Catch
  9. The saga pattern: compensating what has already been done
  10. mercadofresco-procesar-pedido in full
  11. Distributed Map for Friday's volume
  12. Deployment and starting from EventBridge
  13. Observability: debugging a failed execution
  14. Workflow Studio and cost
  15. Common mistakes and tips
  16. Exercises
  17. Conclusion

Choreography versus orchestration

Choreography (events) Orchestration (stateful workflow)
Who knows the process Nobody: it is spread out The orchestrator, in one place
Coupling Minimal Medium: the orchestrator knows the steps
Adding a step Subscribe a consumer Edit the definition
Seeing where it stands Rebuild it from logs Direct query
Undoing what was done Very hard Catch + compensations
Waiting hours or days Needs state storage Native (Wait, task token)
Single point of failure No The orchestrator (managed, but conceptual)
Scale Huge High, with per-account limits

Choose choreography when consumers are independent, order does not matter, each one can fail on its own without affecting the others and you want to add interested parties without touching anything. That is what we did with PedidoConfirmado: email, analytics and marketing need not know each other.

Choose orchestration when there is a named business process, its steps depend on the outcome of the previous ones, there are conditional decisions, there are long waits or human intervention, and —above all— what has been done has to be compensated if something fails halfway. Charging a card and then being unable to fulfil the order is not fixed with retries: it is fixed by giving the money back, and somebody has to know that it needs doing.

MercadoFresco ends up with both: bus-mercadofresco hands out the facts, and one of its rules starts the state machine that governs the order process. They do not compete; they complement each other.

Standard workflows and express workflows

Standard Express
Maximum duration 1 year 5 minutes
Execution semantics Exactly once At least once (synchronous: at most once)
Price 0.025 USD/1,000 transitions Per execution + GB-second
Throughput 2,000 starts/s 100,000 starts/s
Execution history 90 days in the console, in detail Only in CloudWatch Logs, if you enable it
Task token (waitForTaskToken) Yes Only in synchronous mode
.sync integrations Yes No
Use case Long, critical business processes High volume, short, idempotent

The decisive row is the semantics. A standard workflow guarantees that each step runs exactly once: if the service has an internal problem, it resumes from where it was without repeating. An express workflow may re-run a step, so all of its steps must be idempotent.

For MercadoFresco: mercadofresco-procesar-pedido is standard, because it charges cards, lasts up to an hour and cannot afford to repeat a charge. Basket validation before paying —checking stock, recalculating prices, applying coupons, all in under a second and with no external effects— is, by contrast, a perfect candidate for an express workflow (mercadofresco-validar-carrito).

Amazon States Language: the structure

A state machine is a JSON object with three top-level keys.

{
  "Comment": "MercadoFresco order process",
  "StartAt": "CobrarPago",
  "TimeoutSeconds": 5400,
  "States": {
    "CobrarPago": { "Type": "Task", "Resource": "...", "Next": "ReservarStock" },
    "ReservarStock": { "Type": "Task", "Resource": "...", "End": true }
  }
}
  • StartAt: the name of the first state. Mandatory.
  • States: an object where each key is the name of a state. The names are unique identifiers and appear verbatim in the console and in the history, so it pays to make them descriptive and stable: changing them breaks the links from alarms and confuses the history.
  • Each state has a Type and, except for the terminal ones, a Next or "End": true.

There are no explicit loops and no global variables in the classic sense: the "loop" is built with a Choice that goes back to an earlier state, and the "state" is the JSON that travels from one state to the next. That restriction is deliberate and it is what makes the workflow inspectable.

The eight state types

Task — does work. It is the only one that calls something external.

"CobrarPago": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke",
  "Parameters": {
    "FunctionName": "mercadofresco-cobrar-pago",
    "Payload.$": "$.pedido"
  },
  "ResultSelector": { "referencia_cobro.$": "$.Payload.referencia" },
  "ResultPath": "$.cobro",
  "TimeoutSeconds": 30,
  "Next": "ReservarStock"
}

Choice — branches on the content. It does no work; it decides.

"EsUrgente": {
  "Type": "Choice",
  "Choices": [
    {
      "And": [
        { "Variable": "$.pedido.franja_entrega", "StringEquals": "24h" },
        { "Variable": "$.pedido.importe_eur", "NumericGreaterThanEquals": 60 }
      ],
      "Next": "AsignarRepartoPrioritario"
    },
    { "Variable": "$.pedido.provincia", "StringMatches": "Baleares", "Next": "RutaInsular" }
  ],
  "Default": "AsignarReparto"
}

The comparators cover strings, numbers, booleans, timestamps and presence (IsPresent, IsNull), with Path variants to compare two fields against each other. Default is not optional in practice: if no condition matches and there is no Default, the execution fails with States.NoChoiceMatched.

Parallel — runs several branches at once with the same input, and returns an array with the results in branch order.

"NotificarEnParalelo": {
  "Type": "Parallel",
  "Branches": [
    { "StartAt": "AvisarCliente", "States": { "AvisarCliente": {
        "Type": "Task", "Resource": "arn:aws:states:::sns:publish",
        "Parameters": {"TopicArn": "...", "Message.$": "$.pedido.pedido_id"}, "End": true } } },
    { "StartAt": "PublicarAnalitica", "States": { "PublicarAnalitica": {
        "Type": "Task", "Resource": "arn:aws:states:::events:putEvents",
        "Parameters": {"Entries": [{"Source": "mercadofresco.tienda",
          "DetailType": "PedidoProcesado", "EventBusName": "bus-mercadofresco",
          "Detail.$": "$.pedido"}]}, "End": true } } }
  ],
  "ResultPath": "$.notificaciones", "Next": "Exito"
}

Careful: if one branch fails, the whole Parallel fails and the others are cancelled. If you want tolerance, put a Catch inside each branch.

Map — runs the same sub-logic for each element of an array. That is the difference from Parallel: different branches on the same data vs. the same branch on different data.

"ReservarCadaLinea": {
  "Type": "Map",
  "ItemsPath": "$.pedido.lineas",
  "MaxConcurrency": 5,
  "ItemSelector": { "sku.$": "$$.Map.Item.Value.sku", "unidades.$": "$$.Map.Item.Value.unidades" },
  "ItemProcessor": {
    "ProcessorConfig": { "Mode": "INLINE" },
    "StartAt": "ReservarLinea",
    "States": {
      "ReservarLinea": {
        "Type": "Task",
        "Resource": "arn:aws:states:::lambda:invoke",
        "Parameters": { "FunctionName": "mercadofresco-reservar-stock", "Payload.$": "$" },
        "End": true
      }
    }
  },
  "ResultPath": "$.reservas",
  "Next": "PrepararEnAlmacen"
}

$$ is the context object, which gives access to execution metadata: $$.Map.Item.Value is the current element, $$.Execution.Name the execution name, $$.Task.Token the task token.

Wait — waits. For a fixed number of seconds (Seconds), until a timestamp (Timestamp), or with the value taken from the input (SecondsPath, TimestampPath).

"EsperarVentanaDeReparto": {
  "Type": "Wait",
  "TimestampPath": "$.reparto.inicio_franja",
  "Next": "NotificarRepartidor"
}

A three-day Wait consumes nothing: Step Functions keeps no process waiting and no transitions are charged while it waits. It is one of the most practical differences compared with implementing the same thing by hand.

Pass — does no work; it transforms or injects data. Irreplaceable for debugging and for normalising JSON shapes between steps.

"NormalizarEntrada": {
  "Type": "Pass",
  "Parameters": { "pedido.$": "$.detail", "origen": "eventbridge" },
  "Next": "CobrarPago"
}

Succeed and Fail — end the execution. Fail accepts Error and Cause, which are what will show up in the history and in the ExecutionsFailed metric; give them useful text.

"PedidoCompensado": {
  "Type": "Fail",
  "Error": "PedidoNoPreparable",
  "Cause": "The warehouse rejected the preparation; charge refunded and stock released."
}

Data flow between states

This is the part people struggle with most, and it deserves tracing step by step. Every Task state applies five filters in this exact order:

Order Field What it does
1 InputPath Selects which part of the input is looked at. $ (all of it) by default
2 Parameters Builds the payload that is sent to the service
3 (the service responds)
4 ResultSelector Trims and reorders the raw response from the service
5 ResultPath Says where the result is inserted within the original input
6 OutputPath Selects what is passed on to the next state

Fields ending in .$ take their value from a JSONPath expression instead of a literal. That is the rule to learn: "FunctionName": "mercadofresco-cobrar-pago" is a literal, whereas "Payload.$": "$.pedido" is a reference.

A traced example. The input to the CobrarPago state is:

{
  "pedido": { "pedido_id": "PED-2026-084417", "importe_eur": 48.20, "cliente_id": "CLI-30912" },
  "origen": "eventbridge"
}

With the state defined above:

  1. InputPath is absent, so everything is taken ($).
  2. Parameters builds {"FunctionName": "mercadofresco-cobrar-pago", "Payload": {"pedido_id": "PED-2026-084417", "importe_eur": 48.20, "cliente_id": "CLI-30912"}}. Only that reaches Lambda.
  3. Lambda responds, and the lambda:invoke integration wraps the response: {"ExecutedVersion": "$LATEST", "Payload": {"referencia": "PAY-77321", "estado": "capturado"}, "StatusCode": 200}.
  4. ResultSelector {"referencia_cobro.$": "$.Payload.referencia"} trims it down to {"referencia_cobro": "PAY-77321"}, throwing away the integration's noise.
  5. ResultPath "$.cobro" inserts that object into the original input under the cobro key.
  6. OutputPath is absent, so everything goes out.

Final output, which is the input to ReservarStock:

{
  "pedido": { "pedido_id": "PED-2026-084417", "importe_eur": 48.20, "cliente_id": "CLI-30912" },
  "origen": "eventbridge",
  "cobro": { "referencia_cobro": "PAY-77321" }
}

Three special values of ResultPath that you have to know:

Value Effect
"$.something" Inserts the result under that key and preserves the input
"$" (default) The result replaces the whole input. Number one cause of lost data
null Discards the result and passes the input through untouched. Ideal for tasks whose result does not matter

The classic mistake is leaving ResultPath at its default in an intermediate Task: the Lambda response overwrites the whole order and the next state cannot find $.pedido. MercadoFresco's rule: an explicit ResultPath in every intermediate Task.

Integrations and service patterns

Step Functions calls other services in two ways. Optimised integrations have their own ARN (arn:aws:states:::lambda:invoke, :::dynamodb:putItem, :::sns:publish, :::sqs:sendMessage, :::ecs:runTask) and add conveniences. SDK integrations cover more than 200 services and 9,000 actions with the form arn:aws:states:::aws-sdk:<service>:<action>, for example arn:aws:states:::aws-sdk:rds:describeDBClusters. If something can be done with the SDK, it can be done without writing a Lambda.

And there are three integration patterns that completely change how a Task behaves:

Pattern ARN suffix What it does Example
Response none Calls and carries on as soon as the API replies sns:publish
Run and wait .sync Waits for the work to actually finish ecs:runTask.sync, states:startExecution.sync
Task token .waitForTaskToken Stops until somebody returns a token Human step, external system

The difference between response and .sync is enormous and is often misread. ecs:runTask returns as soon as ECS accepts the request —the task may take another ten minutes—; ecs:runTask.sync does not carry on until the task finishes, and fails if the task fails. Without .sync you would have to build a polling loop with Wait and Choice, which is exactly what the pattern saves you.

The task token for the warehouse's human step

Preparing an order in the warehouse is not an API call: it is a person picking up a box, filling it and scanning a barcode. It can take two minutes or an hour, and sometimes the answer is "I can't".

.waitForTaskToken is made for this. Step Functions generates a token, puts it into the payload it sends to the target and stops the state indefinitely (up to HeartbeatSeconds or TimeoutSeconds). The execution consumes nothing while it waits. When the warehouse system finishes, it calls SendTaskSuccess or SendTaskFailure with that token, and the machine carries on.

"PrepararEnAlmacen": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
  "Parameters": {
    "QueueUrl": "https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-almacen",
    "MessageBody": {
      "pedido_id.$": "$.pedido.pedido_id",
      "lineas.$": "$.pedido.lineas",
      "franja.$": "$.pedido.franja_entrega",
      "token_tarea.$": "$$.Task.Token"
    }
  },
  "TimeoutSeconds": 3600,
  "HeartbeatSeconds": 600,
  "ResultPath": "$.preparacion",
  "Retry": [
    { "ErrorEquals": ["States.Timeout"], "MaxAttempts": 1, "IntervalSeconds": 60 }
  ],
  "Catch": [
    { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "CompensarPedido" }
  ],
  "Next": "AsignarReparto"
}

The warehouse side, which is an ordinary application:

sfn = boto3.client("stepfunctions", region_name="eu-west-1")

def confirm_preparation(token, order_id, operator):
    """Called by the warehouse terminal when the box is ready."""
    sfn.send_task_success(taskToken=token, output=json.dumps(
        {"preparado": True, "operario": operator, "pedido_id": order_id}))

def reject_preparation(token, reason):
    """Product in poor condition, real stock shortage, refrigeration incident."""
    sfn.send_task_failure(taskToken=token,
                          error="AlmacenNoPuedePreparar",  # compared in ErrorEquals
                          cause=reason)

def still_working(token):
    """Heartbeat: called every few minutes while the box is being prepared."""
    sfn.send_task_heartbeat(taskToken=token)

HeartbeatSeconds: 600 is the protection against the operator who goes off for lunch with the box half done: if no heartbeat arrives within 10 minutes, the state fails with States.Heartbeat and the process can react well before the one-hour TimeoutSeconds. And error="AlmacenNoPuedePreparar" is not decorative: it is the value compared in ErrorEquals to choose the compensation path.

Two warnings. The token expires with the execution: if the machine stops, SendTaskSuccess will return TaskTimedOut. And you have to store the token somewhere durable (in the queue message itself, as here, or in DynamoDB), because it is the only thing that allows the process to resume.

Error handling: Retry and Catch

Retry retries the same state. Catch abandons the state and jumps to another one. They are evaluated in that order: first the retries are exhausted, and only then does the Catch act.

"Retry": [
  {
    "ErrorEquals": ["Lambda.ServiceException", "Lambda.TooManyRequestsException",
                    "States.TaskFailed", "PasarelaTemporalmenteNoDisponible"],
    "IntervalSeconds": 2,
    "MaxAttempts": 4,
    "BackoffRate": 2.0,
    "MaxDelaySeconds": 30,
    "JitterStrategy": "FULL"
  },
  {
    "ErrorEquals": ["TarjetaRechazada"],
    "MaxAttempts": 0
  }
]
Parameter What it does
ErrorEquals List of error names. States.ALL catches them all
IntervalSeconds Wait before the first retry
MaxAttempts Retries (not attempts). 0 disables retrying for that error
BackoffRate Multiplier between retries: 2 → 2 s, 4 s, 8 s, 16 s
MaxDelaySeconds Ceiling for the interval, so the backoff does not run away
JitterStrategy FULL randomises the wait and stops a thousand executions retrying at once

JitterStrategy: "FULL" should be your default value. Without it, if the payment gateway goes down for 30 seconds, the 450 executions in flight all retry at exactly the same instant and knock it over again just as it was recovering. That is the retry storm, which we cover in depth in 07-05.

The order of the blocks matters. The first match wins, so specific errors go before generic ones. In the example, TarjetaRechazada with MaxAttempts: 0 explicitly disables retrying for an error that retrying does not fix: the card will still be declined. Telling transient errors from permanent ones is the same discipline as in 07-01, now declarative.

Catch works the same way, but instead of retrying it jumps:

"Catch": [
  { "ErrorEquals": ["TarjetaRechazada"], "ResultPath": "$.error", "Next": "PedidoRechazado" },
  { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "CompensarPedido" }
]

ResultPath in the Catch is critical. With "$.error", the target state receives the original input plus the {"Error": "...", "Cause": "..."} object under error. Without it (default $), the error replaces the whole input and the compensation state is left not knowing which order to compensate or which charge reference to refund. It is the most frustrating bug in Step Functions.

Predefined errors worth knowing: States.Timeout, States.TaskFailed, States.Permissions, States.ResultPathMatchFailed, States.NoChoiceMatched, States.Heartbeat, States.DataLimitExceeded (the payload between states exceeded 256 KB).

The saga pattern: compensating what has already been done

There are no distributed transactions across a payment gateway, Aurora, DynamoDB and a partner's ERP. The realistic alternative is the saga: a sequence of steps where each one has a compensation that undoes its effect, and if something fails the compensations of the already completed steps are run, in reverse order.

Step Compensation
Charge the card Refund the charge (mercadofresco-devolver-cobro)
Reserve stock Release stock (mercadofresco-liberar-stock)
Prepare in the warehouse Cancel the preparation order
Assign delivery Free up the driver's slot

A compensation is not a rollback: it is a new, visible business action. A refund shows up on the customer's statement; the money was held. That is why sagas are designed to minimise the time between the risky step and its possible compensation, and why it pays to order the steps from most to least reversible whenever you can, leaving the irreversible ones until the end.

stateDiagram-v2
    [*] --> CobrarPago
    CobrarPago --> ReservarStock: charge OK
    CobrarPago --> PedidoRechazado: TarjetaRechazada
    ReservarStock --> PrepararEnAlmacen: stock reserved
    ReservarStock --> DevolverCobro: SinStock
    PrepararEnAlmacen --> AsignarReparto: token success
    PrepararEnAlmacen --> LiberarStock: AlmacenNoPuedePreparar / Timeout / Heartbeat
    AsignarReparto --> NotificarCliente: delivery assigned
    AsignarReparto --> LiberarStock: SinRepartidor
    NotificarCliente --> PedidoCompletado
    LiberarStock --> DevolverCobro: stock released
    DevolverCobro --> PedidoCompensado: charge refunded
    DevolverCobro --> RevisionManual: refund failed
    PedidoCompletado --> [*]
    PedidoCompensado --> [*]
    PedidoRechazado --> [*]
    RevisionManual --> [*]

Notice RevisionManual. Compensations fail too, and when a refund fails there is a customer's money being held with no order behind it: that cannot end in a silent Fail. That state publishes to alertas-mercadofresco and writes to a queue that Marta reviews. Every saga needs its "this one is for a human now" path.

mercadofresco-procesar-pedido in full

{
  "Comment": "MercadoFresco order process with compensation saga",
  "StartAt": "NormalizarEntrada",
  "TimeoutSeconds": 5400,
  "States": {
    "NormalizarEntrada": {
      "Type": "Pass",
      "Parameters": { "pedido.$": "$.detail", "iniciado_en.$": "$$.Execution.StartTime" },
      "Next": "CobrarPago"
    },

    "CobrarPago": {
      "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "mercadofresco-cobrar-pago", "Payload.$": "$.pedido" },
      "ResultSelector": { "referencia.$": "$.Payload.referencia" },
      "ResultPath": "$.cobro", "TimeoutSeconds": 30,
      "Retry": [
        { "ErrorEquals": ["TarjetaRechazada"], "MaxAttempts": 0 },
        { "ErrorEquals": ["States.ALL"], "IntervalSeconds": 2, "MaxAttempts": 4,
          "BackoffRate": 2.0, "MaxDelaySeconds": 30, "JitterStrategy": "FULL" }
      ],
      "Catch": [{ "ErrorEquals": ["TarjetaRechazada"], "ResultPath": "$.error",
                  "Next": "PedidoRechazado" }],
      "Next": "ReservarStock"
    },

    "ReservarStock": {
      "Type": "Map", "ItemsPath": "$.pedido.lineas", "MaxConcurrency": 5,
      "ItemSelector": { "sku.$": "$$.Map.Item.Value.sku",
                        "unidades.$": "$$.Map.Item.Value.unidades",
                        "pedido_id.$": "$.pedido.pedido_id" },
      "ItemProcessor": {
        "ProcessorConfig": { "Mode": "INLINE" }, "StartAt": "ReservarLinea",
        "States": { "ReservarLinea": {
          "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
          "Parameters": { "FunctionName": "mercadofresco-reservar-stock", "Payload.$": "$" },
          "ResultSelector": { "reservado.$": "$.Payload.reservado" }, "End": true } }
      },
      "ResultPath": "$.reservas",
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error",
                  "Next": "DevolverCobro" }],
      "Next": "PrepararEnAlmacen"
    },

    "PrepararEnAlmacen": {
      "Type": "Task", "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
      "Parameters": {
        "QueueUrl": "https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-almacen",
        "MessageBody": { "pedido_id.$": "$.pedido.pedido_id", "lineas.$": "$.pedido.lineas",
                         "franja.$": "$.pedido.franja_entrega", "token_tarea.$": "$$.Task.Token" }
      },
      "TimeoutSeconds": 3600, "HeartbeatSeconds": 600, "ResultPath": "$.preparacion",
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error",
                  "Next": "LiberarStock" }],
      "Next": "AsignarReparto"
    },

    "AsignarReparto": {
      "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "mercadofresco-asignar-reparto",
                      "Payload": { "pedido_id.$": "$.pedido.pedido_id",
                                   "franja.$": "$.pedido.franja_entrega",
                                   "provincia.$": "$.pedido.provincia" } },
      "ResultSelector": { "repartidor_id.$": "$.Payload.repartidor_id", "eta.$": "$.Payload.eta" },
      "ResultPath": "$.reparto",
      "Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 5, "MaxAttempts": 3,
                  "BackoffRate": 2.0, "JitterStrategy": "FULL" }],
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error",
                  "Next": "LiberarStock" }],
      "Next": "NotificarCliente"
    },

    "NotificarCliente": {
      "Type": "Task", "Resource": "arn:aws:states:::events:putEvents",
      "Parameters": { "Entries": [{
        "EventBusName": "bus-mercadofresco", "Source": "mercadofresco.tienda",
        "DetailType": "PedidoProcesado",
        "Detail": { "pedido_id.$": "$.pedido.pedido_id",
                    "repartidor_id.$": "$.reparto.repartidor_id", "eta.$": "$.reparto.eta" } }] },
      "ResultPath": null, "Next": "PedidoCompletado"
    },

    "PedidoCompletado": { "Type": "Succeed" },

    "LiberarStock": {
      "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "mercadofresco-liberar-stock",
                      "Payload": { "pedido_id.$": "$.pedido.pedido_id",
                                   "lineas.$": "$.pedido.lineas" } },
      "ResultPath": null,
      "Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 5, "MaxAttempts": 5,
                  "BackoffRate": 2.0, "JitterStrategy": "FULL" }],
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error_compensacion",
                  "Next": "RevisionManual" }],
      "Next": "DevolverCobro"
    },

    "DevolverCobro": {
      "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "mercadofresco-devolver-cobro",
                      "Payload": { "referencia.$": "$.cobro.referencia",
                                   "pedido_id.$": "$.pedido.pedido_id" } },
      "ResultPath": null,
      "Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 10, "MaxAttempts": 6,
                  "BackoffRate": 2.0, "MaxDelaySeconds": 300, "JitterStrategy": "FULL" }],
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error_compensacion",
                  "Next": "RevisionManual" }],
      "Next": "PedidoCompensado"
    },

    "RevisionManual": {
      "Type": "Task", "Resource": "arn:aws:states:::sns:publish",
      "Parameters": { "TopicArn": "arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco",
                      "Subject": "Compensation failed: manual review",
                      "Message.$": "States.JsonToString($)" },
      "Next": "PedidoIncoherente"
    },

    "PedidoIncoherente": { "Type": "Fail", "Error": "CompensacionFallida",
      "Cause": "The charge could not be refunded or the stock released. Human action required." },
    "PedidoCompensado": { "Type": "Fail", "Error": "PedidoNoPreparable",
      "Cause": "The order was compensated correctly: charge refunded and stock released." },
    "PedidoRechazado": { "Type": "Fail", "Error": "TarjetaRechazada",
      "Cause": "The gateway declined the payment. There is nothing to compensate." }
  }
}

Details that are not accidental. ResultPath: null in the compensations and in the notification: their result adds nothing, and this way the state does not get dirty. More retries in DevolverCobro (6) than anywhere else: failing a refund is far more expensive than failing anything else. PedidoRechazado does not go through compensation, because if the card was declined nothing was charged. And States.JsonToString($) is one of the language's intrinsic functions; there are more (States.Format, States.Array, States.ArrayPartition, States.MathRandom, States.UUID) that save you from writing a Lambda just to transform data.

Distributed Map for Friday's volume

Map in INLINE mode has two limits: 40 concurrent iterations and all of the data held in the state, subject to the 256 KB maximum. To reconcile the 4,300 orders of a Friday against the payment gateway's entries, or to process a 200,000-line file in S3, you need the DISTRIBUTED mode.

"ConciliarPedidosDelDia": {
  "Type": "Map",
  "ItemReader": {
    "Resource": "arn:aws:states:::s3:getObject",
    "ReaderConfig": { "InputType": "CSV", "CSVHeaderLocation": "FIRST_ROW" },
    "Parameters": { "Bucket": "mercadofresco-informes-analitica",
                    "Key.$": "$.fichero_conciliacion" }
  },
  "ItemProcessor": {
    "ProcessorConfig": { "Mode": "DISTRIBUTED", "ExecutionType": "EXPRESS" },
    "StartAt": "ConciliarPedido",
    "States": {
      "ConciliarPedido": {
        "Type": "Task",
        "Resource": "arn:aws:states:::lambda:invoke",
        "Parameters": { "FunctionName": "mercadofresco-conciliar-pedido", "Payload.$": "$" },
        "End": true
      }
    }
  },
  "MaxConcurrency": 200,
  "ToleratedFailurePercentage": 2,
  "ItemBatcher": { "MaxItemsPerBatch": 25 },
  "ResultWriter": {
    "Resource": "arn:aws:states:::s3:putObject",
    "Parameters": { "Bucket": "mercadofresco-informes-analitica",
                    "Prefix": "conciliacion/resultados/" }
  },
  "Next": "PublicarInformeConciliacion"
}

The differences from INLINE mode are substantial:

INLINE DISTRIBUTED
Maximum concurrency 40 10,000
Source of the items An array in the state Array, or S3: objects, CSV, JSON, manifest
Execution of each iteration Inside the parent execution Independent child execution
History Counts towards the parent's Its own, without inflating the parent's
Fault tolerance All or nothing ToleratedFailurePercentage
Practical volume Hundreds Millions

ToleratedFailurePercentage: 2 allows up to 2 % of the entries to fail without aborting the whole reconciliation —typical when the bank's file brings odd lines— and ItemBatcher groups 25 items per Lambda invocation, dividing the number of invocations, and their cost, by 25. Since each iteration is a child execution, the parent's history does not fill up with 4,300 entries, which is what made the console unusable in INLINE mode.

Deployment and starting from EventBridge

export PROFILE="--profile mercadofresco-dev --region eu-west-1"

aws stepfunctions create-state-machine \
  --name mercadofresco-procesar-pedido \
  --definition file://mercadofresco-procesar-pedido.json \
  --role-arn arn:aws:iam::111122223333:role/rol-mf-step-functions \
  --type STANDARD \
  --logging-configuration '{"level":"ERROR","includeExecutionData":true,
    "destinations":[{"cloudWatchLogsLogGroup":{"logGroupArn":
      "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/vendedlogs/states/mercadofresco:*"}}]}' \
  --tracing-configuration '{"enabled":true}' \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=integracion Key=Propietario,Value=marta \
         Key=CentroCoste,Value=tecnologia $PROFILE

The rol-mf-step-functions role needs permission for every action the machine performs: lambda:InvokeFunction on the five functions, sqs:SendMessage on the warehouse queue, events:PutEvents on the bus, sns:Publish on the alerts topic, plus xray:PutTraceSegments and the write permissions on the log group. A States.Permissions in the middle of a compensation is one of the nastiest failures there is: the order is left half done because the orchestrator could not call whoever it had to call.

The start is done by a rule on bus-mercadofresco:

aws events put-rule --name regla-mf-arrancar-proceso-pedido \
  --event-bus-name bus-mercadofresco --state ENABLED \
  --event-pattern '{"source":["mercadofresco.tienda"],"detail-type":["PedidoConfirmado"]}' $PROFILE

aws events put-targets --rule regla-mf-arrancar-proceso-pedido \
  --event-bus-name bus-mercadofresco \
  --targets '[{
    "Id": "maquina-procesar-pedido",
    "Arn": "arn:aws:states:eu-west-1:111122223333:stateMachine:mercadofresco-procesar-pedido",
    "RoleArn": "arn:aws:iam::111122223333:role/rol-mf-eventbridge-invoca-sfn",
    "DeadLetterConfig": {"Arn":"arn:aws:sqs:eu-west-1:111122223333:mercadofresco-eventbridge-fallidas"},
    "RetryPolicy": {"MaximumRetryAttempts": 5, "MaximumEventAgeInSeconds": 3600}
  }]' $PROFILE

A highly recommended trick: pass an execution name derived from the pedido_id. Execution names must be unique within 90 days, so if the same event arrives twice, the second execution fails with ExecutionAlreadyExists instead of charging the card again. It is idempotency for free, and it leads straight into 07-05.

Observability: debugging a failed execution

Step Functions keeps the complete history of every standard execution for 90 days: every state entry, every output, every retry, every error. It is its best feature and it has no equivalent in a hand-written orchestration.

# Failed executions of the day
aws stepfunctions list-executions \
  --state-machine-arn arn:aws:states:eu-west-1:111122223333:stateMachine:mercadofresco-procesar-pedido \
  --status-filter FAILED --max-items 20 $PROFILE

# The complete history of one of them
aws stepfunctions get-execution-history \
  --execution-arn arn:aws:states:eu-west-1:111122223333:execution:mercadofresco-procesar-pedido:PED-2026-084417 \
  --reverse-order --max-items 40 $PROFILE

A real failed execution. Marta sees an execution in FAILED and walks back through the history:

  1. ExecutionFailed with Error: "PedidoNoPreparable" → it ended in PedidoCompensado, so the compensation worked. A good sign: the customer has their money back.
  2. TaskStateExited for DevolverCobro and for LiberarStock → both compensations ran.
  3. TaskFailed in PrepararEnAlmacen with Error: "States.Heartbeat"it was not a rejection from the warehouse, it was a missing heartbeat. The terminal stopped sending SendTaskHeartbeat.
  4. TaskScheduled for PrepararEnAlmacen at 18:47, TaskFailed at 18:57 → exactly the 600 seconds of HeartbeatSeconds.

Diagnosis: this is not a business problem, it is that the warehouse terminal loses its wifi connection in the cold room and stops sending heartbeats. The fix is not in the state machine —which behaved correctly— but in the terminal, which must retry the heartbeat, and in raising HeartbeatSeconds to 900 to tolerate the known dropouts. Without the history, this diagnosis would have taken days.

With --tracing-configuration '{"enabled":true}', X-Ray (05-02) shows the execution's service map and where the time went: how much in the gateway, how much waiting for the warehouse, how much in Aurora. And the CloudWatch metrics to watch are ExecutionsFailed, ExecutionsTimedOut, ExecutionsAborted and ExecutionTime, all with an alarm towards alertas-mercadofresco.

One configuration detail: "level": "ERROR" logs only the failed states. "ALL" logs everything and is extremely useful while you develop, but in production, with 240,000 executions a month and includeExecutionData: true, the CloudWatch Logs volume can cost more than the machine itself.

Workflow Studio and cost

Workflow Studio is the console's visual editor: you drag states around, configure integrations from forms and watch the JSON being generated in real time, with validation on the fly. It is the best way to learn the language and to explore the SDK's 9,000 actions, and also to sketch the first draft of a workflow with Marta sitting next to you. For production, the definition lives in the repository and is deployed with CloudFormation or CDK (module 9): the visual editor is for designing and for reading, not for being the source of truth.

Cost. Standard workflows are charged per state transition: 4,000 free a month and then 0.025 USD per 1,000. mercadofresco-procesar-pedido goes through about 9 states per order on the happy path.

Scenario Transitions/month Cost
240,000 orders × 9 states (standard) 2,160,000 54.00 USD
Compensations (1.2 % × 4 extra states) 11,520 0.29 USD
mercadofresco-validar-carrito, express, 1.4 M executions of 200 ms and 64 MB ~2.20 USD
Daily reconciliation (DISTRIBUTED, 30 × 4,300 express children) ~1.10 USD

The standard/express comparison is revealing: the same process on express would cost around 3 USD instead of 54. Why not use express for everything, then? Because the order process lasts up to an hour (the express maximum is 5 minutes), it needs asynchronous waitForTaskToken, it needs exactly-once semantics so as not to charge twice, and it needs the 90-day history for complaints. The 51 USD of difference buy precisely that, and for 240,000 orders with an average amount of 48 € it is a negligible fraction of turnover.

The practical rule: express for the short, idempotent and high-volume; standard for the long, the critical and whatever has to be auditable.

aws stepfunctions delete-state-machine \
  --state-machine-arn arn:aws:states:eu-west-1:111122223333:stateMachine:mercadofresco-procesar-pedido $PROFILE
aws events remove-targets --rule regla-mf-arrancar-proceso-pedido \
  --event-bus-name bus-mercadofresco --ids maquina-procesar-pedido $PROFILE
aws events delete-rule --name regla-mf-arrancar-proceso-pedido \
  --event-bus-name bus-mercadofresco $PROFILE

Deleting a state machine does not cancel the executions in flight: they move to ABORTED when their current steps finish, and the ones waiting for a token are left orphaned.

Common Mistakes and Tips

Leaving ResultPath at its default in an intermediate Task. The result replaces the whole input and the next state cannot find its data. Symptom: States.Runtime with "could not resolve the path". Always set an explicit ResultPath, or null if the result does not matter.

Forgetting ResultPath in the Catch. The compensation state receives only the error and has no idea which order to compensate or which charge to refund. The most frustrating failure in this lesson.

Choice without Default. An unforeseen case ends in States.NoChoiceMatched and the execution dies without compensating anything.

Not telling transient errors from permanent ones in Retry. Retrying a TarjetaRechazada four times delays the answer to the customer and fixes nothing. Declare MaxAttempts: 0 for the permanent errors, and put them before the generic block.

Retry without JitterStrategy: "FULL". When the dependency recovers, every execution retries at the same time and knocks it over again.

Compensations without their own Catch. If the refund fails and there is no path to manual review, the customer is left with no order and no money, and nobody finds out.

Passing large payloads between states. The limit is 256 KB. A Map with 5,000 items blows up with States.DataLimitExceeded. Pass S3 references, not contents.

waitForTaskToken without HeartbeatSeconds or TimeoutSeconds. The execution sits there waiting for a year, occupying a slot without anybody noticing.

Using express for processes with non-idempotent effects. At-least-once semantics mean that a charge can be executed twice.

Parallel without a Catch per branch. One failing branch cancels the others, including those that were already halfway through.

Tip: name the execution with the business identifier. PED-2026-084417 as the execution name gives you idempotency for free, and searching in the console goes from impossible to instant.

Tip: validate the data flow with Pass before writing the logic. Build the whole machine with Pass states that return dummy data, check that the JSON travels correctly from one end to the other, and only then replace each Pass with its Task.

Tip: "level": "ALL" in development, "ERROR" in production. Full logging with execution data is extremely expensive at 240,000 executions a month.

Exercises

Exercise 1: the return of a delivered order

Design mercadofresco-procesar-devolucion for when a customer returns an order that has already been delivered. Steps: (1) validate that the return is within the time limit (48 h); (2) generate the pickup label by calling the courier's API; (3) wait for the courier to confirm the pickup, which can take up to 3 days; (4) when it reaches the warehouse, an operator inspects the condition of the product and decides to accept, partially accept or reject; (5) depending on the decision, refund the full amount, a partial amount or nothing; (6) restock only if it was accepted.

State: (a) standard or express and why; (b) which state type you use in each step; (c) how you model steps 3 and 4; (d) which Retry and which Catch you put in step 5; (e) which compensations you need and which you do not.

Exercise 2: tracing the data flow

Given this state and this input, write the exact output that the next state receives.

"ComprobarCliente": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke",
  "InputPath": "$.pedido",
  "Parameters": { "FunctionName": "mercadofresco-datos-cliente", "Payload": { "id.$": "$.cliente_id" } },
  "ResultSelector": { "nivel.$": "$.Payload.nivel_fidelidad", "email.$": "$.Payload.email" },
  "ResultPath": "$.cliente",
  "OutputPath": "$",
  "Next": "AplicarDescuento"
}

Input: {"pedido": {"pedido_id": "PED-1", "cliente_id": "CLI-9", "importe_eur": 30.0}, "origen": "web"}. Lambda returns {"nivel_fidelidad": "oro", "email": "ana@example.com", "telefono": "+34600000000"}.

Answer: (a) what exactly the Lambda receives; (b) the state's complete output; (c) what would happen if ResultPath were removed; (d) what would happen if OutputPath were "$.cliente"; (e) why InputPath does not affect where ResultPath inserts.

Exercise 3: choreography or orchestration

For each case, decide and justify in two sentences: (a) on confirming an order, four independent teams must be notified; (b) registering a supplier requires validating documents, approval from two people and a record in Aurora, with a 10-day deadline; (c) 8,000 catalogue photos must be resized every night; (d) on detecting StockBajo, an order must be placed with the supplier, its confirmation awaited and the expected date updated; (e) every price change must be recorded in Redshift for auditing.

Solutions

Solution 1

(a) Standard, without a doubt. Step 3 can last 3 days, far beyond the 5 minutes of express. On top of that there is money involved, so you need exactly-once semantics and the 90-day history for complaints.

(b) and (c) The states. (1) A Choice comparing $$.Execution.StartTime with the delivery date; if it is out of time, Fail with Error: "FueraDePlazoDevolucion" and nothing to compensate, since nothing has been done yet. (2) A Task with Retry and JitterStrategy: FULL to the courier's API. (3) A Task with .waitForTaskToken and TimeoutSeconds: 259200 (3 days): the courier's webhook calls SendTaskSuccess; a Wait would not do, since we know only the maximum deadline, and HeartbeatSeconds is not appropriate because the courier sends none. (4) Another Task with .waitForTaskToken to the warehouse queue, with TimeoutSeconds: 86400; the operator's decision arrives in the output of SendTaskSuccess and a later Choice routes on $.inspeccion.decision (aceptada/parcial/rechazada), with Default to manual review. (5) A Task for the refund with the calculated amount. (6) A Map per order line to restock.

(d) Retry and Catch for step 5. A generous Retry —6 attempts, IntervalSeconds: 10, BackoffRate: 2, MaxDelaySeconds: 300, JitterStrategy: FULL— because a failed refund is the worst thing that can happen here. Catch on States.ALL with ResultPath: "$.error" towards a RevisionManualDevolucion state that publishes to alertas-mercadofresco. Never a direct Fail: it would leave the customer with neither product nor money.

(e) Compensations. There are far fewer here than in the order process, and the reason is interesting: the flow goes from less to more committed, and the irreversible steps are at the end. The pickup label does need a compensation (cancelling it) if the return is aborted before the pickup. The inspection needs no compensation: it is a read. The refund is not compensated by charging again —that would be unacceptable—; if a later error is found, a manual incident is opened. And the restocking is compensated by removing it again if the product later turns out not to be fit. General lesson: ordering the steps from most reversible to least reversible reduces the number of compensations needed.

Solution 2

(a) What the Lambda receives. InputPath: "$.pedido" reduces the input to {"pedido_id": "PED-1", "cliente_id": "CLI-9", "importe_eur": 30.0}. On top of that, Parameters builds the payload, and "id.$": "$.cliente_id" is resolved against the result of InputPath, not against the original input. The Lambda receives exactly:

{ "id": "CLI-9" }

(b) Complete output. The integration's response is {"ExecutedVersion": "...", "Payload": {"nivel_fidelidad": "oro", "email": "ana@example.com", "telefono": "+34600000000"}, "StatusCode": 200}. ResultSelector trims it to {"nivel": "oro", "email": "ana@example.com"} —the phone number is dropped— and ResultPath: "$.cliente" inserts it into the complete original input, not the one trimmed by InputPath. With OutputPath: "$" everything goes out:

{
  "pedido": { "pedido_id": "PED-1", "cliente_id": "CLI-9", "importe_eur": 30.0 },
  "origen": "web",
  "cliente": { "nivel": "oro", "email": "ana@example.com" }
}

(c) Without ResultPath. The default value is $, so the result replaces the whole input. The output would be {"nivel": "oro", "email": "ana@example.com"} and AplicarDescuento would not find $.pedido.importe_eur, failing with a path resolution error. It is the language's most common mistake.

(d) With OutputPath: "$.cliente". The output would be only {"nivel": "oro", "email": "ana@example.com"}. The order is lost all the same, but for a different reason: it is not that the result replaced it, it is that it was trimmed at the end. It illustrates nicely that ResultPath and OutputPath act at different moments and can ruin the same thing by different routes.

(e) Why InputPath does not affect ResultPath. InputPath only determines what is passed to Parameters to build the call; the state keeps the original input internally, and it is into that input that ResultPath inserts. That separation is deliberate and very useful: it lets you send the service a reduced view without losing the accumulated context of the process.

Solution 3

(a) Choreography. Four independent parties interested in the same fact, with no dependencies and nothing to compensate: pure fan-out. SNS or EventBridge, depending on whether routing by content is needed.

(b) Orchestration. A long process (10 days), with dependent steps, two human interventions —waitForTaskToken twice over— and the need to know where each registration stands. With events you would have to invent a state table and a watcher, which is reimplementing Step Functions, badly.

(c) Neither of the two as classic orchestration: a distributed Map, or simply S3 → SQS → Lambda if no control over the set is needed. These are 8,000 independent, idempotent tasks; what is needed is parallelism and tolerance of partial failures, not a stateful workflow. If you also want to know when they all finished and with what error rate, the distributed Map with ToleratedFailurePercentage and ResultWriter is the right option.

(d) Orchestration. There is a wait of unknown duration (the supplier's confirmation) and chained steps. A standard machine started by the StockBajo rule, with waitForTaskToken for the confirmation and Wait/Choice to chase it up if the supplier does not reply within 24 hours.

(e) Choreography. A single fact that interests one consumer, with no dependencies and nothing to compensate. Event on bus-mercadofresco → queue → load into Redshift. Putting Step Functions here would only add cost per transition and one more piece to maintain.

Conclusion

MercadoFresco's order process now has an explicit owner. mercadofresco-procesar-pedido knows how to charge, reserve stock line by line with a Map, wait up to an hour for a person in the warehouse to confirm the box through a task token with heartbeats, assign delivery, publish PedidoProcesado on bus-mercadofresco and —what no choreography knew how to do— undo what has been done when something fails: release the stock, refund the charge and, if even the compensation fails, alert Marta through alertas-mercadofresco instead of dying in silence. The 90-day history turned a mystery ("why was this order compensated?") into four entries read from the bottom up: the warehouse terminal was losing its wifi in the cold room.

The ideas to take away: the choice between choreography and orchestration is not a matter of taste, it is dictated by the dependencies between steps and the need to compensate; standard versus express is decided by duration, execution semantics and the need for auditing, and the 51 USD of monthly difference buy exactly that; the data flowInputPath, Parameters, ResultSelector, ResultPath, OutputPath— is where most hours are lost, and the golden rule is an explicit ResultPath in every intermediate Task; Retry with jitter and classified errors stops the recovery of a dependency from knocking it over again; and every saga needs its path to manual review, because compensations fail too.

With this, MercadoFresco has the four integration pieces: queues to decouple, topics to hand out, a bus to route by content and workflows to orchestrate. But there are questions that cut across all four and that we have been postponing until now. What exactly does "at least once" mean when the thing being duplicated is a charge? How do you write a consumer that can receive the same message three times without charging three times? How many times should you retry, and when should you stop so as not to knock over the service that is recovering? What exactly do you do with the messages in a DLQ on a Monday morning? And how do you guarantee that an order written to Aurora always gets published, if there is no transaction spanning the database and the bus?

In 07-05, "Integration patterns", we close the module with the answers: delivery guarantees and idempotency with a deduplication table in DynamoDB, exponential backoff with jitter, a circuit breaker for the payment provider, the DLQ runbook, order and grouping, the outbox pattern for the dual write, backpressure, and a final decision table that answers once and for all the question "SQS, SNS, EventBridge, Step Functions or a synchronous call?".

© Copyright 2026. All rights reserved