Máquina de estados para el procesamiento de pedidos: API serverless en TypeScript sobre AWS Lambda, con DynamoDB y frontend en Astro
Servicio de gestión del ciclo de vida de pedidos mediante una máquina de estados, sobre AWS Lambda con Lambda Powertools, con una interfaz web de demostración.
| Herramienta | Versión | Comprobar con |
|---|
| Node.js | ≥ 22 | node --version |
| pnpm | ≥ 11 | pnpm --version |
| Docker | arrancado | docker info |
| AWS SAM CLI | ≥ 1.165 | sam --version |
| esbuild | ≥ 0.25, en el PATH | esbuild --version |
brew install aws-sam-cli esbuild pnpm
esbuildtiene que estar en el PATH del sistema: SAM lo invoca como binario externo y tenerlo ennode_modulesno basta.
El
.npmrcfijanode-linker=hoisted. El constructor de SAM para Node recorrenode_modulesa mano y, con el enlazado simbólico por defecto de pnpm, acaba en el binario nativo de esbuild dentro de.pnpm/e intenta ejecutarlo con Node. El enlazado plano lo evita.
No hace falta cuenta de AWS.
pnpm start
Levanta DynamoDB Local con sus tablas, la API y el frontend en una sola terminal. Comprueba prerrequisitos, instala dependencias si faltan, libera los puertos si están ocupados y espera a que ambos servicios respondan.
Frontend http://localhost:4330
API http://127.0.0.1:3010
DynamoDB localhost:8000
| Comando | |
|---|---|
pnpm start | Sirve el build del frontend, con CSP activa |
pnpm run start:dev | Recarga en caliente, sin CSP |
pnpm stop | Detiene la API y el frontend |
Ctrl+C detiene todo. Los scripts viven en scripts/ y también se pueden invocar directamente.
# 1. base de datos (primera vez o tras reiniciar Docker)
pnpm install && cd web && pnpm install && cd ..
pnpm run db:start
# 2. API
pnpm run dev # http://127.0.0.1:3010
# 3. frontend
cd web && pnpm run dev # http://localhost:4330
ID=$(curl -s -X POST http://127.0.0.1:3010/orders \
-H 'Content-Type: application/json' \
-d '{"productIds":["SKU-1","SKU-2"],"amount":1250.00}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -s http://127.0.0.1:3010/orders/$ID
curl -s -X POST http://127.0.0.1:3010/orders/$ID/events \
-H 'Content-Type: application/json' \
-d '{"eventType":"paymentFailed","metadata":{"gatewayCode":"51"}}'
curl -s http://127.0.0.1:3010/orders/$ID/events
Se usan 3010 y 4330 en lugar de 3000 y 4321, que suelen estar ocupados. El puerto del frontend debe coincidir en cinco sitios, porque el CORS de la API lo valida: web/astro.config.mjs, web/package.json, infrastructure/template.yaml, src/handlers/error-mapper.ts y env.local.json.
src/
├── domain/ Sin dependencias externas
│ ├── order.ts Entidad Order, los 11 estados, límites
│ ├── events.ts Los 15 eventos, TransitionRecord, SupportTicket
│ ├── transitions.ts Tabla declarativa + expansión del comodín
│ └── errors.ts Errores de dominio tipados
│
├── services/
│ ├── state-machine.ts Motor: transition() y allowedEvents(), puras
│ ├── order.service.ts Los 4 casos de uso
│ ├── ports.ts Reloj e identificadores, inyectables
│ └── event-handlers/ Lógica por evento, punto de extensión
│
├── repositories/
│ ├── *.repository.ts Interfaces
│ ├── in-memory/ Implementación en memoria
│ ├── dynamodb/ Implementación DynamoDB
│ └── retry.ts Backoff exponencial con jitter
│
├── handlers/ 4 funciones Lambda, esquemas Zod,
│ mapeo de errores, idempotencia, Powertools
└── config/container.ts Composición de dependencias
tests/
├── unit/ Ejemplos + fronteras arquitectónicas
├── property/ Property-based testing
└── integration/ Contra DynamoDB Local
web/ Frontend
infrastructure/template.yaml AWS SAM
scripts/
├── start.sh Arranca el proyecto completo
└── db.sh DynamoDB Local + tablas
Pending · OnHold · PendingPayment · Confirmed · Processing · Shipped · Delivered · Returning · Returned · Refunded · Cancelled
| Origen | Evento | Destino |
|---|---|---|
| (ninguno) | creación | Pending |
Pending | pendingBiometricalVerification | OnHold |
Pending | noVerificationNeeded | PendingPayment |
Pending | paymentFailed / orderCancelled | Cancelled |
OnHold | biometricalVerificationSuccessful | PendingPayment |
OnHold | verificationFailed | Cancelled |
PendingPayment | paymentSuccessful | Confirmed |
Confirmed | preparingShipment | Processing |
Processing | itemDispatched | Shipped |
Shipped | itemReceivedByCustomer | Delivered |
Shipped | deliveryIssue | OnHold |
Delivered | returnInitiatedByCustomer | Returning |
Returning | itemReceivedBack | Returned |
Returned | refundProcessed | Refunded |
| cualquiera* | orderCancelledByUser | Cancelled |
* Excepto Delivered, Returned, Refunded y Cancelled.
La tabla se declara con 14 entradas explícitas más una regla comodín, y una función pura la expande a 21 entradas efectivas al cargar el módulo. Así la intención queda en un solo sitio y el motor consulta una estructura totalmente explícita, sobre la que el compilador y los property-based tests pueden razonar. Una entrada explícita siempre prevalece sobre la generada por el comodín.
Delivered y Returned no son estados terminales: tienen salida hacia el flujo de devolución. Solo son terminales respecto a la cancelación. Los únicos sin salida son Refunded y Cancelled.
Al recibir paymentFailed, si el importe es estrictamente mayor que 1000 USD se crea un ticket para revisión manual. El enunciado dice "greater than 1000 USD", así que 1000.00 exacto no lo genera. Cubierto por tests en 999.99, 1000.00 y 1000.01.
El ticket copia el importe en lugar de referenciarlo: documenta un hecho de un instante concreto.
| Método | Ruta | |
|---|---|---|
POST | /orders | 201 |
POST | /orders/{orderId}/events | 200 |
GET | /orders/{orderId} | 200, estado + eventos admisibles |
GET | /orders/{orderId}/events | 200, historial |
| Código | Situación |
|---|---|
400 | Payload inválido, eventType inexistente, orderId no UUID, campo desconocido |
404 | El orderId no existe |
409 | El evento existe pero no es aplicable desde el estado actual |
La distinción entre 400 y 409 es deliberada: 400 significa que el evento no existe en el sistema; 409, que existe pero no desde ese estado. Son dos acciones distintas para el cliente.
El 409 incluye currentState y allowedEvents:
{
"error": "INVALID_TRANSITION",
"message": "El evento 'itemDispatched' no es valido desde el estado 'Pending'",
"currentState": "Pending",
"allowedEvents": ["noVerificationNeeded", "paymentFailed", "orderCancelled"]
}
La máquina de estados es pública, así que devolverlos no filtra nada y ahorra al cliente tener que duplicarla.
Los cuerpos de petición rechazan campos desconocidos, lo que impide inyectar state, version o id al crear. Los límites de tamaño (100 productos, 64 caracteres, 8 KB de metadata) son endurecimiento: metadata se persiste en el log de auditoría y sin cota sería un vector de agotamiento.
Una sola tabla DynamoDB, con orderId como clave de partición para que el pedido y su auditoría compartan partición y puedan escribirse en una única TransactWriteItems.
| Ítem | PK | SK |
|---|---|---|
| Estado del pedido | ORDER#<orderId> | #STATE |
| Transición | ORDER#<orderId> | EVENT#0000000001 |
| Ticket de soporte | TICKET#<ticketId> | #TICKET (vía GSI1) |
El sequence va acolchado a 10 dígitos: sin acolchar, el orden lexicográfico de DynamoDB pondría EVENT#10 antes que EVENT#9. El log de auditoría emerge del propio modelo, sin tabla aparte.
Concurrencia optimista. saveWithTransition escribe con ConditionExpression: version = :expected. Si otra ejecución modificó el pedido entre la lectura y la escritura, la transacción se cancela y se traduce a 409. Esto serializa las escrituras sobre un mismo pedido sin bloqueos; pedidos distintos no compiten, porque la clave de partición los aísla. Hay un test de integración con dos escrituras simultáneas: una gana, la otra recibe el error.
Idempotencia. ApplyEvent usa la utilidad Idempotency de Powertools, con la clave derivada de orderId más el cuerpo de la petición, no del evento completo: ese incluye el requestId de API Gateway, que cambia en cada intento.
Derivar la clave del cuerpo tiene una consecuencia que conviene conocer: durante la ventana de una hora, repetir una petición idéntica devuelve la respuesta guardada en vez de reevaluar la transición. Sobre un pedido ya entregado, reenviar paymentSuccessful responde 200 con el estado que tenía entonces, aunque el pedido no cambia y sigue en Delivered. Un evento distinto que no encaje sí se rechaza con 409. En producción la clave debería venir del cliente en una cabecera Idempotency-Key, que es lo que distingue un reintento de una petición nueva; con la clave derivada del cuerpo, el servidor no puede diferenciarlos.
API Gateway REST
|
+--------+--------+--------+---------+
| | | |
Create Apply Get GetOrder <- una Lambda por caso de uso
Order Event Order Events
| | | |
+--------+--------+--------+
|
OrderService
|
+--------+--------------+
| | |
StateMachine EventHandler Repositorios
(puro) Registry (interfaces)
|
+-----+------+
InMemory DynamoDB
| Capa | Responsabilidad | No conoce |
|---|---|---|
| Handlers | Parseo, validación de forma, mapeo a HTTP | Lógica de negocio |
| Services | Motor de estados, casos de uso, lógica por evento | HTTP, AWS, persistencia |
| Repositories | Integración con sistemas externos | Casos de uso |
El motor es una función pura: sin efectos, sin persistencia, sin reloj. Eso permite property-based testing sin dobles de prueba y garantiza el mismo comportamiento con cualquier repositorio. El reloj y el generador de identificadores se inyectan, para que los casos de uso sean deterministas en los tests.
Orden al aplicar un evento: validar la transición antes de escribir, persistir estado y auditoría juntos, y ejecutar los efectos secundarios después de persistir. Si se ejecutaran antes y la persistencia fallara, existiría un ticket para una transición que nunca ocurrió.
Extensibilidad. Añadir una regla de negocio por evento requiere crear un fichero en src/services/event-handlers/ y registrarlo en index.ts. No se toca el motor, la tabla, el servicio ni los handlers. Hay un test que registra una regla nueva sin tocar nada de eso.
Fronteras verificadas. tests/unit/architecture.test.ts falla la build si src/domain/ importa de otra capa, o si dominio o servicios importan de aws-*.
Astro con islas de React y Tailwind, en web/, con arquitectura limpia y patrón repositorio:
components/ Componentes puros, sin hooks ni fetch ni estado
v
useOrderController Estado, orquestación, mensajes de error
v
OrderService Casos de uso, sin fetch ni HTTP
v
OrderRepository Interfaz -> HttpOrderRepository
v
domain/ Tipos y tabla importados del backend
Hay un solo hook. Los tres componentes son funciones puras de sus props; la página llama al controlador una vez y reparte props. web/tests/architecture.test.ts falla la build si un componente usa hooks o fetch, o si aparece un segundo controlador.
El desplegable de transiciones se rellena con el allowedEvents que devuelve la API, no con una copia de la tabla. El diagrama, que sí necesita la topología completa, importa la tabla del backend mediante alias de TypeScript.
| Componente | |
|---|---|
| Formulario de creación | Campos productIds y amount; muestra el orderId y el estado inicial |
| Visor de estado | Estado actual, transiciones admisibles, efectos aplicados e historial |
| Diagrama | Los 11 estados y sus transiciones, con el actual resaltado en tiempo real |

Una CSP estricta escrita a mano rompe la hidratación de las islas de Astro: la página se renderiza pero queda inerte y el formulario hace submit nativo. Se usa la CSP nativa de Astro, que calcula los hashes de sus propios scripts, lo que permite mantenerla estricta sin unsafe-inline en script-src.
Dos detalles que cuestan tiempo: la clave de configuración es security.csp, no csp en el nivel superior. Astro ignora en silencio las claves desconocidas, así que la página se sirve sin ninguna política. Y la CSP se inyecta en el build, no en el dev server, así que hay que verificarla sobre el build servido: cd web && pnpm run build && pnpm run preview.
HSTS y X-Frame-Options solo funcionan como cabecera HTTP real; en un despliegue van en el CDN.
pnpm test # 123 tests
pnpm run test:unit
pnpm run test:property
pnpm run test:integration # requiere DynamoDB Local
pnpm run test:coverage
cd web && pnpm test # 18 tests
| Tests | 141 |
| Cobertura de líneas y funciones (dominio + servicios) | 100% |
| Cobertura de ramas | 96,9% |
La cobertura se acota a src/domain/ y src/services/; extenderla a handlers y adaptadores incentivaría tests de bajo valor. Los tests de integración se omiten solos si DynamoDB Local no está levantado.
Property-based testing con fast-check sobre el motor y los round-trips de serialización. Verifica que todo estado alcanzado esté declarado, que ningún evento transicione desde un estado sin salida, que toda secuencia válida termine en un estado alcanzable y que allowedEvents coincida con los eventos que no lanzan.
La semilla se registra en cada ejecución para poder reproducir cualquier corrida:
[fast-check] semilla: 1342814084 -> reproducir con FC_SEED=1342814084 pnpm test
El generador de importes incluye 999.99, 1000.00 y 1000.01 de forma explícita: un generador uniforme sobre reales prácticamente nunca produciría exactamente el valor del umbral.
Step Functions. Lo valoré, pero el ejercicio pide implementar la máquina de estados, no delegarla en un servicio gestionado.
Una Lambda por caso de uso. GetOrder solo necesita lectura; ApplyEvent necesita lectura y escritura condicional. Con una función monolítica compartirían el permiso más amplio.
Validación con Zod en el código, no con modelos de API Gateway. Un único lugar de validación, tipos derivados con z.infer, y mensajes de error que respetan el formato uniforme de la API.
Un efecto secundario que falla no revierte la transición. Si paymentFailed es válido pero crear el ticket falla, la transición permanece aplicada: el evento comunica un hecho que ya ocurrió, y negarse a registrarlo porque el sistema de tickets esté caído dejaría el pedido contradiciendo la realidad. El fallo se registra y se emite una métrica. En validación, transiciones inválidas y errores de persistencia sí se aborta la operación.
amount como float. El enunciado lo especifica así y se respeta. En un sistema real sería un entero en la unidad mínima de la divisa; aquí el importe nunca se opera aritméticamente, solo se compara una vez contra un umbral.
nodejs24.x sobre arm64. nodejs20.x está deprecado desde abril de 2026 y nodejs22.x se depreca en abril de 2027. Graviton es más económico y no hay binarios nativos que dependan de la arquitectura.
orderCancelled y orderCancelledByUser se mantienen como eventos distintos, tal como los enumera el enunciado. La diferencia se conserva en el log de auditoría.404.| Validación de entrada con cotas y rechazo de campos desconocidos | Zod en los cuatro handlers |
| Errores genéricos al cliente | Sin trazas de pila ni detalles internos |
| CORS | Origen explícito, nunca comodín |
| Throttling | 50 req/s, ráfaga 100 |
| IAM | Mínimo privilegio por función, sin comodines |
| Cifrado en reposo y point-in-time recovery | En ambas tablas |
| Retención de logs | 90 días, declarada explícitamente |
| Integridad del log | Las funciones no pueden borrar sus propios grupos |
| Datos sensibles | Se registran las claves de metadata, no sus valores |
| Auditoría | Entradas inmutables (attribute_not_exists(SK)) |
| Dependencias | pnpm audit sin vulnerabilidades |
Los grupos de logs se declaran de forma explícita porque, sin declararlos, CloudWatch los crea con retención indefinida.
Sin autenticación, cualquiera con un orderId válido puede aplicar eventos sobre un pedido ajeno. Las mitigaciones actuales son parciales: identificadores UUID v4, throttling y CORS restringido. En un sistema real se añadiría un autorizador JWT en API Gateway y comprobación de propiedad en el servicio.
| Síntoma | Solución |
|---|---|
Cannot find esbuild en sam build | brew install esbuild. SAM lo invoca como binario del host |
404 al consultar un pedido recién creado | Se está usando STORAGE=memory; cada Lambda tiene su propio contenedor y no comparten memoria. Usar STORAGE=dynamodb |
ResourceNotFoundException o todo falla tras reiniciar Docker | DynamoDB Local usa -inMemory y pierde datos y tablas. pnpm run db:start |
UnrecognizedClientException | DYNAMODB_ENDPOINT no llegó al contenedor. sam local --env-vars solo inyecta variables declaradas en la plantilla |
Runtime.ImportModuleError | Se arrancó contra la plantilla fuente. Usar pnpm start, que toma la construida |
| Error de CORS en el navegador | El puerto del frontend no coincide con AllowedOrigin |
El formulario recarga con ?productIds= en la URL | React no hidrató, normalmente por una violación de CSP |
astro check se cuelga | Usar pnpm run build, que verifica tipos y compila |
Nota: no añadir claves de comentario a env.local.json. SAM trata cada clave de nivel superior como un nombre de función.
Uso privado. No redistribuir.