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
Waitcan generate hundreds of thousands of transitions in one night. At the end you have the cleanup. Data is fictitious.
Contents
- Choreography versus orchestration
- Standard workflows and express workflows
- Amazon States Language: the structure
- The eight state types
- Data flow between states
- Integrations and service patterns
- The task token for the warehouse's human step
- Error handling:
RetryandCatch - The saga pattern: compensating what has already been done
mercadofresco-procesar-pedidoin full- Distributed
Mapfor Friday's volume - Deployment and starting from EventBridge
- Observability: debugging a failed execution
- Workflow Studio and cost
- Common mistakes and tips
- Exercises
- 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
Typeand, except for the terminal ones, aNextor"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:
InputPathis absent, so everything is taken ($).Parametersbuilds{"FunctionName": "mercadofresco-cobrar-pago", "Payload": {"pedido_id": "PED-2026-084417", "importe_eur": 48.20, "cliente_id": "CLI-30912"}}. Only that reaches Lambda.- Lambda responds, and the
lambda:invokeintegration wraps the response:{"ExecutedVersion": "$LATEST", "Payload": {"referencia": "PAY-77321", "estado": "capturado"}, "StatusCode": 200}. ResultSelector{"referencia_cobro.$": "$.Payload.referencia"}trims it down to{"referencia_cobro": "PAY-77321"}, throwing away the integration's noise.ResultPath"$.cobro"inserts that object into the original input under thecobrokey.OutputPathis 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 $PROFILEThe 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}
}]' $PROFILEA 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 $PROFILEA real failed execution. Marta sees an execution in FAILED and walks back through the history:
ExecutionFailedwithError: "PedidoNoPreparable"→ it ended inPedidoCompensado, so the compensation worked. A good sign: the customer has their money back.TaskStateExitedforDevolverCobroand forLiberarStock→ both compensations ran.TaskFailedinPrepararEnAlmacenwithError: "States.Heartbeat"→ it was not a rejection from the warehouse, it was a missing heartbeat. The terminal stopped sendingSendTaskHeartbeat.TaskScheduledforPrepararEnAlmacenat 18:47,TaskFailedat 18:57 → exactly the 600 seconds ofHeartbeatSeconds.
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 $PROFILEDeleting 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:
(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 flow —InputPath, 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?".
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
