Step Functions vs Lambda encadenada: cuándo un flujo de negocio necesita una máquina de estados explícita
Un patrón común cuando un flujo de negocio empieza como algo simple es encadenar funciones Lambda por eventos: una función termina, publica un mensaje en SNS o SQS, y ese mensaje dispara la siguiente función. Funciona bien mientras el flujo tiene dos o tres pasos y el camino feliz es casi siempre el que se ejecuta. El problema aparece cuando el flujo crece: más pasos, más ramas condicionales, y la necesidad real de saber en qué paso está un caso concreto sin tener que reconstruirlo leyendo CloudWatch Logs de cuatro funciones distintas.
Nos encontramos esta migración en un flujo de onboarding: validar datos, verificar contra un servicio externo, crear recursos en dos sistemas distintos y notificar al final. Empezó como cuatro Lambdas encadenadas por eventos de SQS. Terminó como una máquina de estados de Step Functions. El camino intermedio es lo que vale la pena repasar, porque no todo flujo encadenado necesita este cambio, y forzarlo donde no hace falta agrega una capa de indirección que no compra nada.
Cómo se ve la cadena antes de que duela
La primera versión del flujo de onboarding se veía así: la Lambda de validación recibe la solicitud, valida el payload, y si todo está bien publica un mensaje en una cola SQS que dispara la Lambda de verificación externa. Esa función llama a un servicio de terceros, y si la respuesta es positiva, publica en otra cola que dispara la Lambda de aprovisionamiento. Así hasta el final.
Cada función es simple, se despliega y se prueba de forma aislada, y el código de cada paso no sabe nada de los demás pasos. Esa independencia es la ventaja real de este patrón, y es genuina: para flujos cortos, con poca ramificación y donde un reintento simple (dejar que SQS reintente la entrega del mensaje) alcanza, encadenar por eventos sigue siendo la opción más simple de operar. No hay que aprender Amazon States Language, no hay un componente adicional en la arquitectura, y depurar un caso feliz es leer un log detrás de otro en orden.
Las señales de que la cadena ya no alcanza
Tres señales, en nuestro caso, coincidieron casi al mismo tiempo.
La primera fue el reintento por paso. SQS reintenta la entrega completa del mensaje cuando el consumidor falla, lo cual está bien cuando el paso es idempotente de principio a fin. Pero la Lambda de aprovisionamiento creaba un recurso en un sistema y luego otro en un segundo sistema; si el segundo fallaba, un reintento de SQS volvía a ejecutar la función completa, incluyendo la creación del primer recurso, que ya existía. La solución dentro del propio código era escribir chequeos de idempotencia manuales antes de cada efecto secundario -consultar si el recurso ya existe antes de crearlo-, lo cual es razonable como práctica en cualquier sistema distribuido, pero no resolvía el problema de fondo: no había forma de reintentar solo el segundo efecto secundario sin repetir el primero, porque desde la perspectiva de SQS y Lambda todo el handler es una sola unidad atómica de reintento.
La segunda fue la visibilidad del estado. Cuando un cliente de soporte preguntaba en qué paso estaba trabado el onboarding de un usuario específico, la respuesta salía de correlacionar un ID de solicitud a través de los logs de cuatro funciones distintas en CloudWatch Logs Insights, con la incomodidad adicional de que un mensaje perdido o una Lambda que fallaba antes de loguear nada dejaba un caso sin ningún rastro visible del paso en el que se había detenido.
La tercera fue la ramificación condicional. El flujo real no era lineal: si la verificación externa fallaba por un motivo transitorio (timeout del proveedor), el caso debía reintentarse más tarde; si fallaba por un motivo definitivo (documento inválido), debía notificarse al usuario sin tocar los pasos de aprovisionamiento. Codificar esa lógica de ramificación dentro de la Lambda de verificación, decidiendo a qué cola publicar según el tipo de error, funcionaba, pero movía la definición del flujo de negocio -qué pasa después de cada resultado posible- adentro del código de un paso individual, en vez de tenerla en un solo lugar donde se pudiera leer de un vistazo.
Lo que resuelve una máquina de estados explícita
Step Functions no cambia lo que hace cada Lambda; cambia quién orquesta la secuencia y quién guarda el estado entre pasos. La definición del flujo, en Amazon States Language, queda en un solo documento:
{
"StartAt": "ValidarSolicitud",
"States": {
"ValidarSolicitud": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:validar-solicitud",
"Next": "VerificacionExterna"
},
"VerificacionExterna": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:verificacion-externa",
"Retry": [
{
"ErrorEquals": ["ErrorTransitorio"],
"IntervalSeconds": 30,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{
"ErrorEquals": ["DocumentoInvalido"],
"Next": "NotificarRechazo"
}
],
"Next": "AprovisionarSistemaA"
},
"AprovisionarSistemaA": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:aprovisionar-sistema-a",
"Next": "AprovisionarSistemaB"
},
"AprovisionarSistemaB": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:aprovisionar-sistema-b",
"Next": "NotificarExito"
},
"NotificarExito": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:notificar-exito",
"End": true
},
"NotificarRechazo": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:notificar-rechazo",
"End": true
}
}
}
Los dos cambios que de verdad resolvieron los problemas que veníamos arrastrando están en el bloque Retry y en el bloque Catch del paso de verificación. El reintento con backoff exponencial ahora es declarativo y se aplica solo a ese paso: si AprovisionarSistemaA falla, Step Functions reintenta ese paso puntual según su propia configuración de Retry, sin volver a ejecutar ValidarSolicitud ni VerificacionExterna. El error transitorio y el error definitivo dejan de resolverse dentro del código de la Lambda de verificación y pasan a ser parte de la definición del flujo: cualquiera que lea el JSON entiende, sin abrir el código de la función, qué pasa cuando la verificación falla de cada forma.
La visibilidad de estado viene gratis: la consola de Step Functions muestra, para cada ejecución, en qué estado está o en cuál se detuvo, con el input y el output de cada paso. La pregunta de soporte sobre en qué paso está trabado un caso concreto se responde buscando la ejecución por su ID, no correlacionando logs de cuatro funciones.
Lo que no funcionó: migrar todo por consistencia
Después de esta migración probamos llevar el mismo patrón a un segundo flujo, uno de procesamiento de imágenes con dos pasos: redimensionar y subir a almacenamiento. La razón para migrarlo no era ninguna de las tres señales de arriba -no había ramificación condicional, el reintento de SQS alcanzaba porque cada paso era idempotente, y nadie preguntaba en qué paso estaba un caso individual porque el flujo tardaba segundos, no horas-. La migramos solo porque ya teníamos el patrón fresco y parecía más consistente tener todo bajo el mismo mecanismo de orquestación.
El resultado fue peor que la versión original: una máquina de estados de dos pasos que agregaba un componente más a desplegar y versionar, sin resolver ningún problema real que tuviéramos, y con una latencia extra por la orquestación de Step Functions entre paso y paso que en la cadena de eventos original no existía. Revertimos ese flujo a la cadena por eventos.
El costo real de cada enfoque
Los dos modelos de precio son distintos en su forma, no solo en su magnitud: Step Functions Standard cobra por transición de estado ejecutada, mientras que Lambda y SQS cobran por invocación, tiempo de ejecución y mensajes procesados. Para un flujo de muy alto volumen y pocos pasos, sumar transiciones de estado agrega un costo que la cadena de eventos no tiene. Para un flujo con lógica de reintento y ramificación condicional que de otra forma habría que escribir y mantener a mano dentro del código de cada Lambda, ese costo compite contra el costo de ingeniería de escribir y sostener esa lógica -no contra cero-. No es un cálculo que se resuelva mirando solo el precio por transición: hay que ponerlo al lado de cuánto cuesta, en tiempo de desarrollo y en incidentes, la lógica de estado que de otro modo vive dispersa en el código de cada paso.
El criterio que terminamos usando
Si un flujo es lineal o casi lineal, con reintentos que la propia cola resuelve y sin necesidad operativa de ver en qué paso está un caso puntual, lo dejamos como Lambdas encadenadas por eventos. En el momento en que aparece una de las tres señales -reintento que necesita aplicarse a un paso específico sin repetir los anteriores, ramificación condicional que define el flujo de negocio, o la necesidad real de que alguien fuera del equipo de desarrollo pueda ver en qué estado está un caso- migramos ese flujo puntual a Step Functions, sin migrar por arrastre los demás flujos que siguen sin tener esas señales.