// ESTUDO · SAGA PATTERN ORQUESTRADO · OPEN SOURCE

NodeOrderSaga

Uma API Express recebe pedidos, um worker BullMQ os processa por uma fila e orquestra um Saga pattern simplificado com compensação real — em vez de transação distribuída, cada etapa é local e, se falhar, é desfeita. Tudo 100% Node.js, com deploy testado em Kubernetes.

OPEN SOURCE NODE.JSBULLMQ / REDISSAGA PATTERNKUBERNETESSSE

Motivação do estudo

O objetivo do NodeOrderSaga é entender, na prática, como orquestrar um fluxo de processamento assíncrono que pode falhar no meio do caminho — e o que fazer quando isso acontece. Processar pedidos envolve várias etapas (pagamento, estoque) que, numa arquitetura de microsserviços, não podem viver dentro de uma única transação de banco. A alternativa clássica é o Saga pattern: cada etapa é local e confirmada por conta própria, e se uma etapa mais adiante falhar, o orquestrador executa compensações — ações que desfazem o que já tinha sido feito — até o pedido chegar a um estado final consistente.

O estudo também é deliberadamente 100% Node.js de ponta a ponta: a API que recebe o pedido, o worker que consome a fila e processa a saga, e a ferramenta de build do frontend são todos Node. Isso isola a variável em estudo — orquestração de saga + fila + Kubernetes — sem misturar stacks diferentes, ao contrário de outros repositórios de referência deste autor que exploram .NET, Java ou C++ lado a lado.

O que foi feito

  1. API Express enxuta: três rotas de verdade — POST /orders cria o pedido (grava no Postgres já como PENDING e enfileira o job) e devolve 201 imediatamente, sem esperar o processamento; GET /orders/:id e GET /orders/:id/history consultam o estado atual e a linha do tempo completa de transições.
  2. Worker BullMQ como orquestrador da saga: um processo Node.js separado (própria imagem, próprio Deployment, sem porta exposta) consome a fila order-saga e conduz o pedido por uma máquina de estados sequencial — saga.js é uma função async simples que aguarda cada etapa e chama a compensação em caso de falha, sem indireção de coreografia desnecessária para só dois participantes (pagamento e estoque).
  3. Gatilho de falha determinístico: para poder demonstrar a compensação sob demanda (e não torcer por aleatoriedade), um pedido falha a reserva de estoque quando amount > 5000 ou sku = "OUT-OF-STOCK" — reproduzível por curl ou por um botão “preencher exemplo com falha” no frontend.
  4. Status ao vivo via SSE: cada transição de estado que o worker grava no Postgres também é publicada num canal Redis pub/sub (order:<id>:status); a API expõe isso como GET /orders/:id/events, um stream Server-Sent Events que o frontend consome para atualizar a linha do tempo em tempo real, sem polling.
  5. Histórico auditável: toda transição de status grava uma linha em order_events — o pedido em si guarda só o estado atual, mas a tabela de eventos preserva a sequência completa (inclusive as etapas de compensação) para inspeção posterior.
  6. Deploy real em Kubernetes: manifests Kustomize (k8s/base/, sem overlays) para os 5 componentes — Postgres, Redis, API, worker, frontend — testados de ponta a ponta contra um cluster kind/docker-desktop local, com as duas rotas da saga (sucesso e compensação) executadas via kubectl port-forward com os mesmos resultados do ambiente local em Docker Compose.

Como funciona

Visão geral do sistema

flowchart LR
    subgraph Browser["Navegador"]
        FE["Frontend React (Vite)"]
    end

    subgraph Cluster["API + Worker — Node.js"]
        API["API :8080 — Express\nPOST /orders\nGET /orders/:id\nGET /orders/:id/events (SSE)"]
        WORKER["Worker — BullMQ consumer\norquestra o Saga (saga.js)"]
    end

    PG[("PostgreSQL\norders / order_events")]
    REDIS[("Redis\nfila order-saga (BullMQ)\n+ pub/sub de status")]

    FE -- "POST /orders" --> API
    FE -- "SSE GET /orders/:id/events" --> API
    API -- "INSERT order (PENDING)" --> PG
    API -- "enfileira job {orderId}" --> REDIS
    WORKER -- "consome job" --> REDIS
    WORKER -- "UPDATE status + INSERT event" --> PG
    WORKER -- "publish order:id:status" --> REDIS
    REDIS -- "pub/sub" --> API

Saga: caminho feliz vs. compensação

