Backend flow

Escopo: transversal. Aplica-se a qualquer linguagem ou stack do projeto.

Três fluxos cobrem a maior parte da lógica assíncrona de backend: background job (trabalho em segundo plano), webhook (notificação HTTP acionada por evento externo) e event-driven (orientado a eventos). Os três seguem o mesmo princípio: aceitar, persistir e processar fora do ciclo de request/response. Esta página complementa operation-flow.md, que cobre o ciclo síncrono.

Conceitos fundamentais

ConceitoO que é
Background job (tarefa em segundo plano)Trabalho desacoplado do ciclo de request/response, executado de forma assíncrona
Worker (processo trabalhador)Processo que consome e executa jobs de forma independente
Outbox pattern (padrão de caixa de saída)Garantia de atomicidade entre persistência no banco e publicação no broker, usando a mesma transação
Relay (processo de retransmissão)Processo que lê registros pendentes do outbox e publica no broker com retry
Broker (intermediário de mensagens)Serviço que recebe, armazena e distribui mensagens entre producers e consumers
DLQ (Dead-letter queue, fila de mensagens com falha persistente)Fila de isolamento para mensagens que falharam repetidamente
SSE (Server-Sent Events, Eventos Enviados pelo Servidor)Entrega unidirecional de eventos do servidor ao browser sobre HTTP
HMAC (Hash-based Message Authentication Code, Código de Autenticação de Mensagem Baseado em Hash)Mecanismo que valida a origem e integridade de um webhook
Publisher (publicador)Quem emite um evento de domínio para o broker
Subscriber (assinante)Quem consome e processa eventos do broker de forma independente

Background Job

Um job (tarefa assíncrona) desacopla o aceite de trabalho da sua execução. A API (Application Programming Interface, Interface de Programação de Aplicações) aceita a requisição, persiste o job, responde 202, e o worker (processo trabalhador) executa de forma independente.

HTTP Request → valida input → persiste job → 202 AcceptedWorker dequeue → executa → armazena resultado → notifica

O 202 Accepted (Aceito) é o contrato: "recebi, execução está agendada". A resposta não espera a conclusão do job.

Outbox pattern

O job precisa ser persistido antes do 202 ser retornado. Se a aplicação reiniciar após responder mas antes de enfileirar o job, o trabalho é perdido silenciosamente.

Quando a fila é externa ao banco principal (Kafka, SQS (Simple Queue Service, Serviço Simples de Filas), RabbitMQ), o problema se aprofunda. Commit no banco e publicação na fila são dois sistemas distintos, sem garantia de atomicidade (atomicity, execução como unidade indivisível).

O outbox pattern (padrão de caixa de saída) resolve isso tornando a publicação parte da mesma transação do banco:

BEGIN;
  INSERT INTO orders (id, customer_id, total) VALUES (?, ?, ?);
  INSERT INTO outbox (event_type, payload, published) VALUES ('order.placed', ?, false);
COMMIT;

Um relay (processo de retransmissão) separado lê os registros não publicados do outbox, publica no broker (intermediário de mensagens) e marca como enviado. O commit no banco e a intenção de publicar são atômicos; o relay entrega com retry (retentatva).

Quando a fila de jobs é o banco principal (PostgreSQL com pgboss, por exemplo), o outbox está implícito na ferramenta. O pattern só é necessário explicitamente quando banco e broker são sistemas distintos.

Idempotência do job

O worker deve ser seguro para re-executar o mesmo job mais de uma vez. Redes distribuídas entregam mensagens ao menos uma vez (at-least-once delivery, entrega ao menos uma vez). Duplicatas são inevitáveis.

O padrão é uma idempotency_key única na tabela de jobs:

CREATE TABLE jobs (
  id UUID PRIMARY KEY,
  idempotency_key VARCHAR UNIQUE,
  status VARCHAR,
  payload JSONB,
  result JSONB,
  created_at TIMESTAMP,
  processed_at TIMESTAMP
);

O worker verifica antes de executar: se a chave já existe com status done, retorna o resultado em cache sem reprocessar. A constraint (restrição) UNIQUE é a proteção contra race condition (condição de corrida) quando dois workers dequeuam o mesmo job ao mesmo tempo. O que perder o INSERT recebe um erro de violação e aborta sem efeito colateral.

