Produto próprio · 2026 · em produção
Kravva
Plataforma de palpites de futebol com rankings, bolões, loja com PIX e placar ao vivo. Construo e opero sozinho, do produto à infraestrutura.
- 16filas BullMQ
- 31módulos no back-end
- 96migrations
- 2ambientes: produção e homologação
O problema
Queria um produto meu, com back-end de verdade. O Kravva é uma plataforma de palpites de futebol: o usuário palpita placares, entra em bolões, disputa rankings, acompanha jogos ao vivo e compra passes via PIX. Tem loja, afiliados e uma camada social.
Isso parece simples até o jogo começar. O placar muda e todo mundo quer ver na hora. A pontuação precisa ser recalculada no apito final. A API de dados esportivos tem cota diária. E o dinheiro entra por webhook, que o provedor reenvia quando quer.
Eu era a única pessoa para resolver tudo isso, do produto ao deploy.
O que eu fiz
Back-end modular
A API é um monólito modular em NestJS 11 com PostgreSQL 16: 31 módulos, 82 entidades e 96 migrations. As migrations são SQL escrito à mão e a CI aplica todas num Postgres efêmero antes de qualquer deploy. Auth com JWT e refresh token, SSO Google e RBAC.
Filas para tudo que não cabe numa requisição
São 16 filas BullMQ: sincronização de partidas ao vivo, pontuação, notificações, e-mail e retenção de dados para a LGPD. Os jobs usam IDs determinísticos. Assim, o mesmo gol ou o mesmo apito final não é processado duas vezes.
Tempo real em cluster
A API roda em cluster Node, com mais de um worker. O Socket.io usa Redis adapter e autenticação JWT no handshake. Quando uma compra é confirmada ou o placar muda, o evento sai do worker que processou e chega ao socket do usuário, esteja ele em qual worker estiver.
PIX com webhook e ledger
O webhook chega com assinatura HMAC sobre o corpo cru, que valido com comparação em tempo constante. A confirmação roda numa transação com lock pessimista na linha do pagamento e grava num ledger financeiro append-only. Estorno é lançamento novo, nunca update.
// Confirmação idempotente (pseudocódigo)
await db.transaction(async (tx) => {
const pagamento = await tx.selectForUpdate(pagamentos, { id }); // trava a linha
if (pagamento.status === 'pago') return; // reentrega do webhook: nada a fazer
pagamento.status = 'pago';
await tx.save(pagamento);
await ledger.registrarSeAusente(tx, pagamento); // lançamento append-only
await acessos.concederSeAusente(tx, pagamento);
await comissao.creditar(tx, pagamento); // só na primeira confirmação
});
// O evento de tempo real só sai depois do commit
emitirParaUsuario(pagamento.usuarioId, 'acesso_liberado');
Se o webhook atrasa, o front consulta a transação. Depois de um tempo pendente, a API pergunta ao provedor e chama o mesmo caminho idempotente.
Operação
Rotinas em background precisam rodar em um único processo, então elegi um líder por lease no PostgreSQL. O deploy é automatizado com GitHub Actions e SSH numa VPS com Docker e Nginx. O front fica no Cloudflare Pages e os arquivos no R2. Há ambiente de produção e de homologação.
Decisões e trade-offs
Idempotência em camadas no pagamento. Histórico de eventos, SELECT ... FOR UPDATE, checagem de estado e índices únicos como última barreira. Escolhi lock pessimista porque provedores de pagamento reenviam em rajada e a seção crítica é curta. O custo: entregas concorrentes do mesmo pagamento esperam umas pelas outras.
BullMQ com jobId determinístico. A fila garante entrega pelo menos uma vez, então o processamento precisa tolerar repetição. O ID do job impede duplicatas. O custo: a deduplicação só vale enquanto o job está retido. Já tive de pensar em incluir o placar no ID para que uma correção de VAR gerasse um job novo.
SQL à mão nas migrations. Dá controle sobre índices parciais e backfills, que o gerador do TypeORM faz mal. O custo: nada garante que a entidade continue igual à coluna real. Isso depende de disciplina e de teste.
Cluster em vez de orquestrador. Dois workers aproveitam a máquina sem a complexidade de Kubernetes. O custo é visível no próximo ponto: estado em memória por processo.
Resultado
O Kravva está em produção, com deploy automatizado, testes unitários com Jest, testes de integração com Postgres real e CI que faz dry-run das migrations. Não divulgo o número de usuários.
Três incidentes reais viraram correção:
- WebSocket caindo no cluster. O front recebia "Session ID unknown" (HTTP 400). A causa: o Nginx não repassava o upgrade de WebSocket, o cliente caía para long polling e as requisições do mesmo cliente chegavam a workers diferentes. Adicionei o bloco do Socket.io no Nginx com
Upgrade, e o erro sumiu. Foi um problema separado do Redis adapter, que adicionei no mesmo ajuste para resolver uma falha silenciosa: um evento emitido num worker não chegava a sockets conectados em outro. - Cota da API de futebol estourada na Copa. Passei a usar um snapshot ao vivo compartilhado entre workers e um rate limiter global no Redis.
- PWA servindo arquivos velhos após deploy. Tratei a recuperação de chunk e o
skipWaiting.
Sobre IA: boa parte do código foi escrita com apoio de Cursor e Claude. As decisões, a revisão e a operação são minhas, e sei explicar cada uma delas.
O que eu faria diferente
Começaria com observabilidade: logs estruturados, métricas das filas e alertas. Essa é uma lacuna que conheço.
Quebraria o serviço de sincronização de jogos, que cresceu demais.
Fecharia o fallback de long polling com WebSocket puro ou sticky sessions. Sem isso, o polling continua frágil com mais de um worker.
Testaria as migrations contra um dump anonimizado, não só contra um banco vazio. E padronizaria o acesso a dados, que hoje mistura repositórios próprios com o repositório padrão do TypeORM.