sequenceDiagram
    participant FE as Frontend
    participant API as API (Express)
    participant Q as Redis (fila order-saga)
    participant W as Worker (Saga)
    participant DB as PostgreSQL

    FE->>API: POST /orders {customerName, sku, quantity, amount}
    API->>DB: INSERT order (status=PENDING)
    API->>Q: enqueue job {orderId}
    API-->>FE: 201 { id, status: PENDING }
    FE->>API: GET /orders/:id/events (abre SSE)

    Q->>W: job disponível
    W->>DB: UPDATE status=PAYMENT_PROCESSING
    W-->>API: publish (pub/sub)
    API-->>FE: evento SSE: PAYMENT_PROCESSING
    W->>DB: UPDATE status=PAYMENT_CONFIRMED
    API-->>FE: evento SSE: PAYMENT_CONFIRMED
    W->>DB: UPDATE status=STOCK_RESERVING
    API-->>FE: evento SSE: STOCK_RESERVING

    alt Caminho feliz — estoque disponível
        W->>DB: UPDATE status=STOCK_CONFIRMED
        API-->>FE: evento SSE: STOCK_CONFIRMED
        W->>DB: UPDATE status=COMPLETED
        API-->>FE: evento SSE: COMPLETED
    else Compensação — amount > 5000 ou sku OUT-OF-STOCK
        W->>DB: UPDATE status=STOCK_FAILED (failure_reason)
        API-->>FE: evento SSE: STOCK_FAILED
        Note over W: compensação: estornar o pagamento já confirmado
        W->>DB: UPDATE status=COMPENSATING_PAYMENT
        API-->>FE: evento SSE: COMPENSATING_PAYMENT
        W->>DB: UPDATE status=CANCELLED
        API-->>FE: evento SSE: CANCELLED
    end

A saga é orquestrada, não coreografada: com apenas dois participantes (pagamento e estoque), uma cadeia de serviços publicando e reagindo a eventos uns dos outros adicionaria indireção sem adicionar segurança. O worker é o único coordenador — saga.js aguarda cada etapa em sequência e, ao detectar a falha de estoque, chama explicitamente a compensação (COMPENSATING_PAYMENT) antes de fechar o pedido como CANCELLED, preservando o motivo da falha em failure_reason.

Modelo de dados

erDiagram
    ORDER ||--o{ ORDER_EVENT : "registra transições em"

    ORDER {
        uuid id PK
        string customer_name
        string sku
        int quantity
        numeric amount
        string status "PENDING..CANCELLED/COMPLETED"
        string failure_reason "preenchido só no caminho de compensação"
        timestamptz created_at
        timestamptz updated_at
    }
    ORDER_EVENT {
        bigint id PK
        uuid order_id FK
        string status "snapshot do status nesse instante"
        string note "ex.: Reservando estoque"
        timestamptz created_at
    }

A máquina de estados do pedido tem seis estados de caminho feliz e três de compensação:

PENDING → PAYMENT_PROCESSING → PAYMENT_CONFIRMED → STOCK_RESERVING → STOCK_CONFIRMED → COMPLETED
                                                            ↓ (falha de estoque)
                                                     STOCK_FAILED → COMPENSATING_PAYMENT → CANCELLED

Stack

  • ◆ BACKEND: Node.js + Express (API), Node.js + BullMQ (worker orquestrador da saga).
  • ⇄ FILA / MENSAGERIA: Redis — BullMQ (Streams) para a fila order-saga, pub/sub nativo para o fan-out de status ao vivo.
  • ▤ DADOS: PostgreSQL — tabelas orders (estado atual) e order_events (histórico completo).
  • ◈ FRONTEND: React + Vite, consumindo Server-Sent Events para a linha do tempo em tempo real.
  • ⌂ INFRA: Docker Compose (local) e Kubernetes via Kustomize (k8s/base/) — testado num cluster kind/docker-desktop real.

Como rodar

# Docker Compose — sobe Postgres, Redis, API, worker e frontend
docker compose up -d --build

# Frontend → http://localhost:18083
# API       → http://localhost:18080

# Caminho feliz
curl -X POST http://localhost:18080/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Alice","sku":"WIDGET-01","quantity":2,"amount":150}'

# Caminho de compensação (amount > 5000 ou sku OUT-OF-STOCK)
curl -X POST http://localhost:18080/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Bob","sku":"OUT-OF-STOCK","quantity":1,"amount":8000}'

# Acompanhar em tempo real
curl -N http://localhost:18080/orders/<id>/events
# Kubernetes — deploy num cluster kind/docker-desktop local (sem registry,
# imagens tagueadas :local + imagePullPolicy: IfNotPresent)
docker compose build
kubectl apply -k k8s/base
kubectl -n node-order-saga get pods
kubectl -n node-order-saga port-forward svc/api 30080:8080

# Código aberto

O NodeOrderSaga é open source, feito pra estudar e servir de referência de arquitetura.