Entrega do resultado

ModeloQuando usar
Polling (GET /jobs/{id})Cliente sem endpoint público, duração curta e previsível (segundos)
Webhook (POST <url-do-cliente>)Cliente expõe HTTPS, duração longa (minutos, horas), integrações B2B
SSE (Server-Sent Events, Eventos Enviados pelo Servidor)Cliente é browser, entrega unidirecional em tempo real, duração moderada
WebSocketComunicação bidirecional em tempo real, custo e complexidade mais altos

SSE substituiu WebSocket na maioria dos casos de entrega de status unidirecional: funciona sobre HTTP (HyperText Transfer Protocol, Protocolo de Transferência de Hipertexto)/2 padrão, sem infraestrutura adicional para balanceamento de carga.


Webhook

Webhook é um job de entrada: o sistema recebe um evento de um parceiro externo, confirma o recebimento imediatamente, e processa de forma assíncrona.

POST /webhooks/{provider} → captura raw body → valida HMAC → checa idempotência → 200 OK → enfileira → processa

Duas regras sem exceção:

  1. Responder 200 antes de processar. Provedores como Stripe e GitHub fazem retry se não receberem 200 em 5–30 segundos. Processar dentro do handler cria latência, falhas e tempestades de retry (retentativas repetidas).
  2. Validar HMAC antes de qualquer lógica de negócio. A assinatura confirma a origem. Sem validação, qualquer cliente pode forjar eventos.

Validação HMAC

O HMAC (Hash-based Message Authentication Code, Código de Autenticação de Mensagem Baseado em Hash) é o mecanismo que confirma a origem de um webhook. O provedor assina o payload (corpo da mensagem) com um segredo compartilhado. O receptor recalcula a assinatura com o mesmo segredo e compara. Se bater, a mensagem veio de quem diz ser e não foi alterada no caminho.

O cálculo é feito sobre o raw body (corpo bruto da requisição), antes do parse (interpretação) do JSON (JavaScript Object Notation, Notação de Objetos JavaScript). Frameworks que fazem parse automático do body antes do middleware (componente de pipeline) executar invalidam o cálculo. O webhook handler precisa receber o stream bruto diretamente.

A comparação usa timingSafeEqual para evitar timing attack (ataque de temporização):

❌ Ruim: valida sobre JSON serializado, comparação vulnerável a timing attack
async function handleWebhook(request) {
  const body = await request.json();
  const receivedSignature = request.headers.get("x-signature");

  const expectedSignature = computeHmac(webhookSecret, JSON.stringify(body));

  if (expectedSignature !== receivedSignature) {
    return unauthorizedResponse;
  }

  await processWebhookPayload(body);

  return acceptedResponse;
}
✅ Bom: valida sobre raw body, comparação timing-safe
async function handleWebhook(request) {
  const rawBody = await request.text();
  const receivedSignature = request.headers.get("x-signature") ?? "";

  const expectedSignature = computeHmac(webhookSecret, rawBody);
  const isSignatureValid = timingSafeEqual(expectedSignature, receivedSignature);

  if (!isSignatureValid) {
    return unauthorizedResponse;
  }

  await enqueueWebhookProcessing(rawBody);

  return acceptedResponse;
}

Idempotência por chave externa

Todo provedor envia um ID único no header: X-Stripe-Event, X-GitHub-Delivery. Esse ID é a chave de idempotência. Antes de enfileirar, verifica se o evento já foi recebido:

INSERT INTO webhook_deliveries (event_id, provider, payload)
VALUES (?, ?, ?)
ON CONFLICT (event_id) DO NOTHING;

Zero linhas afetadas: evento duplicado. Retornar 200 silenciosamente. O provedor não precisa saber; ele só quer confirmação de recebimento.

Roteamento de eventos

O processador roteia o evento pelo tipo usando um registry (registro de handlers), não um switch crescente:

✅ Bom: registry de handlers por tipo de evento
const eventHandlers = {
  "payment.succeeded": handlePaymentSucceeded,
  "payment.failed": handlePaymentFailed,
  "customer.created": handleCustomerCreated,
};

