1

System Design de um Banco Digital: Do Ledger ao CAP

E aí, pessoal!

Quando a gente fala de system design, muitas vezes o foco é em escalabilidade. Mas em um banco digital? O foco é em correção. Não dá para perder um centavo.

Publiquei um artigo completo sobre como arquitetar um banco digital na escala do Nubank. Vou destrinchar os pilares aqui.

O Problema: Dinheiro é Diferente

Ao contrário de um tweet ou um like, dinheiro é conservado. Você não pode criar do nada nem destruir silenciosamente. Toda movimentação tem origem e destino. Se duplica, alguém está enriquecendo ilegalmente. Se some, alguém está falindo.

Mais: em um sistema distribuído, mensagens são reentregues. Clientes dão retry. Redes particionam. Se um retry vira uma segunda transferência, você criou um bug que custa dinheiro literal.

O regulador (BACEN, PCI-DSS, LGPD) está assistindo cada decisão. Isso não é só um problema técnico — é um problema jurídico.

Pilar 1: Ledger de Partidas Dobradas (desde 1494!)

A primeira insight: nunca armazene saldos mutáveis. Armazene lançamentos imutáveis que somam zero.

Em um banco real:

SELECT SUM(CASE WHEN type='debit' THEN amount ELSE -amount END) as balance
FROM ledger
WHERE account_id = ?;

Simples. Imutável. Auditável. Se o regulador pede o histórico completo de um centavo, está lá, em ordem cronológica, para sempre.

Isso resolve três problemas de uma vez:

  1. Auditabilidade — todo movimento rastreável até a causa-raiz
  2. Invariante verificável — a soma SEMPRE é zero
  3. Concorrência — como cada lançamento é imutável, lêms concorrentes nunca conflitam

Pilar 2: Idempotência é Obrigatória

Em um banco, essa é a linha entre um sistema que funciona e um que falência.

Imagine:

Cliente clica "Transferir R$ 5.000"
→ Timeout na rede
→ Cliente clica de novo (retry automático)
→ Sem idempotência: R$ 10.000 saem da conta

A solução: idempotency keys.

// Cada operação tem um UUID único gerado no cliente
const transferId = crypto.randomUUID();

// Server: se já processou esse ID, retorna o resultado anterior
const existingTransfer = await db.transfers.findUnique({
  where: { idempotencyKey: transferId }
});

if (existingTransfer) {
  return existingTransfer; // Mesmo resultado, sem duplicar
}

// Primeira vez: processa
const transfer = await processTransfer(...);
await db.transfers.create({
  idempotencyKey: transferId,
  ...transfer
});

O BACEN exige isso para PIX. Visa exige para cartões. Não é opcional.

Pilar 3: CQRS — Separe Leitura de Escrita

Um banco é ~50x mais read-heavy que write-heavy. Consultar saldo, extrato, limites de crédito — isso é um volume colossal. Mas escrita tem restrições brutais: precisa ser forte, imutável, auditada.

A solução é Command Query Responsibility Segregation (CQRS):

  • Lado de escrita: Operações financeiras críticas. Ledger imutável. Transações forte. Bloqueia o máximo possível para evitar race conditions.
  • Lado de leitura: Projeções. Caches. Eventual consistency é OK. Se o saldo demora 1 segundo para atualizar, ninguém morre.
// LADO DE ESCRITA (Operação financeira)
async function postTransfer(from: string, to: string, amount: number) {
  const txn = await db.$transaction(async (tx) => {
    // Bloqueia a conta, valida saldo, emite eventos
    const ledgerEntry = await tx.ledger.create({
      ...
    });
    
    await tx.event.create({
      type: 'TransferPosted',
      payload: ledgerEntry
    });
  });
  
  return txn;
}

// LADO DE LEITURA (Consulta de saldo — eventual)
async function getBalance(accountId: string) {
  // Tira de um cache eventualmente consistente
  return redis.get(`balance:${accountId}`);
}

// Um subscriber atualiza o cache quando eventos chegam
eventBus.on('TransferPosted', (event) => {
  const newBalance = calculateBalance(event.accountId);
  redis.set(`balance:${event.accountId}`, newBalance);
});

Pilar 4: Sagas — Transações Sem 2PC

Quando você precisa orquestrar múltiplos serviços (débito na conta, crédito no outro banco, notificação), transações distribuídas tradicionais (2PC) travam e morrem.

A solução é Sagas — uma sequência de passos compensáveis.

// Saga: Transferência entre bancos
async function transferOutOfBank(from: string, to: string, amount: number) {
  const steps = [];
  
  try {
    // Passo 1: Débito na conta local (este banco)
    const debit = await ledger.debit(from, amount);
    steps.push({ undo: () => ledger.credit(from, amount) });
    
    // Passo 2: Enviar para o BACEN (PIX)
    const pixId = await bacen.sendPix(to, amount);
    steps.push({ undo: () => bacen.reversePix(pixId) });
    
    // Passo 3: Notificar usuário
    await notif.send(from, `Enviou R$ ${amount}`);
    // Notificação não precisa de undo
    
    return { success: true, pixId };
    
  } catch (error) {
    // Executa os undos em ordem reversa
    for (let i = steps.length - 1; i >= 0; i--) {
      await steps[i].undo();
    }
    throw error;
  }
}

A diferença é brutal: 2PC bloqueia tudo até consenso. Sagas permitem que cada passo seja independente, com passos compensáveis claros.

Pilar 5: CAP — CP, não AP

O Teorema CAP diz que você só pode ter 2 de 3: Consistência, Disponibilidade, Tolerância a Partições.

Em um banco, quando uma partição de rede acontece, a escolha é clara:

  • AP (Disponibilidade + Tolerância): Aprova transações mesmo divergindo de um réplica. Resultado: dois usuários da mesma conta gastam o mesmo dinheiro duas vezes (double-spend). Catastrófico.

  • CP (Consistência + Tolerância): Recusa operações até sincronizar. Resultado: um usuário fica sem poder sacar, fica irritado, mas o dinheiro está seguro.

CP é a escolha certa. Um falso negativo (recusar uma transação válida) irrita. Um falso positivo (aprovar uma inválida) gera um rombo no balanço.

// Verificação de saldo FORTE (consistency-first)
async function authorizeWithdraw(accountId: string, amount: number) {
  // Lê do banco primário, não de réplicas
  const balance = await db.getBalance(accountId, { consistency: 'strong' });
  
  if (balance >= amount) {
    // Aprova
    return { approved: true };
  } else {
    // Recusa (falso negativo é OK)
    return { approved: false, reason: 'Insufficient funds' };
  }
}

Bônus: Observabilidade de Invariantes

A maioria dos bancos monitora SLA (uptime, latência). Mas o que importa em um banco é: a soma do ledger é sempre zero.

// Job executado a cada 5 minutos
setInterval(async () => {
  const sum = await ledger.sum();
  
  if (sum !== 0) {
    // ALERTA P0
    alert.fire({
      severity: 'CRITICAL',
      title: 'Ledger integrity violation',
      value: sum
    });
  }
}, 5 * 60 * 1000);

Se isso dispara, pare tudo. Não é "99,9% de uptime" — é uma violação da lei física do seu domínio.


Leitura Completa

Publiquei um artigo muito mais profundo com estimativas de capacidade, DDD, event sourcing, antifraude, compliance, segurança em camadas, testes, deploy sem downtime e muito código TypeScript.

Se você está construindo um banco, uma carteira digital, ou qualquer sistema que movimenta dinheiro de verdade, vale a pena ler o artigo completo no blog.

Até a próxima! 🚀

Carregando publicação patrocinada...