Relato anonimizado. O nome do cliente fica de fora por conta do acordo de não concorrência — mas a arquitetura e os padrões de stack estão totalmente abertos para discussão.
O cenário
Um varejista multicanal LATAM de primeira linha. Quatro frentes de consumo convivendo ao mesmo tempo: mobile nativo (iOS e Android), web responsiva para o cliente, console administrativo para os operadores de loja e POS físico nas lojas-âncora. O estoque mora em dois mundos — um armazém online mais cerca de 150 lojas físicas, cada uma com seu pool de estoque próprio. Do lado do dinheiro, 4 adquirentes locais da LATAM somados a uma bandeira internacional de cartão. E, costurando tudo, um programa de fidelidade que acumula e resgata pontos em qualquer canal.
A stack que herdamos antes de mexer em nada: um monólito Rails com mais de 8 anos de estrada, 1M+ LOC, 40 e tantos engenheiros entregando dentro dele todos os dias, deploy de 30-min preso atrás de QA manual e um único Postgres onde as tabelas de cada time se misturavam sem cerimônia. No pico, ~250 orders/min gerando ~4 events/order para distribuir downstream.
O que estava pegando
A pressão para desacoplar vinha de três dores bem concretas.
A primeira era o teto de autonomia dos times. Junte 40 e tantos engenheiros a um único pipeline de deploy e o que sobra é contenção de deploy-lock: o mobile fica refém da fidelidade, e pagamentos não conseguem soltar a correção de um adquirente sem que mudanças do console admin entrem de carona. O lead-time-for-changes já tinha furado os 5 days de mediana — bem longe do tier de elite do DORA 2024, que fecha em < 1 day
.
A segunda era a fragmentação entre os canais. Cada frente quer uma forma de payload diferente: o mobile pede customer + inventory@store + loyalty-summary já pré-juntados por tela; o admin quer a árvore de entidades crua com os metadados de auditoria; o POS precisa de offline-first com sincronização eventual. Servir os três por uma única superfície de API só dá no menor denominador comum — e 3 dos 4 canais pagavam imposto de over-fetch em toda requisição.
A terceira, e a mais delicada, era a consistência transacional quando algo falha pela metade. Um order-placed se abre em payment-capture, inventory-reserve, loyalty-accrue, ship-label e warehouse-pick, e isso tem que ser tudo-ou-nada. Um timeout de adquirente às 03:00 BRT simplesmente não pode terminar com um pedido pago e o estoque nunca bloqueado.
As regras do jogo
- Checkout com
p99 ≤ 500msponta a ponta — é o limiar em que o usuário ainda sente a página do mobile como instantânea. - Queda de provedor de pagamento não pode virar erro na cara do cliente: a resposta é fazer retry e degradar com elegância.
- Um único trace ponta a ponta atravessando todos os serviços, para dar conta do replay de auditoria financeira — a reconciliação de adquirente de cartão exigida pelo Banco Central (Circular BACEN 3.978/2020 ).
- Evento perdido é inaceitável: entrega at-least-once via Outbox, com consumidores idempotentes do outro lado.
- Cada time de canal entrega no seu próprio ritmo, sem deploy-lock compartilhado.
- Escolha de linguagem com critério: TypeScript nos BFFs (tipos compartilhados entre front e back, mais um pool de contratação maior); Go no Broker e no Dispatcher (binário estático e
~5msde overhead por evento); e o monólito Rails sendo aposentado aos poucos, jamais num flag-day.
A arquitetura
fig. 2 — fluxo de eventos · BFF → Core → outbox → Broker → Kafka → Dispatcher
São seis peças que sustentam o desenho:
- BFF-por-canal. Cada time de canal cuida do seu próprio BFF enxuto, em TypeScript / NestJS, que agrega o Core no payload exato que aquele frontend pede — nada a mais. O mobile junta
customer + inventory@my-store + loyalty-summarynum único round-trip; o admin devolve a árvore de entidades crua com os metadados de auditoria; o POS roda offline-first sobre SQLite local e sincroniza ao reconectar. Cada BFF fica abaixo de< 15 KLOCe quem faz o deploy é o próprio time, na hora que quiser. - Serviços de Core. Orders, payments, inventory, loyalty, shipping — cada um dono do seu schema Postgres, sem nenhuma FK cruzando fronteiras. O contrato público é gRPC definido em protobuf entre Core e Core, e JSON/REST entre BFF e Core. A meta é manter cada serviço abaixo de
< 20 KLOC, no tamanho em que um único engenheiro ainda carrega o modelo inteiro na cabeça. - Tabela Outbox. Toda escrita do Core grava as linhas de domínio e a linha de outbox dentro da mesma transação:
BEGIN; UPDATE orders SET status='placed'; INSERT INTO outbox(event_id, type, payload, created_at); COMMIT;. A atomicidade é a garantia: se a escrita de domínio passou, o evento vai existir. - Broker. Um serviço em Go que faz polling de cada outbox a cada
200ms— ou se inscreve via replicação lógica do Postgres onde a latência de p99 aperta — e publica no Kafka compartition_key = order_id, mantendo a ordem dentro de cada pedido. E ele é o único componente com permissão de ler as tabelas outbox: publicação direta a partir do Core, nunca. - Dispatcher. Consumidor Go, totalmente stateless. Chama os serviços de Core downstream carregando
idempotency-key = event_id(UUIDv7). A política de retry é backoff exponencial de100ms → 30spor 8 tentativas e, esgotadas elas, manda para a DLQ. O estado de retry inteiro vive nos offsets do Kafka e num contador de tentativas no Redis com24h-TTL— o Dispatcher em si não guarda nada. - DLQ + replay UI. É onde o time de ops abre os payloads que falharam, reconstrói a cadeia inteira da saga pelo
trace_ide decide entre re-enfileirar ou marcar como resolvido. O replay mantém aidempotency-keyoriginal, então o downstream nunca processa duas vezes. Nenhum evento se perde de verdade.
Por que cada decisão saiu assim
Vale abrir quatro escolhas — e, em cada uma, deixar o trade-off na mesa.
Decisão 1: BFF-por-canal, e não um API gateway único.
Um gateway central (Kong
, AWS API Gateway
) centralizaria a operação e deixaria a história de deploy mais simples. Descartamos porque a dor real nunca foi roteamento — era formato de payload. Cada canal queria um conjunto de joins distinto, e um gateway só resolve isso a golpe de lógica de transformação customizada, que cedo ou tarde vira um BFF disfarçado. Com um BFF por canal, a fronteira entre times fica explícita e o mobile entrega 12×/week enquanto o admin entrega 2×/week, sem ninguém precisar combinar nada. O alicerce do padrão está no texto de BFF do Sam Newman
e no capítulo dedicado em Building Microservices, 2ª ed., cap.4, da O’Reilly
. Vale repensar quando o produto tem só 1 superfície de consumo — aí o BFF deixa de ser abstração e vira só peso morto.
Decisão 2: Outbox, e não publicação direta com dual-write.
O dual-write — a aplicação dá commit no banco e, num segundo passo, publica no Kafka — é uma fábrica de condição de corrida: basta o commit passar e o broker cair para o evento sumir em silêncio. O Outbox fecha essa brecha gravando o evento na mesma transação da escrita de domínio; o Broker publica a partir do outbox com entrega at-least-once, e o downstream deduplica pela idempotency-key. O preço é latência: a mediana de publicação sobe de ~5ms na via direta para ~120ms (intervalo de polling de 200ms mais o lag de replicação). Em troca, foram ~18 months de tráfego em produção com zero evento perdido. É o padrão que o Chris Richardson documenta em Microservices Patterns, cap.3
. O gatilho para revisitar o intervalo de polling — baixar para 50ms ou migrar de vez para replicação lógica — é o momento em que a latência de fan-out ponta a ponta começa a comer o orçamento de 500ms do checkout.
Decisão 3: Saga, e não commit em duas fases, para transação multisserviço.
Um order-placed encosta em 5 serviços (orders, payments, inventory, loyalty e shipping). O 2PC travaria os cinco durante o commit — desastre de disponibilidade no segundo em que um deles engasgar. A Saga resolve por outro caminho: uma sequência orquestrada de transações locais, cada uma com sua ação compensatória quando algo dá errado (o order-cancel reverte o payment-capture, libera o estoque bloqueado e estorna os pontos de fidelidade). O custo é que toda ação precisa de uma compensação desenhada à mão, o que praticamente dobra o esforço de design por workflow. Em compensação, a disponibilidade de cada serviço fica livre, e um 5xx de qualquer downstream apenas empurra a saga para o compensate — nunca trava o usuário. A base segue sendo o artigo original SAGAS de Garcia-Molina e Salem, SIGMOD 1987
, que ainda banca o assunto 39 anos depois. Só repensaria diante de invariante semântica de tempo real duro — aceitar lance num leilão ao vivo, por exemplo — onde consistência eventual não cabe.
Decisão 4: Strangler-fig, e não a reescrita big-bang.
O monólito Rails nunca foi reescrito num corte único. O caminho foi o padrão strangler-fig do Fowler
: código novo nascendo nos serviços de Core atrás dos BFFs, código velho seguindo vivo no monólito, e as rotas migrando feature a feature atrás de feature-flags ao longo de ~18 months.O preço é duplicação temporária: durante o cutover, a feature existe no monólito e no Core ao mesmo tempo. O ganho é não ter risco de flag-day algum — e poder reverter a migração de qualquer feature isolada em < 5 min, só desligando o flag dela.
O que ficou de aprendizado
- Chave de idempotência é cidadã de primeira classe — não dá para encaixar depois. Todo evento carrega um UUIDv7, e todo downstream persiste
(idempotency_key, result)numa tabela de dedup com24h-TTL. Com isso, retry vira coisa entediante. - A deriva do outbox-poller é o único modo de falha que merece um alerta. Um
oldest_unprocessed_outbox_age > 60spor serviço pega o Broker travando antes de o cliente sentir. O nosso disparou exatamente duas vezes em 18 meses — as duas reais, as duas atalhadas a tempo. - BFF-por-canal puxa naturalmente para a duplicação; é preciso podar sempre. Os BFFs de mobile e web chegaram a compartilhar
~70%da query de perfil do cliente. Extraímos isso para um pacote versionado,@company/customer-client, e re-lintamos a cada sprint. Autonomia de time sem de-duplicação ativa termina em três cópias da mesma query. - A replay UI da DLQ é a garantia da invariante da saga. Como nada se perde de verdade, ops sempre reconstrói a cadeia de
order-placedaté o passo que falhou. A auditoria deixa de ser perícia forense e vira reconciliação de uma query só. - Propagação de trace-id é tudo ou nada — ou está em todo hop, ou está em nenhum. Um único salto sem ela custa uma sessão de debug de 3 horas. Por isso o OpenTelemetry entrou desde o dia 1, embutido no middleware do BFF, nos interceptors de gRPC e nos headers do Kafka. Tentar colar isso depois sai 10× mais caro.
- Anti-padrão para barrar de imediato: um único SDK de cliente compartilhado pelos quatro canais. Parece DRY por um sprint e logo vira o novo deploy-lock compartilhado — exatamente o nó que o BFF-por-canal existe para desatar.
A stack
- Camada de BFF: TypeScript 5 + Node.js 20 + NestJS 10 , clientes gRPC para o Core e Zod validando em runtime cada fronteira BFF↔Core.
- Serviços de Core: TypeScript + Node onde o peso é CRUD (orders, loyalty); Go 1.22 onde performance importa (payments, inventory); Postgres 14 com schema por serviço e zero FK entre schemas.
- Broker + Dispatcher: Go 1.22 com o cliente Kafka IBM/Sarama , stateless e escalado horizontalmente por tópico.
- Barramento de eventos: Kafka gerenciado no Confluent Cloud
, com
partition_key = order_idgarantindo a ordem por pedido. - Store de dedup: Redis 7 — TTL de
24hnas chaves de idempotência mais o contador de tentativas por evento. - Observabilidade: OpenTelemetry → Honeycomb
numa abordagem trace-first, com dashboards no Grafana para
saga_health,outbox_age_p99edlq_depth. - Deploy: Kubernetes (GKE) com Helm por serviço, CI em matriz no GitHub Actions e rollout progressivo via Argo Rollouts .
- Migração: strangler-fig ao longo de
~18 months, com toda troca de rota atrás de feature-flag e reversível em< 5 min.
Quando esse padrão vale a pena
- Comércio, fintech ou fidelidade multicanal: a partir de 3 superfícies de consumo distintas, cada uma puxando um formato de payload diferente.
- A partir de 5 times de engenharia independentes já esbarrando na contenção de deploy-lock (lead-time do DORA
> 1 day). - Quando a consistência transacional precisa conviver com prioridade de disponibilidade — saga serve, 2PC não.
- Quando há exigência de replay de auditoria financeira: o log de eventos já entrega isso de brinde.
Quando é melhor deixar pra lá
- SaaS de canal único. Aí o BFF-por-canal é puro imposto; uma API REST bem versionada dá conta.
- Time com menos de 8 engenheiros por BFF. Abaixo disso o churn de dependência domina, e o time de canal não sustenta uma superfície de serviço sem entrar em burnout.
- Exigência semântica estritamente síncrona (aceitar lance em leilão em tempo real, jobs em lote regulados). A consistência eventual da saga não cabe.
- Monólito de
< 100 KLOCcom< 10engenheiros. Um monólito modular com algumas costuras de evento bem escolhidas é mais barato de operar e mais fácil de raciocinar — o retorno do strangler-fig só aparece depois de você cruzar o limiar da coordenação entre times.