async function dispatchWebhookEvent(event) {
  const eventType = event.type;
  const handler = eventHandlers[eventType];

  if (!handler) {
    logUnhandledEventType(eventType);
    return;
  }

  const eventPayload = event.data;
  await handler(eventPayload);
}

Tipos de evento desconhecidos são logados, não rejeitados. Provedores adicionam novos tipos; o sistema ignora o que não conhece sem errar.


Event-Driven

No modelo event-driven (orientado a eventos), o publisher (publicador) emite um evento de domínio para um broker. Subscribers (assinantes) independentes consomem e processam sem conhecer o publisher.

Publisher emite evento → Broker (tópico/fila)Subscriber consome → processa → ack → broker remove
                                                                    ↓ falha N vezes
                                                                 DLQ → alerta → revisão manual

Dead-letter queue

A DLQ (Dead-letter queue, fila de mensagens com falha persistente) é obrigatória. Sem ela, uma mensagem que falha repetidamente bloqueia o consumer group (grupo de consumidores) inteiro.

O fluxo padrão:

  • Retry (retentatva) com backoff exponencial (espera crescente entre tentativas), tipicamente 3–5 tentativas
  • Após esgotar as tentativas, a mensagem vai para a DLQ
  • Qualquer mensagem na DLQ dispara alerta. DLQ silenciosa é lixeira de perda de dados

A mensagem na DLQ deve preservar: payload original, número de tentativas, último erro e timestamp do evento. Sem esse contexto, mensagens mortas são indebuggáveis.

Entrega at-least-once

Entrega exactly-once (exatamente uma vez) é possível em Kafka e SQS FIFO, mas exige infraestrutura transacional com overhead (custo extra) de 10–30% de throughput. Na prática, at-least-once com consumer idempotente entrega a mesma garantia com menos complexidade.

✅ Bom: consumer verifica idempotência antes de processar
async function consumeEvent(event) {
  const alreadyProcessed = await findProcessedEvent(event.id);

  if (alreadyProcessed) {
    return;
  }

  await processEvent(event.data);
  await markEventAsProcessed(event.id);
}

A escrita em processed_events e a operação de negócio devem estar na mesma transação de banco quando possível. Duplicatas chegam. O sistema precisa tolerá-las sem efeito colateral.

Envelope de evento

O CloudEvents v1.0 (especificação aberta mantida pela CNCF, Cloud Native Computing Foundation) é o padrão de envelope (estrutura de empacotamento de evento) adotado pelos principais cloud providers e ecossistemas:

{
  "specversion": "1.0",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "source": "/orders-service",
  "type": "com.company.orders.placed",
  "time": "2026-04-21T14:32:00Z",
  "datacontenttype": "application/json",
  "data": {
    "orderId": "ord_123",
    "customerId": "cust_456",
    "total": 9900
  }
}
CampoPropósito
idChave de idempotência para consumers
sourceServiço publicador, habilita roteamento e debugging
typeTipo reverse-DNS, evita colisões entre serviços
timeHora do evento, não do processamento. Essencial para ordering (ordenação) e replay (reprocessamento)
dataPayload (carga útil) de negócio. Mínimo necessário, sem IDs internos expostos a consumers externos

Campos desconhecidos são ignorados. Producers versionam pelo campo type (orders.placed.v2). Deploys sincronizados para adicionar um campo são anti-pattern.

Outbox como ponte

O outbox pattern é a ponte entre o banco transacional e o broker de eventos. Resolve o problema de dual-write (escrita dupla): commit no banco e publicação no broker são sistemas distintos. Sem atomicidade, qualquer falha entre eles cria inconsistência.

AbordagemProblema
Commit no banco → publica no brokerSe a publicação falhar, evento perdido
Publica no broker → commit no bancoSe o commit falhar, evento fantasma publicado
Commit inclui linha no outbox → relay publicaIntenção e dado são sempre consistentes

O relay lê o outbox e publica com retry.


Veja também

Desenvolvido por @thiagocajadev · Fork baseado no repositório pmndrs/docs · Poimandres.