Um mergulho profundo em ledgers de partidas dobradas, idempotência, event sourcing, sagas e os limites do Teorema CAP — projetando o coração de uma fintech para escala de centenas de milhões de contas

Software Architect
Pós-graduado em arquitetura de software e soluções. Conecto profundidade técnica com resultados de negócio para entregar produtos que as pessoas realmente usam. Também mentoro desenvolvedores e criadores em programas ao vivo, podcasts e iniciativas de comunidade focadas em tecnologia inclusiva.
Continue explorando tópicos similares

Um guia aprofundado sobre como a Fase 1 do Pos Tech FIAP forma engenheiros(as) front-end completos, unindo JavaScript avançado, TypeScript robusto, Design Systems, Next.js e fundamentos de arquitetura, performance e memória — da teoria à aplicação em produção.

Stop littering your business logic with logging and security checks. Learn how to use Aspect-Oriented Programming (AOP) to centralize cross-cutting concerns across TypeScript, C#, and Java with real-world implementation patterns.

A CVE-2025-55182, conhecida como React2Shell, revelou uma falha crítica em React Server Components e Next.js que permite RCE não autenticado e já está sendo explorada em produção — muitas vezes com PoCs gerados por IA. Neste artigo, mostro o que aconteceu, como saber se seu app está vulnerável, o que fazer agora e por que incidentes assim vão ficar cada vez mais comuns na era da inteligência artificial.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.

"A arquitetura é sobre as coisas importantes. Sejam elas o que forem." — Ralph Johnson, citado por Martin Fowler.
Em um banco, as "coisas importantes" têm nome e sobrenome: dinheiro de gente de verdade. Você não pode perder um centavo, não pode duplicar uma transferência, não pode ficar fora do ar quando o salário cai, e não pode deixar um fraudador esvaziar a conta de uma vovó. Este artigo é sobre como arquitetar exatamente isso.
Existe uma razão pela qual entrevistas de system design adoram pedir "projete um encurtador de URL" ou "projete o Twitter": esses sistemas toleram perda. Se um tweet some, ninguém vai à falência. Se a contagem de likes fica eventualmente consistente e mostra 41 em vez de 42 por alguns segundos, o mundo não acaba.
Um banco digital é o oposto disso em quase todas as dimensões:
R$ 1.000,00 e R$ 100.000,00 é um zero — e esse zero pode ser a diferença entre uma empresa solvente e uma fraude criminal.R$ 5.000,00, você acabou de criar um bug que custa dinheiro literal.Este artigo é um blueprint completo. Vamos do guardanapo (estimativas de capacidade) até o código TypeScript de um ledger transacional, passando por DDD, event sourcing, CQRS, sagas, idempotência, antifraude e os trade-offs brutais do Teorema CAP aplicado a dinheiro.
Vou escrever no espírito de dois mestres. De Martin Fowler, pego a obsessão por modelar o domínio antes da infraestrutura e por nomear padrões com precisão. De Robert C. Martin (Uncle Bob), pego a disciplina das fronteiras: regras de negócio no centro, frameworks e bancos de dados como detalhes substituíveis na periferia. E de Gregor Hohpe, a humildade de saber que em sistemas distribuídos "você não pode ter exatamente-uma-vez na entrega; você só pode tê-la no efeito".
Vamos começar.
Se você tem dois minutos, eis a essência destilada do que vamos construir:
Para quem é este artigo: engenheiros de backend e arquitetos que querem entender o sistema por trás do app do banco; quem está construindo (ou avaliando construir) infraestrutura financeira; e qualquer pessoa curiosa sobre como dinheiro de verdade se move através de software sem se perder pelo caminho.
Agora, a jornada completa — do guardanapo ao TypeScript de produção.
A primeira disciplina de qualquer system design sério é resistir à vontade de desenhar caixinhas. Antes de qualquer arquitetura, precisamos saber o que o sistema faz e, mais importante, sob quais restrições ele faz.
Vamos delimitar o escopo do nosso banco digital. Um banco real tem centenas de funcionalidades; vamos focar no núcleo que define um "banco de verdade" — o que internamente chamamos de core banking.
| Capacidade | Descrição | Criticidade |
|---|---|---|
| Abertura de conta (Onboarding) | Cadastro, validação de identidade (KYC), criação da conta de pagamento | Alta |
| Saldo e extrato | Consulta de saldo em tempo real e histórico de transações | Alta |
| Transferências internas | Movimentação entre contas do próprio banco | Crítica |
| PIX | Transferências instantâneas via SPI/BACEN (envio e recebimento) | Crítica |
| Cartão de crédito | Emissão, autorização de compras, fatura, pagamento | Crítica |
| Cartão de débito | Autorização em tempo real contra o saldo | Crítica |
| Pagamento de boletos | Liquidação de contas via código de barras | Média |
| Investimentos | Aplicação e resgate em produtos (CDB, fundos) | Média |
| Notificações | Push em tempo real para cada movimentação | Alta |
A palavra "Crítica" na tabela acima não é decorativa. Ela significa: se isto falhar de forma incorreta, alguém perde dinheiro e nós perdemos a licença bancária.
Uncle Bob diria que os requisitos funcionais são os "casos de uso" — eles definem a forma do software. Mas em um banco, são os requisitos não-funcionais que ditam quase todas as decisões de infraestrutura.
A regra de ouro do banco: Em caso de dúvida, recuse a operação. Um falso negativo (recusar uma transação válida) gera um cliente irritado. Um falso positivo (aprovar uma transação inválida) gera um rombo no balanço. Estas falhas não são simétricas, e nossa arquitetura precisa refletir essa assimetria em cada camada.
Ser explícito sobre o que não vamos construir é tão importante quanto o que vamos. Fora do escopo deste artigo: motor de crédito/underwriting (um sistema de ML inteiro por si só), câmbio internacional, e o app mobile em si. Vamos focar na espinha dorsal transacional — o lugar onde o dinheiro realmente se move.
Antes de escolher tecnologias, fazemos as contas. Jeff Dean popularizou os "números que todo engenheiro deveria saber"; aqui aplicamos o mesmo espírito ao domínio financeiro. Estimar errado a capacidade é como construir uma ponte sem calcular a carga: parece funcionar até o dia em que não funciona.
Total de contas: 100.000.000 (100M)
Usuários ativos diários (DAU): 30.000.000 (30M, ~30% de engajamento)
Transações por usuário/dia: ~5 (PIX, cartão, consultas de saldo contam à parte)
Vamos calcular as escritas transacionais — o caminho mais caro e crítico.
Transações financeiras/dia = 30M DAU × 5 tx = 150.000.000 tx/dia
Segundos em um dia = 86.400
TPS médio = 150.000.000 / 86.400 ≈ 1.736 TPS
Mas o TPS médio é uma mentira reconfortante. O tráfego de um banco é violentamente não-uniforme. Concentre-se no pico:
Fator de pico (horário comercial + dia 5): ~10x sobre a média
TPS de pico (escrita) ≈ 1.736 × 10 ≈ 17.360 TPS
Agora as leituras (consultas de saldo, extrato, abertura do app). A razão leitura/escrita em fintechs costuma ser de 50:1 ou mais — gente abre o app pra "ver o saldo" muito mais do que transaciona.
TPS de leitura (pico) ≈ 17.360 × 50 ≈ 868.000 TPS
Conclusão imediata da estimativa: Temos um sistema massivamente read-heavy. Isso é o primeiro grande sinal arquitetural: vamos precisar separar o caminho de leitura do caminho de escrita (a semente do CQRS, que veremos na Seção 7). Não faz sentido proteger 868k TPS de leitura com os mesmos locks pessimistas que protegem 17k TPS de escrita.
O coração de um banco é o ledger — o registro imutável de toda movimentação. Ele só cresce.
Tamanho médio de um registro de transação (lançamento): ~300 bytes
(id, conta_origem, conta_destino, valor, moeda, timestamp,
tipo, status, idempotency_key, metadados, hash)
Lançamentos por dia (cada transação gera ≥2 lançamentos no ledger):
150M tx × 2 ≈ 300.000.000 lançamentos/dia
Armazenamento bruto/dia = 300M × 300 bytes ≈ 90 GB/dia
Armazenamento bruto/ano = 90 GB × 365 ≈ 32,85 TB/ano
Adicione índices (multiplicador de ~2-3x), réplicas (3x para durabilidade) e retenção regulatória (BACEN exige guarda de 5 a 10 anos):
Armazenamento efetivo do ledger (10 anos, com réplicas e índices):
32,85 TB × 10 anos × 3 (réplicas) × 2,5 (índices) ≈ ~2,5 PB
Dois petabytes e meio só do ledger transacional. Isto mata a ideia ingênua de "um Postgres gigante guarda tudo". Vamos precisar de uma estratégia de particionamento temporal (dados "quentes" recentes vs. "frios" arquivados) e sharding horizontal, que detalharemos na Seção 15.
Tamanho médio de uma request de API (autorização): ~1 KB
Tamanho médio de uma response: ~2 KB
Banda de pico (leitura) ≈ 868.000 req/s × 3 KB ≈ 2,6 GB/s ≈ 20,8 Gbps
Vinte gigabits por segundo de pico. Isto exige load balancers L7 robustos, connection pooling agressivo e CDN para qualquer conteúdo cacheável (que, num banco, é frustrantemente pouco — dados financeiros raramente são cacheáveis sem cuidado).
| Métrica | Valor estimado | Implicação arquitetural |
|---|---|---|
| TPS escrita (pico) | ~17.000 | Sharding por conta; ledger particionado |
| TPS leitura (pico) | ~870.000 | CQRS; read replicas; cache agressivo |
| Razão R/W | ~50:1 | Separar caminhos leitura/escrita |
| Storage ledger (10a) | ~2,5 PB | Tiering quente/frio; arquivamento |
| Latência cartão (P99) | < 100ms | Caminho de autorização ultra-otimizado |
| Durabilidade | RPO = 0 | Replicação síncrona; quórum de escrita |
Estas contas não precisam estar perfeitas. Elas precisam estar certas em ordem de grandeza. A diferença entre projetar para 17k TPS e 17M TPS é uma arquitetura completamente diferente — e o guardanapo nos salva de descobrir isso em produção.
Aqui começamos a divergir do tutorial típico de "system design para entrevista". A maioria pula direto para "use Kafka e Cassandra". Mas Martin Fowler e Eric Evans nos ensinaram algo mais profundo: a estrutura do seu software deve espelhar a estrutura do negócio. Se você acertar o modelo de domínio, a infraestrutura vira detalhe. Se você errar, nenhuma quantidade de Kafka vai te salvar.
Antes de desenhar fronteiras, precisamos concordar sobre as palavras. Em DDD isso se chama Linguagem Ubíqua (Ubiquitous Language): o vocabulário compartilhado entre engenheiros e especialistas do domínio. No nosso banco:
Por que isso importa: Quando um engenheiro diz "saldo" e o time de risco entende "saldo disponível" enquanto o contábil entende "saldo contábil", você tem um bug latente esperando para custar dinheiro. A Linguagem Ubíqua não é burocracia — é prevenção de defeitos.
Um Bounded Context é uma fronteira explícita dentro da qual um modelo de domínio é consistente e válido. A palavra "conta" significa coisas diferentes no contexto de Onboarding (um cadastro a validar) e no contexto de Ledger (um saldo a movimentar). Bounded Contexts nos permitem ter ambos sem confusão.
Aqui está o mapa de contextos do nosso banco:
Note uma decisão deliberada: o Ledger Context está no centro, e todos os outros contextos que movem dinheiro (Payments, Cards) dependem dele, nunca o contrário. Isto é Uncle Bob na veia — a Regra da Dependência: dependências apontam para dentro, em direção às regras de negócio mais estáveis e fundamentais. O ledger não sabe o que é um PIX ou um cartão; ele só sabe debitar e creditar. Essa ignorância é uma feature.
Nem todo contexto se relaciona da mesma forma. DDD nomeia os padrões de integração:
Dentro do Ledger Context, qual é o nosso Aggregate Root? Um Aggregate é um cluster de objetos tratados como uma unidade para fins de mudança de dados — e a fronteira de consistência transacional.
A tentação ingênua é fazer da Account o aggregate root e guardar o saldo como um campo mutável. Isso é um erro de design profundo. Se Account.balance é um número que você atualiza, você criou um ponto de contenção brutal (toda transação trava a conta) e destruiu a auditabilidade (você sabe o saldo atual, mas não como chegou nele).
O modelo correto trata a transação como o aggregate, e o saldo como uma projeção derivada da soma dos lançamentos:
// Value Object: dinheiro NUNCA é um float.
// Floats têm erros de arredondamento que, multiplicados por milhões
// de transações, viram um rombo contábil. Use inteiros (centavos).
class Money {
private constructor(
public readonly amountInCents: bigint,
public readonly currency: string,
) {}
static of(cents: bigint, currency = "BRL"): Money {
return new Money(cents, currency);
}
static fromReais(reais: number, currency = "BRL"): Money {
// Atenção: este construtor é só para entrada/teste.
// A fonte da verdade sempre trafega em centavos (bigint).
return new Money(BigInt(Math.round(reais * 100)), currency);
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amountInCents + other.amountInCents, this.currency);
}
negate(): Money {
return new Money(-this.amountInCents, this.currency);
}
isZero(): boolean {
return this.amountInCents === 0n;
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
// Somar BRL com USD é um bug, não uma operação válida.
// O domínio se recusa a permitir estados inválidos.
throw new DomainError(
`Não é possível operar moedas diferentes: ${this.currency} vs ${other.currency}`,
);
}
}
}
Repare na filosofia: o Money é um Value Object imutável que torna estados inválidos não-representáveis. Você não consegue, nem por acidente, somar reais com dólares — o tipo proíbe. Isso é o que Uncle Bob chama de empurrar a corretude para o sistema de tipos, e é a base de tudo que vem a seguir.
Com o domínio modelado, podemos finalmente desenhar caixinhas — e agora elas significam algo. A arquitetura geral segue um padrão de microsserviços orientados a eventos, mas com uma disciplina rígida: o caminho do dinheiro é síncrono e transacional, enquanto tudo que é derivado (notificações, extratos, analytics) é assíncrono e eventual.
Uma forma poderosa de raciocinar sobre essa arquitetura é separá-la por velocidade e garantia de consistência:
A genialidade dessa separação é que ela permite otimizar cada caminho independentemente. O caminho quente usa consistência forte e locks; o morno usa filas e idempotência; o frio usa data lakes e processamento distribuído. Tentar usar a mesma tecnologia para os três é a receita clássica do desastre.
Cada serviço de domínio (especialmente o Ledger) segue a Arquitetura Limpa de Uncle Bob internamente. Isto não é decoração — é o que permite testar a lógica financeira sem subir um banco de dados, e trocar o Postgres por outra coisa sem reescrever as regras.
A Regra da Dependência manda: o código-fonte das dependências aponta sempre para dentro. As entidades (o Money, a Transaction) não sabem que existe Postgres ou Kafka. Os casos de uso conhecem as entidades, mas dependem de interfaces (LedgerRepository), não de implementações. O Postgres é um detalhe que vive na borda mais externa e pode ser substituído.
// Caso de uso no centro — não importa NADA de infraestrutura.
// Depende apenas de abstrações (ports).
interface LedgerRepository {
// Persiste uma transação de forma atômica e idempotente.
save(transaction: Transaction): Promise<void>;
findByIdempotencyKey(key: string): Promise<Transaction | null>;
getBalance(accountId: AccountId): Promise<Money>;
}
interface EventGateway {
publish(events: DomainEvent[]): Promise<void>;
}
class TransferUseCase {
constructor(
private readonly ledger: LedgerRepository, // interface, não Postgres
private readonly events: EventGateway, // interface, não Kafka
private readonly clock: Clock, // até o tempo é injetado (testabilidade)
) {}
async execute(cmd: TransferCommand): Promise<TransferResult> {
// 1. Idempotência: já vimos esta operação?
const existing = await this.ledger.findByIdempotencyKey(cmd.idempotencyKey);
if (existing) {
return TransferResult.fromExisting(existing); // retry seguro
}
// 2. Regra de negócio pura (testável sem banco):
const transaction = Transaction.transfer({
from: cmd.fromAccount,
to: cmd.toAccount,
amount: cmd.amount,
occurredAt: this.clock.now(),
idempotencyKey: cmd.idempotencyKey,
});
// 3. Persistência atômica + publicação de eventos
await this.ledger.save(transaction);
await this.events.publish(transaction.pullEvents());
return TransferResult.success(transaction);
}
}
Repare: você consegue testar TransferUseCase inteiro com mocks em memória, sem subir Postgres nem Kafka. A lógica financeira é uma função quase pura. Esse é o pagamento que a Arquitetura Limpa nos dá — e em um banco, onde cada regra precisa de testes exaustivos, esse pagamento é enorme.
Se você ler apenas uma seção deste artigo, leia esta. O ledger é o componente onde tudo pode dar terrivelmente errado e onde séculos de sabedoria contábil nos salvam. A técnica não é nova — a partida dobrada (double-entry bookkeeping) foi formalizada por Luca Pacioli em 1494. Sim, 1494. E ela continua sendo, em 2026, a melhor estrutura de dados já inventada para rastrear dinheiro.
A modelagem ingênua que quase todo iniciante faz:
-- ❌ O ANTI-PADRÃO QUE VAI TE CUSTAR CARO
CREATE TABLE accounts (
id UUID PRIMARY KEY,
balance BIGINT NOT NULL -- saldo mutável em centavos
);
-- Transferir = duas updates
UPDATE accounts SET balance = balance - 10000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 10000 WHERE id = 'B';
Por que isso é catastrófico?
UPDATE, você destruiu R$ 100 (debitou A, nunca creditou B). Dinheiro evaporou do universo.UPDATE trava a linha da conta. Uma conta "quente" (a do iFood recebendo milhares de pagamentos) vira um gargalo de serialização.No ledger de partidas dobradas, você nunca atualiza um saldo. Você apenas insere lançamentos imutáveis. Cada transação é um conjunto de lançamentos (débitos e créditos) que soma exatamente zero. O saldo é uma derivação: a soma de todos os lançamentos de uma conta.
O esquema correto:
-- ✅ O MODELO DE PARTIDAS DOBRADAS
-- Transações: o "cabeçalho" que agrupa lançamentos
CREATE TABLE transactions (
id UUID PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL, -- a chave da reentrega segura
type TEXT NOT NULL, -- 'transfer', 'pix', 'card_capture'...
status TEXT NOT NULL, -- 'posted', 'pending', 'voided'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB
);
-- Lançamentos: imutáveis, append-only, NUNCA recebem UPDATE
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES transactions(id),
account_id UUID NOT NULL,
-- positivo = crédito, negativo = débito. Em centavos (bigint).
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'BRL',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entries_account ON entries(account_id, created_at);
-- O saldo é uma VIEW (ou materialização) — derivado, nunca armazenado mutável
CREATE VIEW account_balances AS
SELECT account_id, currency, SUM(amount) AS balance
FROM entries
GROUP BY account_id, currency;
A invariante sagrada, que pode (e deve) ser checada continuamente:
-- Esta query DEVE sempre retornar 0. Se não retornar, dinheiro
-- foi criado ou destruído — um incidente P0 absoluto.
SELECT SUM(amount) FROM entries; -- = 0 para um sistema fechado e saudável
A beleza de Pacioli: Como cada transação soma zero, a soma de todos os lançamentos do sistema é sempre zero. Esta é uma invariante global verificável em uma única query. Se ela quebrar, você sabe — imediatamente e com certeza matemática — que algo está errado. Nenhum "campo de saldo" te dá essa garantia.
"Mas espera," você pergunta, "se tudo soma zero, como dinheiro entra no banco quando alguém deposita via PIX externo?" Excelente pergunta — e a resposta revela a elegância do modelo.
Você cria contas de sistema (contas-espelho do mundo externo). Quando R$ 100 entram via PIX de outro banco, o lançamento é:
A conta de sistema PIX_LIQUIDACAO_SPI representa a posição do banco no SPI/BACEN. Ela fica negativa porque, do ponto de vista do nosso ledger, "devemos" esse dinheiro que entrou — e ele será reconciliado contra a liquidação real no BACEN. Tudo continua somando zero. Dinheiro nunca aparece do nada; ele sempre vem de alguma conta, mesmo que seja uma conta que represente o mundo lá fora.
Agora o código que efetivamente posta uma transação. Os pontos críticos: atomicidade (tudo ou nada), validação da invariante zero-sum, e idempotência.
// Domínio: a Transaction como aggregate root
class Transaction {
private readonly events: DomainEvent[] = [];
private constructor(
public readonly id: TransactionId,
public readonly idempotencyKey: string,
public readonly type: TransactionType,
public readonly entries: ReadonlyArray<Entry>,
public readonly occurredAt: Date,
) {}
// Factory para transferência entre duas contas
static transfer(params: {
from: AccountId;
to: AccountId;
amount: Money;
occurredAt: Date;
idempotencyKey: string;
}): Transaction {
if (params.amount.amountInCents <= 0n) {
throw new DomainError("O valor da transferência deve ser positivo");
}
const entries: Entry[] = [
Entry.create(params.from, params.amount.negate()), // débito
Entry.create(params.to, params.amount), // crédito
];
const tx = new Transaction(
TransactionId.generate(),
params.idempotencyKey,
TransactionType.TRANSFER,
entries,
params.occurredAt,
);
// A INVARIANTE SAGRADA: zero-sum. Verificada na construção.
tx.assertZeroSum();
tx.events.push(
new MoneyTransferredEvent(tx.id, params.from, params.to, params.amount),
);
return tx;
}
// A regra de Pacioli, codificada e inviolável.
private assertZeroSum(): void {
const sumByCurrency = new Map<string, bigint>();
for (const entry of this.entries) {
const current = sumByCurrency.get(entry.currency) ?? 0n;
sumByCurrency.set(entry.currency, current + entry.amount.amountInCents);
}
for (const [currency, sum] of sumByCurrency) {
if (sum !== 0n) {
// Se isto dispara, há um bug GRAVE. A transação se recusa a existir.
throw new DomainError(
`Violação de partidas dobradas: ${currency} soma ${sum}, esperado 0`,
);
}
}
}
pullEvents(): DomainEvent[] {
return [...this.events];
}
}
E a persistência atômica, no adaptador de infraestrutura (a borda externa da Clean Architecture):
class PostgresLedgerRepository implements LedgerRepository {
constructor(private readonly pool: Pool) {}
async save(transaction: Transaction): Promise<void> {
const client = await this.pool.connect();
try {
// BEGIN: tudo ou nada. Atomicidade do banco é nossa aliada.
await client.query("BEGIN");
// Insere o cabeçalho da transação.
// O UNIQUE em idempotency_key é nossa rede de segurança final:
// se dois processos tentarem a mesma operação, o banco rejeita o segundo.
await client.query(
`INSERT INTO transactions (id, idempotency_key, type, status, created_at)
VALUES ($1, $2, $3, 'posted', $4)`,
[transaction.id.value, transaction.idempotencyKey,
transaction.type, transaction.occurredAt],
);
// Insere todos os lançamentos na mesma transação de banco.
for (const entry of transaction.entries) {
await client.query(
`INSERT INTO entries (transaction_id, account_id, amount, currency)
VALUES ($1, $2, $3, $4)`,
[transaction.id.value, entry.accountId.value,
entry.amount.amountInCents, entry.currency],
);
}
// COMMIT: agora é definitivo e durável.
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK"); // qualquer falha → nada aconteceu
// Violação da constraint UNIQUE = retry concorrente. Tratado acima.
if (isUniqueViolation(err, "idempotency_key")) {
throw new IdempotencyConflict(transaction.idempotencyKey);
}
throw err;
} finally {
client.release();
}
}
// getBalance, findByIdempotencyKey... omitidos por brevidade
}
Três camadas de proteção trabalham juntas aqui:
assertZeroSum) garante que a transação é contabilmente válida antes de tocar o banco.BEGIN/COMMIT) garante atomicidade — débito e crédito acontecem juntos ou nenhum acontece.UNIQUE na idempotency_key garante que retries concorrentes não dupliquem dinheiro, mesmo que escapem das verificações da camada de aplicação."Calcular SUM(amount) de uma conta com milhões de lançamentos a cada consulta de saldo? Isso vai ser lentíssimo!" — você está certíssimo. Para uma conta quente, recalcular o saldo do zero a cada leitura é inviável a 870k TPS.
A solução é snapshots de saldo (materialização incremental). Mantemos uma tabela de saldos que é atualizada por um trigger ou por um projector assíncrono, mas — e aqui está o pulo do gato — ela é uma otimização de leitura, não a fonte da verdade. Se ela divergir, podemos sempre recomputá-la a partir dos lançamentos imutáveis.
// Snapshot incremental: o saldo é atualizado a cada lançamento,
// mas SEMPRE pode ser reconstruído dos entries imutáveis.
class BalanceSnapshotProjector {
async onEntryPosted(entry: PersistedEntry): Promise<void> {
// UPSERT atômico do saldo materializado.
await this.db.query(
`INSERT INTO account_balances_snapshot (account_id, currency, balance, last_entry_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, currency)
DO UPDATE SET
balance = account_balances_snapshot.balance + $3,
last_entry_id = $4
WHERE account_balances_snapshot.last_entry_id < $4`, // idempotente!
[entry.accountId, entry.currency, entry.amount, entry.id],
);
}
}
O WHERE ... last_entry_id < $4 torna a projeção idempotente: reprocessar o mesmo lançamento não soma duas vezes. Esse padrão — fonte da verdade imutável + projeção materializada idempotente + reconciliação — é o que nos permite ter tanto a auditabilidade perfeita das partidas dobradas quanto leituras de saldo em sub-milissegundos. Não precisamos escolher.
Nenhum sistema é perfeito. Bugs acontecem, projeções divergem, hardware falha. A diferença entre um banco amador e um banco sério é a reconciliação contínua — um processo que constantemente verifica as invariantes e grita quando algo está errado.
// Job de reconciliação: roda continuamente, compara fonte da verdade
// com as projeções materializadas. Em um banco, isto NÃO é opcional.
class ReconciliationJob {
async run(): Promise<ReconciliationReport> {
const discrepancies: Discrepancy[] = [];
// 1. Invariante global: soma de TODOS os lançamentos = 0
const globalSum = await this.ledger.sumAllEntries();
if (globalSum !== 0n) {
// P0. Dinheiro foi criado ou destruído. Acorde todo mundo.
await this.alerting.pageOnCall(
Severity.P0,
`Invariante de partidas dobradas QUEBRADA: soma global = ${globalSum}`,
);
}
// 2. Para cada conta: snapshot bate com a soma dos entries?
for await (const account of this.ledger.iterateAccounts()) {
const truth = await this.ledger.computeBalanceFromEntries(account.id);
const snapshot = await this.ledger.getSnapshotBalance(account.id);
if (truth !== snapshot) {
discrepancies.push({ account: account.id, truth, snapshot });
// Auto-corrige a projeção a partir da fonte da verdade imutável.
await this.ledger.repairSnapshot(account.id, truth);
}
}
return new ReconciliationReport(globalSum, discrepancies);
}
}
Esta é a diferença filosófica fundamental: num campo de saldo mutável, uma divergência é invisível e irrecuperável — você não tem como saber qual valor está certo. Num ledger de partidas dobradas, a divergência é detectável e autorrecuperável — os lançamentos imutáveis são sempre a verdade, e qualquer projeção pode ser reconstruída a partir deles. Cinco séculos depois, Pacioli ainda está certo.
Gregor Hohpe, autor de Enterprise Integration Patterns, tem uma frase que todo engenheiro de fintech deveria tatuar: "Exactly-once delivery is a myth; exactly-once processing is achievable." Em português: você nunca vai garantir que uma mensagem seja entregue exatamente uma vez numa rede não-confiável. Mas você pode garantir que seu efeito aconteça exatamente uma vez. Essa garantia se chama idempotência, e é a linha que separa um banco confiável de um gerador de rombos.
Imagine o fluxo de uma transferência PIX:
O débito foi feito, mas a resposta se perdeu na rede. Do ponto de vista do app, a operação "falhou". O usuário (ou o próprio cliente HTTP, automaticamente) tenta de novo. Sem idempotência, ele paga duas vezes. Isso não é um caso raro — em escala de milhões de transações por dia, retries acontecem o tempo todo: timeouts, redes instáveis, deploys, load balancers reciclando conexões.
A premissa central dos sistemas distribuídos: Tudo que pode ser reenviado, será reenviado. Sua arquitetura não pode esperar que retries não aconteçam. Ela precisa torná-los seguros.
A solução é o cliente gerar uma chave de idempotência (um UUID) uma vez por intenção de operação, e reenviá-la em cada tentativa. O servidor usa essa chave para reconhecer "ah, esta é a mesma operação que eu já processei" e retornar o resultado original em vez de processar de novo.
Uma implementação ingênua de idempotência tem uma janela de corrida: o que acontece se dois retries chegam simultaneamente, antes do primeiro terminar de processar? Precisamos de uma máquina de estados.
type IdempotencyState = "in_progress" | "completed" | "failed";
interface IdempotencyRecord {
key: string;
state: IdempotencyState;
requestHash: string; // detecta reuso indevido da mesma key
response?: SerializedResponse;
createdAt: Date;
lockedUntil?: Date; // lease para detectar processos mortos
}
class IdempotencyMiddleware {
constructor(
private readonly store: IdempotencyStore,
private readonly clock: Clock,
) {}
async handle(
key: string,
request: Request,
process: () => Promise<Response>,
): Promise<Response> {
const requestHash = hashRequest(request);
// Tenta "reivindicar" a key de forma atômica (INSERT ... ON CONFLICT).
const claim = await this.store.tryClaim({
key,
requestHash,
state: "in_progress",
lockedUntil: this.clock.now().plus({ seconds: 30 }), // lease
});
if (claim.alreadyExists) {
const existing = claim.record!;
// GUARDA CRÍTICA: mesma key, request DIFERENTE = erro do cliente.
// Reusar uma idempotency key para outra operação é proibido.
if (existing.requestHash !== requestHash) {
throw new IdempotencyKeyReuseError(key);
}
switch (existing.state) {
case "completed":
// Já processamos: devolve o resultado original. Seguro.
return deserializeResponse(existing.response!);
case "in_progress":
// Outro request está processando AGORA. Não processe em paralelo.
if (this.leaseExpired(existing)) {
// O processo anterior morreu. Recupera o lease e reprocessa.
return this.reprocess(key, requestHash, process);
}
// Lease ainda válido: peça para o cliente tentar daqui a pouco.
throw new OperationInProgressError(key, retryAfterSeconds: 1);
case "failed":
// Falha anterior: permite nova tentativa real.
return this.reprocess(key, requestHash, process);
}
}
// Primeira vez: processa de verdade.
return this.reprocess(key, requestHash, process);
}
private async reprocess(
key: string,
requestHash: string,
process: () => Promise<Response>,
): Promise<Response> {
try {
const response = await process();
await this.store.complete(key, serializeResponse(response));
return response;
} catch (err) {
await this.store.markFailed(key);
throw err;
}
}
private leaseExpired(record: IdempotencyRecord): boolean {
return !!record.lockedUntil && record.lockedUntil < this.clock.now();
}
}
Os detalhes que separam o brinquedo do sistema de produção:
requestHash: Se o cliente reusar a mesma key para uma operação diferente (R$ 500 vira R$ 5.000), nós detectamos e rejeitamos. A key promete "esta operação específica", não "qualquer operação".in_progress com lease: Dois retries simultâneos? O segundo vê in_progress e espera, em vez de processar em paralelo e duplicar.lockedUntil expira e outro processo pode assumir. Sem isso, uma operação ficaria travada para sempre.Uma sutileza arquitetural importante: temos duas camadas de idempotência, e elas servem propósitos diferentes.
UNIQUE em idempotency_key, vista na Seção 5.4): é a última linha de defesa. Mesmo que toda a lógica de aplicação falhe, o banco de dados fisicamente recusa um segundo INSERT com a mesma chave. É a garantia de que, no fim das contas, o dinheiro só se move uma vez.Defesa em profundidade. Em segurança, você não confia numa única camada. Em dinheiro, a mesma regra: toda operação financeira deve ser idempotente em pelo menos duas camadas independentes. Se uma falhar, a outra segura.
Idempotência é exatamente o tipo de propriedade que você precisa testar de forma agressiva, porque o caso de falha (duplicar dinheiro) é silencioso em condições normais e só aparece sob concorrência.
import { describe, it, expect } from "vitest";
describe("Idempotência da transferência", () => {
it("retries concorrentes resultam em UM único débito", async () => {
const ledger = new InMemoryLedger();
const useCase = new TransferUseCase(ledger, noopEvents, fixedClock);
const key = "idem-key-fixa-123";
const cmd: TransferCommand = {
fromAccount: ana,
toAccount: bruno,
amount: Money.fromReais(500),
idempotencyKey: key,
};
// Dispara 10 retries SIMULTÂNEOS da mesma operação.
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => useCase.execute(cmd)),
);
// Todos retornam sucesso (ou conflito tratado), mas...
const balance = await ledger.getBalance(ana);
// ...o saldo de Ana caiu R$ 500 UMA única vez, não R$ 5.000.
expect(balance).toEqual(Money.fromReais(-500));
// E existe exatamente UMA transação persistida.
expect(ledger.transactionCount()).toBe(1);
});
});
Este teste é o tipo de coisa que, quando passa de forma confiável sob concorrência real, te deixa dormir à noite. E na era AI-Native, onde agentes refatoram seu código de pagamento, esse teste vira o guardrail que impede uma "otimização" bem-intencionada de reintroduzir o bug de double-spend.
Lá na Seção 2, o guardanapo nos disse: o sistema é 50x mais read-heavy do que write-heavy. E na Seção 5, o ledger nos deu uma fonte da verdade imutável e append-only. Esses dois fatos convergem para dois padrões que Martin Fowler ajudou a popularizar: Event Sourcing e CQRS (Command Query Responsibility Segregation).
A ideia central do Event Sourcing é radical em sua simplicidade: em vez de armazenar o estado atual, armazene a sequência de eventos que levaram a ele. O estado atual é uma derivação — você o reconstrói "dobrando" (folding) os eventos.
Repare que nosso ledger já é, essencialmente, um sistema event-sourced. Os entries imutáveis são os eventos. O saldo é a dobra (SUM) sobre eles. Event Sourcing apenas leva esse princípio a todo o domínio.
O que ganhamos:
// Os eventos do domínio são fatos imutáveis no passado.
// Nomeados no passado (Transferred, não Transfer) — eles JÁ aconteceram.
type LedgerEvent =
| { type: "AccountOpened"; accountId: string; at: string }
| { type: "MoneyDeposited"; accountId: string; amountCents: string; at: string }
| { type: "MoneyWithdrawn"; accountId: string; amountCents: string; at: string }
| { type: "MoneyTransferred"; from: string; to: string; amountCents: string; at: string };
// O estado é uma FUNÇÃO PURA dos eventos. Isto é o "fold".
function projectBalance(events: LedgerEvent[], accountId: string): bigint {
return events.reduce((balance, event) => {
switch (event.type) {
case "MoneyDeposited":
return event.accountId === accountId
? balance + BigInt(event.amountCents) : balance;
case "MoneyWithdrawn":
return event.accountId === accountId
? balance - BigInt(event.amountCents) : balance;
case "MoneyTransferred":
if (event.from === accountId) return balance - BigInt(event.amountCents);
if (event.to === accountId) return balance + BigInt(event.amountCents);
return balance;
default:
return balance;
}
}, 0n);
}
O saldo é literalmente events.reduce(...). Uma função pura, determinística, testável e — crucialmente — reconstruível. Se sua projeção de saldo corromper, você a joga fora e recalcula a partir dos eventos. A fonte da verdade nunca é perdida.
O leitor atento já percebeu o problema: uma conta com 5 anos e milhões de eventos não pode ter o saldo recalculado do zero a cada consulta. A solução, como no ledger da Seção 5.5, são snapshots: materializações periódicas do estado, a partir das quais você só precisa dobrar os eventos desde o último snapshot.
async function getCurrentBalance(accountId: string): Promise<bigint> {
// 1. Carrega o snapshot mais recente (otimização, não verdade).
const snapshot = await snapshotStore.getLatest(accountId);
const fromVersion = snapshot?.version ?? 0;
const baseBalance = snapshot?.balance ?? 0n;
// 2. Carrega apenas os eventos APÓS o snapshot (poucos).
const recentEvents = await eventStore.getEventsSince(accountId, fromVersion);
// 3. Dobra só o delta. Rápido mesmo para contas antigas.
return recentEvents.reduce(
(balance, e) => applyEvent(balance, e, accountId),
baseBalance,
);
}
CQRS (Command Query Responsibility Segregation) é a aplicação direta da nossa observação do guardanapo. Já que leitura e escrita têm requisitos radicalmente diferentes (17k TPS fortemente consistente vs. 870k TPS tolerante a consistência eventual), por que forçá-las a usar o mesmo modelo de dados?
Cada read model é otimizado para sua query específica:
| Read Model | Tecnologia | Otimizado para |
|---|---|---|
| Saldo atual | Redis | Latência sub-milissegundo, altíssimo volume |
| Extrato/histórico | Cassandra ou Postgres | Paginação, ordenação temporal, write-heavy de eventos |
| Analytics/BI | ClickHouse / colunar | Agregações pesadas, relatórios, ML |
| Busca | Elasticsearch | Busca textual em transações ("aquele pix do mercado") |
// Um projector consome eventos e mantém um read model específico.
// Note: cada projector é INDEPENDENTE. Se o de analytics cair,
// o de saldo continua funcionando. Isolamento de falhas.
class StatementProjector {
constructor(private readonly readDb: CassandraClient) {}
// Consome do event bus, idempotentemente (note o checkpoint).
async on(event: LedgerEvent): Promise<void> {
if (event.type !== "MoneyTransferred") return;
// Desnormaliza para exibição rápida no extrato.
// Duplicamos dados de propósito — leitura barata > normalização.
await this.readDb.execute(
`INSERT INTO statement_by_account (account_id, occurred_at, tx_id, description, amount_cents, balance_after)
VALUES (?, ?, ?, ?, ?, ?)`,
[event.from, event.at, event.txId, "PIX enviado",
`-${event.amountCents}`, await this.computeBalanceAfter(event.from)],
);
await this.readDb.execute(
`INSERT INTO statement_by_account (...)`,
[event.to, event.at, event.txId, "PIX recebido",
`+${event.amountCents}`, await this.computeBalanceAfter(event.to)],
);
// Avança o checkpoint para retomar de onde parou após um crash.
await this.checkpointStore.advance(this.projectorName, event.position);
}
}
CQRS não é grátis. O custo é a consistência eventual entre o write side e os read models. Você transfere R$ 100, mas por alguns milissegundos o read model de extrato pode ainda não mostrar a transação. Como lidar?
A resposta é uma combinação de design técnico e de produto:
A lição de Fowler sobre CQRS: Ele mesmo alerta que CQRS adiciona complexidade significativa e não deve ser o padrão. Use-o onde a assimetria leitura/escrita justifica — e em um banco de 100M de contas com 50:1 de razão R/W, justifica no núcleo transacional. Mas o serviço de onboarding ou o de configurações de perfil? Provavelmente CRUD simples basta. Aplique CQRS cirurgicamente, não religiosamente.
A maturidade arquitetural não está em usar todos os padrões; está em saber onde cada padrão paga seu custo. O ledger merece event sourcing e CQRS. A tela de "alterar foto de perfil" não.
Chegamos a um dos problemas mais difíceis de sistemas distribuídos. Uma transferência PIX para outro banco envolve múltiplos serviços e sistemas externos: nosso Payments Service, nosso Ledger, o Fraud Service, e o SPI do BACEN. Cada um tem seu próprio banco de dados. Como garantir consistência através de fronteiras transacionais que não compartilham um banco?
A resposta acadêmica seria o Two-Phase Commit (2PC): um coordenador pergunta a todos os participantes "podem commitar?", e só commita se todos disserem sim. Na prática, em fintechs de escala, 2PC é veneno:
Como Pat Helland argumentou no clássico "Life Beyond Distributed Transactions": em escala, você abandona transações distribuídas e abraça consistência através de atividades coordenadas com compensação. Esse é o padrão Saga.
Uma Saga é uma sequência de transações locais. Cada transação local atualiza um serviço e publica um evento ou comando que dispara a próxima. Se um passo falha, a Saga executa transações compensatórias que desfazem os passos anteriores. Em vez de atomicidade (tudo ou nada instantâneo), você tem consistência eventual com compensação (tudo se completa, ou tudo é desfeito eventualmente).
Há duas formas de implementar sagas, e a escolha tem implicações profundas:
Vamos modelar a saga do PIX como uma máquina de estados explícita e persistente. A persistência é crucial: se o orchestrator cair no meio, ele precisa retomar a saga exatamente de onde parou.
// Estados da saga de PIX externo
type PixSagaState =
| "started"
| "fraud_checked"
| "funds_reserved"
| "spi_dispatched"
| "completed"
| "compensating"
| "compensated" // rollback bem-sucedido
| "failed"; // estado terminal de erro irrecuperável
interface PixSaga {
id: string;
state: PixSagaState;
command: PixCommand;
reservationId?: string; // referência do hold no ledger (p/ compensar)
spiE2eId?: string; // id ponta-a-ponta no SPI
attempts: number;
lastError?: string;
}
class PixSagaOrchestrator {
constructor(
private readonly sagaStore: SagaStore,
private readonly fraud: FraudService,
private readonly ledger: LedgerService,
private readonly spi: SpiGateway,
) {}
// Avança a saga um passo. Idempotente e retomável.
async step(sagaId: string): Promise<void> {
const saga = await this.sagaStore.load(sagaId);
try {
switch (saga.state) {
case "started":
await this.checkFraud(saga);
break;
case "fraud_checked":
await this.reserveFunds(saga);
break;
case "funds_reserved":
await this.dispatchToSpi(saga);
break;
case "spi_dispatched":
await this.awaitSpiConfirmation(saga);
break;
case "compensating":
await this.compensate(saga);
break;
// completed/compensated/failed: terminais, nada a fazer.
}
} catch (err) {
// Falha em qualquer passo dispara compensação.
await this.transition(saga, "compensating", { lastError: String(err) });
// Reenfileira o passo de compensação.
await this.scheduler.enqueue(sagaId);
}
}
private async reserveFunds(saga: PixSaga): Promise<void> {
// Idempotente: usa o saga.id como idempotency key.
const reservation = await this.ledger.reserve({
account: saga.command.fromAccount,
amount: saga.command.amount,
idempotencyKey: `saga-reserve-${saga.id}`,
});
await this.transition(saga, "funds_reserved", {
reservationId: reservation.id,
});
await this.scheduler.enqueue(saga.id); // próximo passo
}
private async dispatchToSpi(saga: PixSaga): Promise<void> {
const result = await this.spi.sendPix({
e2eId: `saga-${saga.id}`, // idempotência no SPI também
amount: saga.command.amount,
destination: saga.command.destination,
});
await this.transition(saga, "spi_dispatched", { spiE2eId: result.e2eId });
await this.scheduler.enqueue(saga.id);
}
// A COMPENSAÇÃO: desfaz na ordem inversa o que já foi feito.
private async compensate(saga: PixSaga): Promise<void> {
// Só compensa o que efetivamente aconteceu.
if (saga.reservationId) {
// Libera o hold. Idempotente: liberar duas vezes não causa dano.
await this.ledger.releaseReservation({
reservationId: saga.reservationId,
idempotencyKey: `saga-release-${saga.id}`,
});
}
await this.transition(saga, "compensated");
// Notifica o cliente que o PIX não foi concluído e o dinheiro voltou.
await this.notifier.pixFailed(saga.command.fromAccount, saga.command.amount);
}
private async transition(
saga: PixSaga,
to: PixSagaState,
patch: Partial<PixSaga> = {},
): Promise<void> {
// Persiste o novo estado ATOMICAMENTE. Se cair aqui, retomamos daqui.
await this.sagaStore.save({ ...saga, ...patch, state: to });
}
}
Há uma sutileza vital sobre transações compensatórias em um banco: você não pode "deletar" um lançamento. Lembre-se — os entries são imutáveis. Então como você "desfaz" um débito?
A resposta vem, novamente, da contabilidade de 1494: você posta um lançamento de estorno (reversal). Para desfazer um débito de R$ 1.000, você não apaga nada — você posta um crédito de R$ 1.000. O histórico mostra ambos: o débito original e o estorno. Auditabilidade preservada, dinheiro de volta.
Compensação ≠ rollback. Um rollback de banco apaga como se nada tivesse acontecido. Uma compensação de saga reconhece que algo aconteceu e adiciona uma ação que neutraliza o efeito. Em sistemas financeiros, a compensação é sempre a forma certa — porque "fingir que nada aconteceu" é mentir para o regulador. A trilha de auditoria deve mostrar a verdade: tentou, falhou, estornou.
saga.id como idempotency key.compensating é um sinal de incêndio.A Saga é, no fundo, um reconhecimento humilde: em sistemas distribuídos, não existe o botão mágico de "atomicidade global barata". O que existe é disciplina — passos pequenos, idempotentes, compensáveis e observáveis. Pat Helland estava certo: a vida além das transações distribuídas é possível, mas exige que você projete para a falha desde o primeiro dia.
Antes de um centavo se mover, um cliente precisa existir no sistema — e existir legalmente. O onboarding de um banco não é um "formulário de cadastro"; é um processo regulado onde erros têm consequências criminais (lavagem de dinheiro, financiamento ao terrorismo). É também, paradoxalmente, o ponto onde a maioria dos clientes desiste. O design precisa equilibrar rigor regulatório com fricção mínima.
KYC (Know Your Customer) é a obrigação legal de verificar a identidade de quem abre uma conta. No Brasil, é regulado pelo BACEN e pela legislação de PLD/AML (Prevenção à Lavagem de Dinheiro). Um banco que abre contas sem KYC adequado não tem um bug — tem um problema com a Polícia Federal.
As camadas de verificação:
Uma conta bancária não é um booleano ativo/inativo. É uma máquina de estados rica, onde cada transição tem regras e implicações regulatórias. Modelá-la explicitamente previne classes inteiras de bugs ("como esse cliente bloqueado conseguiu fazer um PIX?").
// O ciclo de vida codificado. Transições inválidas são impossíveis.
type AccountStatus =
| "pending_kyc"
| "under_review"
| "active"
| "limited" // pode receber, mas não enviar (suspeita)
| "blocked" // congelada totalmente
| "closed";
// Tabela de transições permitidas. Fonte única da verdade.
const ALLOWED_TRANSITIONS: Record<AccountStatus, AccountStatus[]> = {
pending_kyc: ["under_review", "closed"],
under_review: ["active", "rejected" as AccountStatus, "closed"],
active: ["limited", "blocked", "closed"],
limited: ["active", "blocked", "closed"],
blocked: ["active", "closed"],
closed: [], // estado terminal: nada sai daqui
};
class Account {
constructor(
public readonly id: AccountId,
private status: AccountStatus,
) {}
transitionTo(newStatus: AccountStatus, reason: string): AccountStatusChanged {
const allowed = ALLOWED_TRANSITIONS[this.status];
if (!allowed.includes(newStatus)) {
// Transição ilegal. Ex.: tentar reativar uma conta 'closed'.
throw new IllegalAccountTransition(this.status, newStatus);
}
const previous = this.status;
this.status = newStatus;
// Toda mudança de estado é um evento auditável (event sourcing).
return new AccountStatusChanged(this.id, previous, newStatus, reason);
}
// Regras de negócio derivadas do estado.
canSendMoney(): boolean {
return this.status === "active";
}
canReceiveMoney(): boolean {
return this.status === "active" || this.status === "limited";
}
}
Repare na assimetria deliberada em limited: uma conta sob suspeita pode receber mas não enviar. Por quê? Porque congelar o recebimento de um salário legítimo causa dano real a um cliente possivelmente inocente, enquanto bloquear o envio contém o risco de uma conta-laranja drenar fundos. É a assimetria fail-safe da Seção 1 aplicada ao ciclo de vida.
Verificações de KYC (consultar bureaus, rodar biometria, checar listas de sanções) levam de segundos a minutos, e dependem de sistemas externos que falham. Bloquear o cliente numa tela de loading por 2 minutos é morte por abandono. A solução é o onboarding progressivo: dê acesso limitado imediatamente e expanda conforme as verificações concluem.
// Saga de onboarding: orquestra verificações assíncronas.
class OnboardingSaga {
async start(cmd: StartOnboardingCommand): Promise<void> {
// 1. Cria a conta em 'pending_kyc' IMEDIATAMENTE. Cliente não espera.
const account = await this.accounts.create(cmd.cpf, "pending_kyc");
// 2. Dispara verificações em PARALELO (não sequencial — latência importa).
await this.scheduler.enqueueAll([
{ task: "validate_cpf", accountId: account.id },
{ task: "run_biometrics", accountId: account.id },
{ task: "check_sanctions", accountId: account.id },
]);
// 3. Dá ao cliente acesso LIMITADO já (ver saldo zerado, explorar app).
// Movimentação financeira só libera quando o KYC completa.
}
// Cada verificação, ao concluir, reavalia se pode promover a conta.
async onVerificationCompleted(ev: VerificationCompleted): Promise<void> {
const checks = await this.getAllChecks(ev.accountId);
if (checks.every((c) => c.status === "passed")) {
await this.accounts.transition(ev.accountId, "active", "KYC completo");
await this.notifier.accountActivated(ev.accountId);
} else if (checks.some((c) => c.status === "failed")) {
await this.accounts.transition(ev.accountId, "under_review", "checagem falhou");
await this.caseManagement.openManualReview(ev.accountId);
}
}
}
A integração com os bureaus externos sempre passa por um Anti-Corruption Layer (Seção 3.3): o modelo bagunçado e instável do fornecedor de biometria nunca contamina o nosso domínio. Traduzimos a resposta deles para o nosso vocabulário (VerificationResult) na fronteira, e o resto do sistema nem sabe qual fornecedor usamos. Trocar de fornecedor vira uma mudança localizada, não uma cirurgia de coração aberto.
Agora juntamos as peças no fluxo mais visível de um banco: mover dinheiro de verdade, para o mundo de verdade. Vamos detalhar os dois caminhos mais críticos — PIX (instantâneo, brasileiro, regulado pelo BACEN) e autorização de cartão (o SLA mais brutal de toda a arquitetura).
O PIX é uma maravilha de engenharia de pagamentos, e um pesadelo de consistência distribuída. Ele precisa ser instantâneo (< 10s ponta-a-ponta por exigência regulatória), 24x7, e irrevogável. Vamos rastrear o caminho completo:
O ponto sutil aqui é o padrão reserva-então-captura (hold-then-capture). Não debitamos definitivamente antes de o SPI confirmar — fazemos uma reserva (hold). Por quê? Porque se o SPI rejeitar (conta destino inexistente, por exemplo), reverter uma reserva é limpo (vide compensação da Seção 8.5), enquanto reverter um débito já efetivado e refletido no extrato do cliente é confuso e assustador para ele.
// O hold reduz o saldo DISPONÍVEL sem mexer no saldo CONTÁBIL.
// Esta distinção (Seção 3.1) é o que torna a reserva possível.
class LedgerService {
async reserve(params: ReserveParams): Promise<Reservation> {
return this.idempotent(params.idempotencyKey, async () => {
const available = await this.getAvailableBalance(params.account);
// Verificação de fundos contra o saldo DISPONÍVEL (já líquido de holds).
if (available.isLessThan(params.amount)) {
throw new InsufficientFunds(params.account, available, params.amount);
}
// O hold é um lançamento numa conta de "reservas pendentes",
// não um débito definitivo. Saldo contábil intacto; disponível cai.
const reservation = await this.postHold({
account: params.account,
amount: params.amount,
expiresAt: this.clock.now().plus({ seconds: 30 }), // auto-expira
});
return reservation;
});
}
async capture(reservationId: string): Promise<void> {
// Converte o hold em débito definitivo: posta a transação real
// e libera o hold, atomicamente. Agora sim o dinheiro saiu.
await this.postTransactionAndReleaseHold(reservationId);
}
}
Receber um PIX parece simples ("apareceu dinheiro, credita o cliente"), mas esconde uma armadilha de consistência: o crédito no nosso ledger e a liquidação no BACEN são eventos distintos que precisam ser reconciliados. Creditamos o cliente na hora (boa UX), mas reconciliamos contra o extrato oficial do SPI ao longo do dia. Divergências (raras, mas possíveis) viram casos de investigação. A conta de sistema PIX_LIQUIDACAO_SPI da Seção 5.3 é exatamente o instrumento dessa reconciliação.
Se o ledger é o coração, a autorização de cartão é o sistema nervoso reflexo. Quando você passa o cartão na maquininha, as bandeiras (Visa/Mastercard) impõem um SLA duro: responda em ~100ms ou a transação é negada por timeout. Não há "tente de novo mais tarde". Ou você responde a tempo, ou perde a venda e irrita o cliente no caixa do supermercado.
Para bater 100ms, o caminho de autorização sacrifica generalidade por velocidade:
class CardAuthorizationService {
// Orçamento de latência total: ~100ms. Cada chamada tem deadline.
async authorize(req: AuthRequest): Promise<AuthDecision> {
const deadline = this.clock.now().plus({ milliseconds: 100 });
// 1. Saldo via cache (rápido). Se o cache falhar, fallback controlado.
const available = await this.withTimeout(
this.balanceCache.get(req.cardAccount),
{ ms: 10, onTimeout: () => this.conservativeFallback(req) },
);
if (available.isLessThan(req.amount)) {
return AuthDecision.decline("insufficient_funds");
}
// 2. Antifraude inline, só regras baratas. Deadline curto.
const risk = await this.withTimeout(
this.fraud.inlineScore(req),
{ ms: 30, onTimeout: () => this.fraudFallback(req) },
);
if (risk.shouldBlock) {
return AuthDecision.decline("fraud_suspected");
}
// 3. Hold otimista. Captura definitiva vem no settlement batch.
await this.ledger.reserve({
account: req.cardAccount,
amount: req.amount,
idempotencyKey: `card-auth-${req.authId}`,
});
return AuthDecision.approve(req.authId);
}
// Quando um sistema dependente excede o deadline, decidимos
// conservadoramente: baixo valor pode passar, alto valor nega.
private conservativeFallback(req: AuthRequest): Money {
// Sem saldo confiável, assume o pior para valores altos.
return req.amount.isGreaterThan(Money.fromReais(50))
? Money.zero() // força decline em valores altos
: this.lastKnownBalance(req.cardAccount);
}
}
O contraste entre PIX e cartão é instrutivo. O PIX otimiza para correção e irrevogabilidade (reserva, confirma, captura, reconcilia). O cartão otimiza para latência sob um SLA externo brutal (cache, fallback, settlement diferido). Mesma empresa, mesmo ledger no fundo, mas perfis de engenharia opostos. Não existe uma "arquitetura de pagamentos" única — existe a arquitetura certa para cada produto de pagamento, todas ancoradas no mesmo ledger de partidas dobradas.
A fraude é o imposto inevitável de operar um banco. Fraudadores são criativos, organizados e incansáveis. O sistema de antifraude é onde a engenharia encontra a guerra adversarial — e onde a latência e a correção colidem de forma especialmente cruel. Você tem ~30ms para decidir se uma transação é legítima, sabendo que tanto aprovar fraude quanto bloquear um cliente legítimo têm custos altos.
A chave do design é reconhecer que nem toda análise precisa caber nos 30ms do caminho síncrono. Separamos em duas camadas:
As regras mais eficazes (e mais baratas) são as de velocity — detectar comportamento anômalo na frequência ou volume das transações. "Esta conta nunca gastou mais de R$ 200, e agora está tentando 5 PIX de R$ 5.000 em 2 minutos para contas novas" é um sinal clássico de conta comprometida.
// Engine de regras de velocity. Usa janelas deslizantes em Redis.
class VelocityRuleEngine {
async evaluate(tx: TransactionContext): Promise<RiskSignal[]> {
const signals: RiskSignal[] = [];
// Regra 1: volume em janela curta. Conta que dispara muitos
// PIX em pouco tempo é um padrão clássico de drenagem.
const last5min = await this.counter.sum(
`velocity:amount:${tx.accountId}`,
{ window: minutes(5) },
);
if (last5min.plus(tx.amount).isGreaterThan(this.dynamicLimit(tx))) {
signals.push({ rule: "high_velocity_amount", weight: 0.7 });
}
// Regra 2: desvio do comportamento histórico do cliente.
const profile = await this.profiles.get(tx.accountId);
if (tx.amount.isGreaterThan(profile.p95TransactionAmount.times(5))) {
// 5x acima do percentil 95 habitual: suspeito.
signals.push({ rule: "amount_anomaly", weight: 0.5 });
}
// Regra 3: destinos novos em sequência (padrão de mula financeira).
const newDestinations = await this.countNewDestinations(
tx.accountId, minutes(10),
);
if (newDestinations >= 3) {
signals.push({ rule: "many_new_destinations", weight: 0.6 });
}
// Regra 4: geolocalização impossível (login em SP, tx no PA em 5min).
if (await this.impossibleTravel(tx)) {
signals.push({ rule: "impossible_travel", weight: 0.8 });
}
return signals;
}
}
Sinais individuais raramente decidem sozinhos. Você combina os pesos num score e aplica limiares — mas o limiar não é único: ele varia com o valor e o contexto. Negar um café de R$ 8 com base numa suspeita fraca irrita um cliente bom; negar um PIX de R$ 10.000 com a mesma suspeita pode salvar a poupança de alguém.
class FraudDecisionEngine {
decide(signals: RiskSignal[], tx: TransactionContext): FraudDecision {
// Score combinado (poderia ser um modelo; aqui, soma ponderada).
const score = signals.reduce((sum, s) => sum + s.weight, 0);
// Limiares ADAPTATIVOS ao risco financeiro da operação.
// Quanto maior o valor, mais conservadores somos. Assimetria fail-safe.
const thresholds = this.adaptiveThresholds(tx.amount);
if (score >= thresholds.block) {
return FraudDecision.block(signals); // nega e abre investigação
}
if (score >= thresholds.challenge) {
// Não nega: DESAFIA. Pede 2FA, biometria, confirmação.
// Atrito proporcional ao risco — não fricção cega.
return FraudDecision.challenge(signals);
}
return FraudDecision.allow();
}
private adaptiveThresholds(amount: Money): { challenge: number; block: number } {
if (amount.isGreaterThan(Money.fromReais(5000))) {
return { challenge: 0.3, block: 0.6 }; // valores altos: pouca tolerância
}
if (amount.isGreaterThan(Money.fromReais(500))) {
return { challenge: 0.5, block: 0.8 };
}
return { challenge: 0.8, block: 0.95 }; // micro-valores: alta tolerância
}
}
Repare na opção do meio: desafiar em vez de aprovar ou negar. Esta é uma das ideias mais subestimadas do antifraude. Em vez da decisão binária aprovar/negar, introduzimos uma terceira via: pedir uma autenticação adicional (2FA, biometria, confirmação no app). Isso converte um falso positivo doloroso (negar um cliente bom) numa pequena fricção (confirmar uma vez), preservando a experiência e a segurança.
Os fraudadores mais sofisticados operam em redes — dezenas de contas-laranja coordenadas para lavar dinheiro. Você não os pega olhando transações isoladas; você os pega olhando o grafo de relacionamentos entre contas, dispositivos e destinos.
Três contas "diferentes" que compartilham o mesmo dispositivo e convergem dinheiro para um destino comum: isso é uma rede. A análise de grafos (rodada no caminho offline 🔵, com bancos de dados de grafo ou processamento batch sobre o event store) revela esses padrões que nenhuma regra de transação isolada captura. Quando uma rede é identificada, todas as contas envolvidas transicionam para blocked ou limited (Seção 9.2) de uma vez.
A guerra adversarial nunca termina. Diferente de um bug que você conserta de vez, a fraude é um adversário que aprende. Cada defesa que você ergue, eles testam e contornam. Por isso o sistema de antifraude precisa ser, ele próprio, um sistema evolutivo: feature flags para ligar/desligar regras rápido, A/B testing de modelos, e um feedback loop onde cada fraude confirmada vira dado de treino. A arquitetura precisa abraçar a mudança constante — é o oposto do ledger, que valoriza imutabilidade. Dois subsistemas com filosofias opostas, convivendo no mesmo banco.
Chegamos ao terreno onde a teoria de sistemas distribuídos encontra a realidade do dinheiro. Toda decisão de arquitetura até aqui — consistência forte no ledger, eventual nos read models, sagas em vez de 2PC — é, no fundo, uma resposta a um único teorema implacável.
O Teorema CAP, formulado por Eric Brewer, é frequentemente reduzido a "escolha 2 de 3: Consistência, Disponibilidade, Tolerância a Partição". Essa formulação é, tecnicamente, errada e enganosa. A versão correta, refinada pelo próprio Brewer e por Martin Kleppmann:
Em um sistema distribuído, partições de rede são inevitáveis (o "P" não é opcional — cabos se rompem, switches falham, latência explode). Portanto, a escolha real é: quando uma partição acontecer, você prioriza Consistência (CP) ou Disponibilidade (AP)?
Para o núcleo transacional de um banco, a escolha é inequívoca: CP. Consistência sobre disponibilidade. Pense no cenário: uma partição de rede separa dois data centers, e ambos acham que são o "líder" da conta da Ana, que tem R$ 1.000.
Esta é a aplicação direta da assimetria fail-safe da Seção 1. Um banco prefere mil vezes dizer "não consigo processar agora" do que processar errado. A indisponibilidade temporária é recuperável; um rombo contábil, não.
// O ledger exige QUÓRUM para escrever. Sem quórum, recusa.
// Esta é a escolha CP codificada.
class QuorumLedgerWriter {
async write(transaction: Transaction): Promise<void> {
const replicas = this.cluster.replicasFor(transaction.partitionKey);
const quorum = Math.floor(replicas.length / 2) + 1; // maioria
const acks = await this.writeToReplicas(transaction, replicas, {
timeout: ms(50),
});
if (acks.successful < quorum) {
// NÃO conseguimos quórum (provável partição). RECUSAMOS.
// Melhor um erro honesto do que uma escrita que pode divergir.
throw new QuorumNotReachedError(
`Apenas ${acks.successful}/${replicas.length} réplicas confirmaram. ` +
`Recusando a escrita para preservar consistência.`,
);
}
// Quórum atingido: a escrita é durável e consistente.
}
}
Seria um erro, porém, achar que "banco = tudo fortemente consistente". Isso mataria a escalabilidade e a disponibilidade. A maestria está em mapear cada parte do sistema para o modelo de consistência certo:
| Componente | Modelo | Justificativa |
|---|---|---|
| Ledger (escrita) | CP, forte | Double-spend é inaceitável |
| Saldo (leitura) | CP via read-your-writes | Cliente deve ver sua própria tx na hora |
| Extrato/histórico | AP, eventual | Atraso de segundos é tolerável |
| Notificações | AP, eventual | Push 2s depois não causa dano |
| Analytics/BI | AP, eventual | Relatórios toleram horas de atraso |
| Perfil de risco | AP, eventual | Atualiza para próximas tx, não a atual |
| Limites de fraude (velocity) | AP, eventual | Pequena janela de inconsistência aceitável (com margem de segurança) |
A genialidade dessa abordagem é que a maior parte do tráfego (lembre: 50:1 leitura/escrita) cai nas linhas AP, que escalam horizontalmente sem limite. Apenas o fino núcleo de escrita transacional paga o custo da consistência forte. Você não sacrifica escala global no altar da correção; você isola a parte que precisa de correção e deixa o resto voar.
CAP só fala sobre o comportamento durante partições. Mas e no dia a dia normal, sem partições? O teorema PACELC (de Daniel Abadi) completa a história:
Se há Partição, escolha entre Availability e Consistency; Else (no caso normal), escolha entre Latency e Consistency.
Ou seja: mesmo sem partições, há um trade-off entre latência e consistência. Replicação síncrona (esperar todas as réplicas confirmarem) dá consistência forte mas adiciona latência. Replicação assíncrona é rápida mas pode servir dados levemente desatualizados.
Nossa classificação PACELC completa para o banco:
Esse vocabulário (PC/EC, PA/EL) não é pedantismo acadêmico. É uma ferramenta de comunicação precisa: quando um engenheiro propõe "vamos cachear o saldo com TTL de 5 segundos", a pergunta certa é "isso é EL ou EC? E se o cliente fizer uma tx e recarregar em 1s, ele vê?". O vocabulário torna o trade-off explícito e debatível, em vez de uma decisão acidental enterrada no código.
Por fim, uma humildade necessária. A velocidade da luz é finita. Uma ida-e-volta entre data centers em São Paulo e na Virgínia (EUA) custa ~120ms no melhor caso físico possível, independente de quão bom seja seu código. Isso significa que consistência forte global é fisicamente cara.
A consequência arquitetural: dados que precisam de consistência forte devem ser geograficamente localizados. A conta da Ana "vive" em uma região (provavelmente perto dela), e a escrita forte acontece ali. Replicação para outras regiões é assíncrona (para disaster recovery, não para escrita forte cross-region). Tentar manter um único ledger globalmente sincronizado de forma síncrona é lutar contra a física — e a física sempre ganha. É por isso que o sharding por conta (Seção 15) não é só sobre escala; é sobre respeitar os limites do universo.
Um banco digital opera sob escrutínio que sistemas comuns nunca conhecem. Aqui, "segurança" não é uma feature — é uma condição de existência. E o "compliance" não é burocracia opcional: é o que mantém a licença bancária válida e os executivos fora da cadeia. Vamos cobrir as camadas que transformam um sistema funcional em um sistema autorizado a tocar no dinheiro dos outros.
Nenhuma camada confia na anterior. Se o WAF falha, a autenticação ainda protege. Se a autenticação é burlada, a autorização limita o estrago. Se os dados vazam, a criptografia os torna inúteis. É o princípio que Uncle Bob aplica a fronteiras de código, aplicado a fronteiras de segurança: cada camada assume que as outras podem falhar.
// Dados sensíveis são criptografados em nível de campo, com chaves
// gerenciadas por um KMS/HSM. A aplicação nunca vê a chave mestra.
class FieldEncryptionService {
constructor(private readonly kms: KeyManagementService) {}
async encryptPII(plaintext: string, context: EncryptionContext): Promise<EncryptedField> {
// Envelope encryption: o KMS gera uma data key; criptografamos o
// dado com ela e guardamos a data key criptografada junto.
const { plaintextKey, encryptedKey } = await this.kms.generateDataKey();
const ciphertext = aesGcmEncrypt(plaintext, plaintextKey, context);
plaintextKey.zeroize(); // limpa a chave da memória imediatamente
return { ciphertext, encryptedKey, context };
}
}
Se você toca em dados de cartão (PAN — Primary Account Number), você cai sob o PCI-DSS, um padrão rigoroso da indústria. A melhor estratégia arquitetural é reduzir o escopo: quanto menos seus sistemas tocam o PAN em claro, menor a superfície de compliance. A técnica-chave é a tokenização — substituir o número real do cartão por um token sem valor fora do seu vault.
O token (tok_a1b2c3d4) circula livremente pelos seus sistemas — em logs, em bancos de dados, em mensagens — porque ele é inútil para um atacante. Só o vault isolado (o único ambiente que precisa de certificação PCI completa) sabe mapeá-lo de volta ao PAN real. Isso reduz drasticamente o custo e o risco de compliance.
A LGPD (Lei Geral de Proteção de Dados) impõe direitos que precisam ser projetados no sistema, não remendados depois:
A tensão fundamental do compliance: LGPD diz "delete os dados do cliente"; BACEN diz "retenha os registros por 5 anos". Estas obrigações parecem contraditórias, e resolvê-las exige sofisticação de modelagem: separar a identidade pessoal (que pode ser anonimizada) da trilha transacional (que deve ser preservada, mas pode ser desvinculada da pessoa). Um lançamento no ledger pode permanecer ("uma transação de R$ X ocorreu nesta conta"), enquanto os dados que ligam aquela conta a um CPF específico são anonimizados. Esse é o tipo de problema que você precisa resolver no modelo de domínio (Seção 3), não num script de migração às pressas.
Talvez a maior mudança de mentalidade para quem vem de fora de fintech: o BACEN é um usuário do seu sistema. Ele consome relatórios, exige SLAs, audita sua arquitetura e pode te multar ou cassar sua licença. Funcionalidades que servem o regulador não são "nice to have":
A arquitetura de um banco, portanto, tem um requisito que sistemas comuns ignoram: ela precisa ser explicável e auditável por terceiros hostis. Não basta funcionar; precisa provar que funciona corretamente. E é exatamente por isso que escolhas como o ledger imutável e o event sourcing — que parecem "over-engineering" para um app comum — são, num banco, o caminho de menor resistência.
Você não pode operar o que não consegue enxergar. Em um banco, observabilidade transcende "monitoramento de uptime" — ela precisa responder perguntas como "o dinheiro que entrou bate com o que saiu nas últimas 24h?" e "por que esta transação específica do cliente X demorou 800ms?". Observabilidade aqui é uma combinação de SRE clássico com observabilidade financeira.
A observabilidade tradicional tem três pilares: métricas, logs e traces. Em fintech, adicionamos um quarto, igualmente vital: invariantes de negócio.
Os três primeiros são padrão da indústria. O quarto é o que faz a diferença: monitorar continuamente as invariantes financeiras e alarmar no instante em que uma quebra. A query SELECT SUM(amount) FROM entries da Seção 5.2 não é um teste pontual — é uma métrica monitorada 24x7. Se ela sair de zero, é um incidente P0 antes que qualquer cliente perceba.
// Métricas técnicas (Golden Signals do Google SRE) + financeiras.
class BankingMetrics {
recordAuthorization(result: AuthDecision, latencyMs: number): void {
// Latência: o SLA de cartão (P99 < 100ms) é monitorado aqui.
this.histogram("card.auth.latency_ms", latencyMs, {
decision: result.outcome,
});
// Taxa de aprovação: uma QUEDA súbita pode indicar incidente
// (ledger lento? fraude bloqueando demais? bug?).
this.counter("card.auth.total", 1, { decision: result.outcome });
}
// MÉTRICA FINANCEIRA: a invariante sagrada, monitorada em tempo real.
recordLedgerInvariant(globalSum: bigint): void {
this.gauge("ledger.global_sum_cents", Number(globalSum));
if (globalSum !== 0n) {
// Alerta IMEDIATO. Dinheiro foi criado/destruído.
this.alert(Severity.P0, "Invariante de partidas dobradas violada");
}
}
// Discrepância de reconciliação: nosso ledger vs. extrato do BACEN.
recordReconciliationGap(source: string, gapCents: bigint): void {
this.gauge("reconciliation.gap_cents", Number(gapCents), { source });
}
}
Numa arquitetura de microsserviços, uma única transação atravessa Payments → Fraud → Ledger → SPI. Quando algo dá errado ou fica lento, você precisa de distributed tracing (OpenTelemetry) para ver a jornada completa, com um trace_id propagado por todos os serviços.
Com o trace acima, fica óbvio onde está o tempo: o SPI (externo) domina a latência (~205ms dos 310ms totais). Isso direciona a otimização para o lugar certo — e te impede de perder semanas otimizando o Fraud Service (que gasta meros 25ms) quando o gargalo está na confirmação externa.
Um detalhe crítico e fácil de errar: logs não podem conter dados sensíveis. Logar um PAN de cartão, um CPF ou um saldo em texto plano transforma seu sistema de logs (geralmente menos protegido que o banco de dados) num vazamento de dados ambulante. Logs estruturados precisam de redaction automática.
// Toda saída de log passa por um sanitizador que mascara PII.
class SafeLogger {
private readonly SENSITIVE = /\b(\d{11}|\d{16})\b/g; // CPF, PAN
info(message: string, context: Record<string, unknown>): void {
this.sink.write({
level: "info",
message,
// Mascara CPF/PAN e remove campos sensíveis conhecidos.
context: this.redact(context),
trace_id: currentTraceId(), // correlação sem expor dado
});
}
private redact(ctx: Record<string, unknown>): Record<string, unknown> {
const safe = { ...ctx };
delete safe.cardNumber;
delete safe.cpf;
delete safe.balance; // saldo é PII financeira
for (const k of Object.keys(safe)) {
if (typeof safe[k] === "string") {
safe[k] = (safe[k] as string).replace(this.SENSITIVE, "***REDACTED***");
}
}
return safe;
}
}
Um banco gera milhões de eventos. Alarmar em tudo é alarmar em nada — a equipe de plantão fica anestesiada (alert fatigue) e perde o sinal real no ruído. A disciplina é alarmar em sintomas que afetam o cliente ou violam invariantes, não em causas intermediárias:
A regra de ouro do SRE aplicada a dinheiro: alarme no que o cliente sente e no que o regulador audita. O resto é dashboard, não pager.
Voltamos aos números do guardanapo (Seção 2): 100M de contas, ~17k TPS de escrita no pico, 2,5 PB de ledger. Nenhum banco de dados único aguenta isso. A escalabilidade de um banco é, fundamentalmente, um exercício de particionar dinheiro sem quebrar a consistência — e fazê-lo respeitando os limites físicos da Seção 12.5.
A boa notícia é que dados bancários têm uma fronteira de particionamento natural e quase perfeita: a conta. A esmagadora maioria das operações envolve uma ou duas contas, e raramente cruzam fronteiras arbitrárias. Particionamos (shardamos) o ledger por uma chave derivada da conta.
// Roteamento de shard determinístico. A mesma conta sempre cai
// no mesmo shard — essencial para consistência forte localizada.
class ShardRouter {
constructor(private readonly shardCount: number) {}
shardFor(accountId: AccountId): ShardId {
// Hash consistente para distribuir uniformemente e permitir
// rebalanceamento incremental ao adicionar shards.
const hash = consistentHash(accountId.value);
return new ShardId(hash % this.shardCount);
}
}
Cada shard é fortemente consistente internamente (com seu próprio quórum de réplicas, Seção 12.2). A consistência forte que precisamos é sempre dentro de uma conta — e a conta vive inteira em um shard. Resolvemos a escala sem abrir mão da correção onde ela importa.
"Mas e uma transferência entre a conta da Ana (Shard 1) e a do Bruno (Shard 3)?" Aqui o particionamento cobra seu preço. Uma transação que cruza shards não pode usar uma única transação de banco atômica. As opções:
A Opção B (conta de trânsito) é particularmente elegante e muito usada na prática: a transferência cross-shard vira duas transações locais (cada uma atômica em seu shard), ligadas por uma conta de sistema de trânsito:
A conta de trânsito é uma conta de sistema (Seção 5.3) que serve de "ponte" entre shards. Se o passo 2 falhar, a saga compensa o passo 1. E a invariante zero-sum global continua válida, porque a conta de trânsito sempre volta a zero quando a transferência completa. As partidas dobradas de Pacioli, mais uma vez, dão a estrutura para resolver um problema que ele jamais imaginou.
Sharding uniforme assume tráfego uniforme — mas algumas contas são muito mais quentes que outras. A conta de um grande recebedor (um marketplace, uma concessionária recebendo milhares de pagamentos) pode sobrecarregar seu shard. Estratégias:
UPDATE em um campo de saldo. Inserções append-only escalam muito melhor que updates contenciosos — não há linha única a ser travada. Este é mais um dividendo do modelo de partidas dobradas: ele é naturalmente mais escalável que o campo de saldo mutável, porque elimina o ponto de contenção.Os 2,5 PB do guardanapo não precisam estar todos em armazenamento caro e rápido. A esmagadora maioria das consultas toca dados recentes. Aplicamos tiering temporal:
Consultas de extrato dos últimos meses são instantâneas (tier quente). A retenção regulatória de 10 anos vive no tier frio, barato, com latência de recuperação aceitável (ninguém precisa de uma transação de 8 anos atrás em milissegundos). Isso corta o custo de armazenamento em ordens de magnitude sem violar nenhuma obrigação.
Uma palavra final sobre escalabilidade, no espírito da simplicidade que Uncle Bob e Fowler pregam: não construa para 100M de contas quando você tem 100 mil. A arquitetura que descrevi é o destino, não o ponto de partida. O caminho saudável:
| Estágio | Contas | Arquitetura apropriada |
|---|---|---|
| MVP | < 100k | Monólito modular, um Postgres, ledger de partidas dobradas desde o dia 1 |
| Crescimento | 100k - 5M | Extrair serviços críticos (Ledger, Payments), read replicas, cache |
| Escala | 5M - 50M | CQRS, event sourcing, sharding por conta, sagas |
| Hiperescala | 50M+ | Sharding geográfico, tiering, otimizações de hot partition |
O que você deve fazer desde o dia 1, mesmo no MVP: o ledger de partidas dobradas, a idempotência e a imutabilidade. Por quê? Porque esses não são otimizações de escala — são decisões de correção. Migrar de um campo de saldo mutável para um ledger imutável depois que você tem milhões de transações é uma cirurgia de altíssimo risco. As partidas dobradas custam quase nada para adotar cedo e são quase impossíveis de retrofitar tarde. Correção primeiro; escala quando os números pedirem.
Há uma frase de SRE que vale por um livro: "Tudo falha o tempo todo." (Werner Vogels, CTO da Amazon). Em um banco, essa não é uma observação pessimista — é a premissa de projeto. A diferença entre um sistema frágil e um resiliente não é a ausência de falhas; é o que acontece quando elas chegam. Esta seção é sobre os padrões que transformam falhas inevitáveis em degradações controladas.
Quando um serviço dependente (digamos, o gateway do SPI) começa a falhar ou ficar lento, a reação ingênua — continuar mandando requests e esperando timeout em cada uma — é a pior possível. Você esgota threads, enfileira requests, e a falha de um serviço derruba toda a cadeia em cascata. O Circuit Breaker (popularizado por Michael Nygard em Release It!) é o disjuntor que previne o incêndio.
// Circuit breaker: para de bater num serviço que já está caído.
class CircuitBreaker {
private state: "closed" | "open" | "half_open" = "closed";
private failures = 0;
private openedAt?: Date;
constructor(
private readonly threshold: number, // falhas p/ abrir
private readonly cooldownMs: number, // tempo antes de testar
private readonly clock: Clock,
) {}
async execute<T>(operation: () => Promise<T>, fallback: () => T): Promise<T> {
if (this.state === "open") {
if (this.cooldownElapsed()) {
this.state = "half_open"; // hora de testar se voltou
} else {
// Circuito aberto: falha RÁPIDO, sem nem tentar. Usa fallback.
return fallback();
}
}
try {
const result = await operation();
this.onSuccess(); // reset
return result;
} catch (err) {
this.onFailure();
if (this.state === "open") return fallback();
throw err;
}
}
private onFailure(): void {
this.failures++;
if (this.failures >= this.threshold) {
this.state = "open";
this.openedAt = this.clock.now();
}
}
private onSuccess(): void {
this.failures = 0;
this.state = "closed";
}
private cooldownElapsed(): boolean {
return !!this.openedAt &&
this.clock.now().diff(this.openedAt) > this.cooldownMs;
}
}
O ponto sutil é o estado half-open: depois de um tempo de cooldown, o breaker deixa passar uma requisição de teste. Se ela funcionar, o serviço voltou e o circuito fecha. Se falhar, abre de novo. Isso evita tanto martelar um serviço morto quanto demorar demais para reconhecer que ele se recuperou.
A metáfora vem da engenharia naval: navios têm compartimentos estanques (bulkheads) para que um furo no casco não afunde o navio inteiro — só inunda um compartimento. Em software, isolamos recursos (pools de threads, conexões) por dependência, para que a saturação de uma não contamine as outras.
Sem bulkheads, um único dependente lento pode esgotar todo o pool de threads do serviço, paralisando até as operações que nada têm a ver com ele. Com bulkheads, o estrago fica contido. É o mesmo princípio das fronteiras da Clean Architecture, aplicado a recursos de runtime: isole para que a falha não se propague.
Retries são essenciais (a rede é não-confiável), mas retries ingênuos são perigosos. Se 10.000 clientes falham ao mesmo tempo e todos tentam de novo exatamente 1 segundo depois, você cria uma tempestade de retries (thundering herd) que derruba o serviço que estava só se recuperando.
A solução tem três ingredientes:
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: { maxAttempts: number; baseDelayMs: number },
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
// Só faz retry de erros TRANSITÓRIOS. Erro de negócio
// (saldo insuficiente) não melhora com retry — falha logo.
if (!isTransient(err)) throw err;
// Backoff EXPONENCIAL: 1ª espera ~1s, 2ª ~2s, 3ª ~4s...
const exponential = options.baseDelayMs * 2 ** attempt;
// JITTER: aleatoriza para evitar que todos retornem juntos.
const jitter = Math.random() * exponential;
await sleep(exponential / 2 + jitter);
}
}
throw lastError;
}
Voltemos ao RPO = 0 e RTO < 5min do nosso guardanapo (Seção 1.2). Honrar isso exige planejamento de disaster recovery para o cenário em que um data center ou região inteira desaparece (incêndio, falha de energia, desastre natural).
Aqui voltamos ao trade-off PACELC da Seção 12.4. RPO = 0 exige replicação síncrona dos dados críticos (você não pode perder um commit confirmado), e isso adiciona latência cross-region. A reconciliação da física: replicamos sincronamente apenas o ledger (o dado irreparável), e assincronamente os read models (reconstruíveis a partir dos eventos). Você paga o preço da consistência forte só onde a perda seria irrecuperável.
E — lição dura de quem já operou em produção — um plano de DR que nunca foi testado não é um plano; é uma esperança. Bancos sérios fazem game days: derrubam deliberadamente uma região em horário controlado para validar que o failover funciona. A Netflix levou isso ao extremo com o Chaos Monkey, matando instâncias aleatoriamente em produção. A filosofia é a mesma: você não descobre que sua resiliência funciona durante o desastre real — você prova isso antes, em condições controladas.
Se há um domínio onde "funciona na minha máquina" é uma sentença de demissão, é o de software financeiro. Mas testar um banco vai muito além de cobertura de testes — é uma estratégia em camadas que reflete a assimetria de risco que discutimos desde a Seção 1. E, na era AI-Native, onde agentes escrevem e refatoram código de pagamento, os testes assumem um papel novo: deixam de ser só rede de segurança humana e viram a função de recompensa que mantém os agentes honestos.
O que muda em relação à pirâmide tradicional é a ênfase em duas camadas frequentemente negligenciadas: property-based testing das invariantes e testes de contrato das fronteiras.
Testes baseados em exemplo ("transferir R$ 100 de A para B deixa A com -100 e B com +100") são necessários mas insuficientes. Eles testam os casos que você pensou. O property-based testing testa propriedades universais gerando milhares de casos aleatórios — incluindo os que você nunca imaginaria.
import { test } from "vitest";
import fc from "fast-check";
test("PROPRIEDADE: toda transferência preserva o dinheiro total (zero-sum)", () => {
fc.assert(
fc.property(
// Gera valores, contas e sequências de transferências ALEATÓRIOS.
fc.array(
fc.record({
from: fc.integer({ min: 0, max: 9 }),
to: fc.integer({ min: 0, max: 9 }),
amountCents: fc.bigInt({ min: 1n, max: 1_000_000n }),
}),
{ maxLength: 100 },
),
(transfers) => {
const ledger = new InMemoryLedger(/* 10 contas, total inicial conhecido */);
const totalBefore = ledger.sumAllBalances();
for (const t of transfers) {
// Aplica só se houver fundos (regra de negócio).
ledger.tryTransfer(t.from, t.to, t.amountCents);
}
// A INVARIANTE: não importa QUAIS transferências aconteceram,
// o dinheiro total no sistema NUNCA muda. Pacioli, sempre.
return ledger.sumAllBalances() === totalBefore;
},
),
{ numRuns: 10_000 }, // 10 mil cenários aleatórios
);
});
Este teste não verifica um exemplo — verifica uma lei: "dinheiro é conservado, faça o que fizer". O fast-check vai gerar 10.000 sequências aleatórias de transferências, e se qualquer uma delas quebrar a conservação, ele encontra e te mostra o caso mínimo que falha (shrinking). É a forma mais próxima de uma prova que um teste pode oferecer — e para invariantes financeiras, é exatamente o nível de rigor que o domínio merece.
O bug mais perigoso de um banco — o double-spend sob concorrência — é também o mais difícil de testar, porque é não-determinístico. Já vimos o teste de idempotência na Seção 6.5; aqui o generalizamos para o ataque concorrente clássico: dois saques simultâneos de uma conta que só tem saldo para um.
test("CONCORRÊNCIA: saldo nunca fica negativo sob saques paralelos", async () => {
const ledger = new ConcurrentLedger();
await ledger.deposit(ana, Money.fromReais(100)); // só R$ 100
// 50 saques SIMULTÂNEOS de R$ 100 cada. Só UM pode vencer.
const withdrawals = Array.from({ length: 50 }, () =>
ledger.withdraw(ana, Money.fromReais(100)).catch(() => "rejected"),
);
const results = await Promise.all(withdrawals);
const succeeded = results.filter((r) => r !== "rejected").length;
// EXATAMENTE um saque vence. Os outros 49 são rejeitados.
expect(succeeded).toBe(1);
// E o saldo NUNCA ficou negativo (nem por um instante).
expect(await ledger.getBalance(ana)).toEqual(Money.zero());
});
Este teste depende do mecanismo de controle de concorrência subjacente (locks otimistas, SELECT FOR UPDATE, ou a constraint do banco). Rodá-lo sob carga real, repetidamente, é o que expõe condições de corrida que passam despercebidas em testes sequenciais.
Nosso banco depende de sistemas que não controlamos: o SPI, as bandeiras, os bureaus. Quando o contrato deles muda (e muda), queremos descobrir em um teste, não em produção. Consumer-driven contract testing (com ferramentas como Pact) verifica que nossas premissas sobre as APIs externas — e as entre nossos próprios serviços — continuam válidas.
// Contrato: o que o Payments Service ESPERA do Ledger Service.
// Se o Ledger mudar a resposta, este teste quebra no CI, não em prod.
describe("Contrato Payments → Ledger", () => {
it("reserve retorna um reservationId utilizável para capture", async () => {
const interaction = {
request: { method: "POST", path: "/reservations",
body: { account: "uuid", amountCents: "20000" } },
response: { status: 201,
body: { reservationId: like("res_xxx"), status: "held" } },
};
// O mock do Ledger HONRA este contrato; o Ledger real é verificado
// contra o mesmo contrato no pipeline dele. Ambos os lados alinhados.
await verifyContract(interaction);
});
});
Uma última peça, frequentemente esquecida: a reconciliação da Seção 5.6 é, ela própria, um teste rodando em produção 24x7. Enquanto os testes de unidade e integração validam o código antes do deploy, a reconciliação valida os dados continuamente, em produção real. Ela é a última linha — se um bug escapou de toda a pirâmide e corrompeu um saldo, a reconciliação detecta a divergência contra a fonte da verdade imutável e dispara o alarme.
A síntese AI-Native: Junte tudo — property tests provando invariantes, testes de concorrência caçando double-spend, contratos guardando fronteiras, e reconciliação validando produção. Esse conjunto não é só "qualidade". Na era em que agentes de IA refatoram o código do ledger, ele é o contrato executável que define o que "correto" significa. Um agente pode reescrever inteiramente a implementação de uma transferência; se todos esses testes continuam verdes e a reconciliação não acusa divergência, a refatoração é segura. Os testes deixaram de dizer ao humano onde ele errou — eles dizem ao agente como continuar trabalhando sem quebrar o dinheiro de ninguém. Em um banco, essa é a única forma de deixar a IA chegar perto do ledger.
Toda a elegância arquitetural deste artigo encontra seu teste final num momento prosaico e aterrorizante: o deploy. Lembre do SLA de 99,99% (Seção 1.2) e do PIX 24x7 — não existe "janela de manutenção às 3h". Você precisa evoluir um sistema que está, neste exato instante, movendo o dinheiro de milhões de pessoas, sem que nenhuma delas perceba. Esta é uma das disciplinas mais subestimadas e mais difíceis da engenharia de fintech.
Um e-commerce pode exibir "voltamos em 10 minutos" numa madrugada. Um banco não pode — porque alguém na Austrália está recebendo um salário, alguém está pagando uma conta que vence hoje, e a maquininha de um restaurante lotado precisa autorizar agora. Indisponibilidade não é só perda de receita; é quebra de confiança e, acima de certos limites, problema regulatório. Portanto, todo deploy precisa ser rolling (gradual) e reversível.
O canary deployment manda uma fração mínima do tráfego (5%) para a nova versão e a observa de perto — não só métricas técnicas, mas as invariantes financeiras da Seção 14.1. Se a soma do ledger começar a desviar no canário, corta-se o tráfego imediatamente, antes que o estrago se espalhe. Só depois de validado o canário sobe gradualmente para 100%.
Mudar código é fácil de reverter. Mudar o schema do banco de dados — especialmente o do ledger, que tem petabytes e nunca para — é onde os deploys matam bancos. Um ALTER TABLE ingênuo numa tabela de bilhões de linhas pode travar a tabela inteira por minutos, paralisando todas as transações. A regra é: toda mudança de schema deve ser compatível para trás (backward-compatible) e aplicada em etapas.
O padrão canônico é o expand-contract (também chamado de parallel change), que Martin Fowler documentou:
Vamos tornar concreto. Suponha que precisamos adicionar um campo settlement_date aos lançamentos:
-- ❌ NUNCA: trava a tabela inteira, paralisa o banco
ALTER TABLE entries ADD COLUMN settlement_date DATE NOT NULL DEFAULT now();
-- ✅ EXPAND-CONTRACT, em etapas seguras:
-- Etapa 1 (EXPAND): adiciona coluna NULLABLE, sem default.
-- Operação de metadados, instantânea, não reescreve linhas.
ALTER TABLE entries ADD COLUMN settlement_date DATE NULL;
-- Etapa 2 (MIGRATE): o código novo passa a PREENCHER a coluna.
-- Backfill dos dados antigos em LOTES pequenos, sem travar.
-- Roda em background, controlando a carga:
UPDATE entries SET settlement_date = created_at::date
WHERE settlement_date IS NULL AND id BETWEEN $1 AND $2; -- lote a lote
-- Etapa 3 (CONTRACT): só depois de 100% preenchido e código novo
-- estável em produção, aplica a constraint.
ALTER TABLE entries ALTER COLUMN settlement_date SET NOT NULL;
Cada etapa é independentemente reversível e nenhuma trava a tabela. O código precisa funcionar em todos os estados intermediários — porque, durante o deploy gradual, versões antigas e novas do serviço rodam simultaneamente, ambas tocando o mesmo banco.
// Durante a migração, o código deve tolerar a coluna ausente/nula.
// Esta é a essência do parallel change: compatibilidade dupla.
function readEntry(row: EntryRow): Entry {
return Entry.reconstruct({
id: row.id,
accountId: row.account_id,
amount: row.amount,
// Tolera o estado intermediário: se a migração ainda não
// preencheu, deriva do created_at. Código velho e novo coexistem.
settlementDate: row.settlement_date ?? deriveSettlementDate(row.created_at),
});
}
A etapa de CONTRACT (remover a coluna/tabela antiga) é a perigosa, porque é a única irreversível. Aqui a disciplina é brutal: só execute a contração depois de dias ou semanas de a nova versão estar 100% estável, e nunca no mesmo deploy que introduziu a expansão. Uma coluna removida cedo demais, com uma versão antiga ainda no ar tentando lê-la, é um incidente garantido.
A regra que separa o sênior do júnior em migrations: Separe sempre a mudança de código da mudança de dados, e a expansão da contração. Um deploy que tanto adiciona quanto remove é um deploy que você não consegue reverter pela metade. Pequenos passos reversíveis — o mesmo princípio das sagas (Seção 8) e dos lançamentos imutáveis (Seção 5) — aplicado ao ciclo de vida do próprio sistema. A coragem de fazer mudanças grandes vem da capacidade de revertê-las em pequenos pedaços.
A ferramenta final que torna tudo isso gerenciável é o feature flag. Ele desacopla deployar o código (colocá-lo no servidor) de liberar a funcionalidade (ativá-la para usuários). Você pode deployar código novo de PIX desligado, ativá-lo para 1% dos clientes, observar, e expandir — ou desligar instantaneamente sem um novo deploy se algo der errado.
class PaymentsService {
async transfer(cmd: TransferCommand): Promise<TransferResult> {
// O novo motor de transferência roda atrás de uma flag.
// Deploy != release. Podemos desligar em segundos, sem redeploy.
if (await this.flags.isEnabled("new-transfer-engine", cmd.fromAccount)) {
return this.newTransferEngine.execute(cmd);
}
return this.legacyTransferEngine.execute(cmd); // caminho seguro
}
}
Isso conecta de volta ao antifraude evolutivo da Seção 11.4 (ligar/desligar regras em tempo real) e fecha o ciclo: um banco precisa evoluir constantemente e nunca parar e nunca errar. Feature flags, canary, expand-contract e reconciliação contínua são as ferramentas que tornam essa combinação aparentemente impossível em rotina operável. Não é magia — é disciplina, aplicada no único lugar onde a maioria dos sistemas relaxa: a hora de mudar.
Percorremos um longo caminho: do guardanapo com estimativas de capacidade ao ledger de partidas dobradas de Pacioli; da idempotência que torna retries seguros às sagas que coordenam o caos distribuído; do antifraude adversarial aos limites físicos do Teorema CAP. Se há uma única lição que amarra tudo isso, é esta:
Arquitetar um banco não é escolher tecnologias. É assumir uma postura diante do risco.
Toda decisão que tomamos foi, no fundo, uma resposta à mesma pergunta: "o que acontece quando isto falha?"
Note como nenhuma dessas conclusões veio de um framework ou de uma moda. Elas vieram de levar o domínio a sério — exatamente o que Fowler prega com DDD — e de respeitar as fronteiras entre o que é regra de negócio estável e o que é detalhe substituível — exatamente o que Uncle Bob prega com a Arquitetura Limpa.
Mesmo que você nunca construa uma fintech, os padrões aqui são transferíveis:
Money que se recusa a somar moedas diferentes previne classes inteiras de bugs no compilador, antes de qualquer teste.Martin Fowler diz que boa arquitetura é a que mantém as opções abertas — adiar decisões irreversíveis até ter informação suficiente. Uncle Bob diz que ela protege as regras de negócio dos caprichos da tecnologia. Em um banco, eu adicionaria uma terceira: ela honra a confiança. Cada cliente que deposita o salário no seu sistema está fazendo uma aposta de que você não vai perder, duplicar ou expor o dinheiro dele. A arquitetura é a forma material de honrar essa aposta.
E é por isso que vale tanto a pena fazê-la direito. Não pelos diagramas bonitos, nem pelos padrões com nomes elegantes — mas porque, do outro lado de cada transação, há uma pessoa real confiando que o sistema vai fazer a coisa certa. Construir esse sistema é, no fim, um ato de responsabilidade. E essa é a parte que nenhum framework vai fazer por você.
Escrito por Anderson Lima. Se este artigo te ajudou a enxergar o sistema por trás do app do banco, compartilhe — e me conte qual decisão de arquitetura você faria diferente.
Os exemplos de código deste artigo são ilustrativos e priorizam clareza pedagógica sobre completude de produção. Tratamento de erros, observabilidade e otimizações foram simplificados onde necessário para focar no conceito em discussão. Use-os como pontos de partida para entender os padrões, não como código a copiar e colar num sistema real que toca dinheiro de verdade.
Gostou deste mergulho? Ele faz parte de uma série sobre system design de sistemas que importam. Se quiser que eu detalhe qualquer subsistema — o motor de antifraude, o arranjo do PIX, ou a estratégia de sharding geográfico — em um artigo próprio, é só me dizer. A engenharia de fintech é profunda o suficiente para um livro inteiro; aqui foi só o primeiro andar.
Uma referência rápida para consultar quando você estiver projetando seu próprio sistema financeiro. Cada linha condensa um trade-off discutido ao longo do artigo.
| Decisão | Escolha recomendada | Por quê | Seção |
|---|---|---|---|
| Representação de dinheiro | Inteiro (centavos), nunca float | Floats acumulam erro de arredondamento | 3.4 |
| Modelo de saldo | Ledger de partidas dobradas, saldo derivado | Auditabilidade + invariante verificável | 5 |
| Atualização de saldo | Append-only (inserir lançamentos) | Sem contenção, sem perda de histórico | 5.2 |
| Retries de pagamento | Idempotência em ≥2 camadas | Rede sempre reentrega; duplicar = rombo | 6 |
| Leitura vs. escrita | CQRS no núcleo transacional | Razão 50:1 justifica caminhos separados | 7 |
| Transações distribuídas | Saga com compensação, não 2PC | 2PC trava e não fala com BACEN/bandeiras | 8 |
| "Desfazer" uma transação | Lançamento de estorno, não delete | Imutabilidade + trilha de auditoria | 8.5 |
| Consistência do ledger | CP (forte), exige quórum | Double-spend é irrecuperável | 12.2 |
| Consistência dos read models | AP (eventual) | Escala horizontal; atraso tolerável | 12.3 |
| Decisão de fraude | Aprovar / Desafiar / Negar (3 vias) | Falso positivo também tem custo | 11.3 |
| Falha de dependência | Circuit breaker + fallback | Evita falha em cascata | 16.1 |
| Retry sob falha | Backoff exponencial + jitter | Evita tempestade de retries | 16.3 |
| Mudança de schema | Expand-contract, em etapas | Sistema nunca para; deve ser reversível | 18.2 |
| Deploy | Canary + feature flags | Desacopla deploy de release | 18.1 |
| Quando adotar tudo isso | Ledger desde o dia 1; resto sob demanda | Correção é retrofit caro; escala não | 15.5 |
Os termos do domínio, reunidos. Um vocabulário compartilhado é a primeira defesa contra bugs (Seção 3.1).
| Termo | Definição |
|---|---|
| Lançamento (Entry/Posting) | Uma única linha de débito ou crédito em uma conta. A unidade atômica e imutável do ledger. |
| Transação (Transaction) | Conjunto de lançamentos que soma exatamente zero (partidas dobradas). |
| Saldo contábil | Soma de todos os lançamentos efetivados de uma conta. |
| Saldo disponível | Saldo contábil menos as reservas/holds pendentes. O que o cliente pode efetivamente gastar. |
| Reserva (Hold) | Bloqueio temporário de fundos que reduz o saldo disponível sem mexer no contábil. |
| Autorização | Aprovação de uma operação (ex: compra no cartão) que tipicamente cria um hold. |
| Captura (Capture/Settlement) | Efetivação definitiva de uma autorização, convertendo o hold em débito real. |
| Estorno (Reversal) | Lançamento que neutraliza o efeito de outro, preservando ambos no histórico. |
| Idempotency Key | Identificador único de uma intenção de operação, que torna retries seguros. |
| Conta de sistema | Conta interna que representa o mundo externo (ex: posição no SPI), mantendo o zero-sum global. |
| Saga | Sequência de transações locais com compensações, para coordenação distribuída sem 2PC. |
| Reconciliação | Processo contínuo que verifica invariantes e corrige projeções contra a fonte da verdade. |
| Projeção (Read Model) | Visão materializada e otimizada para leitura, derivada dos eventos/lançamentos. |
| Zero-sum | A invariante de que a soma de todos os lançamentos do sistema é sempre zero. |
Para fixar como as peças se conectam, eis um PIX bem-sucedido atravessando todas as camadas que construímos:
Cada número neste fluxo corresponde a uma decisão de arquitetura que discutimos — e cada uma existe porque, em algum momento, alguém perguntou "e se isto falhar?". É assim que se constrói um sistema digno da confiança de quem deposita o salário nele: uma pergunta difícil de cada vez, respondida com disciplina, e verificada sem piedade.
Nenhum design sobrevive ao contato com engenheiros experientes sem objeções. Aqui estão as mais comuns, respondidas com honestidade — porque um artigo que só apresenta o caminho feliz da própria arquitetura não está ensinando, está vendendo.
Para 100 contas, sim. Para 100 milhões, não. Mas a resposta mais importante é a distinção da Seção 15.5: o ledger de partidas dobradas não é uma otimização de escala — é uma decisão de correção. Mesmo no seu MVP com 100 usuários, você quer o ledger imutável, porque o custo de adotá-lo cedo é quase zero e o custo de migrar para ele depois (com milhões de transações já registradas no modelo errado) é uma cirurgia de altíssimo risco. O que é over-engineering num MVP: sharding, CQRS, sagas e múltiplas regiões. Esses você adiciona quando os números pedirem, não antes. A arte está em saber qual é qual.
Excelente instinto. Existem sistemas dedicados (TigerBeetle, por exemplo, é um banco de dados desenhado especificamente para contabilidade de alta performance, com partidas dobradas nativas e foco em correção financeira). Em muitos casos, usar uma solução madura é mais sensato do que construir a sua. O valor de entender a arquitetura aqui descrita não é necessariamente para reimplementá-la do zero — é para saber avaliar essas ferramentas, entender suas garantias, e tomar decisões informadas sobre o que terceirizar e o que controlar. Uncle Bob diria: o banco de dados é um detalhe. Mas é um detalhe que você precisa escolher com conhecimento de causa.
Esta é a objeção mais técnica e a mais importante de responder com nuance. Sim, consistência forte custa latência (PACELC, Seção 12.4). Mas lembre de duas mitigações cruciais do design:
O resultado: você paga o preço da consistência forte apenas onde a perda seria irreparável, e o amortiza sobre uma fração minúscula do tráfego total.
É exatamente para isso que existe o Anti-Corruption Layer (Seções 3.3 e 9.3). O modelo externo — instável, fora do seu controle, sujeito a mudanças regulatórias — nunca contamina o núcleo de domínio. Quando o SPI muda um campo no protocolo, a mudança fica contida na camada de tradução da fronteira. Seu ledger, seus casos de uso, suas regras de negócio não sabem nem se importam. Essa é a recompensa de levar a sério a Regra da Dependência: o que é volátil fica na periferia; o que é estável e fundamental fica protegido no centro.
Em dois lugares muito concretos, e ambos importam:
A lição maior: a IA não substitui a arquitetura sólida — ela aumenta o valor dela. Quanto mais código é gerado por agentes, mais valiosas ficam as fronteiras claras, os contratos executáveis e as invariantes verificáveis. O futuro não é menos engenharia de sistemas; é mais.
Comece pequeno e correto, nesta ordem:
Money em centavos e a invariante zero-sum testada (Seção 17.2). Esta é a fundação inegociável.Correção primeiro. Escala quando os números pedirem. Confiança sempre. Esse é o caminho — e cada passo dele foi pensado para que, do outro lado da tela, uma pessoa real possa confiar que o sistema vai fazer a coisa certa com o dinheiro dela.
Tecnologias concretas mudam; os princípios não. Ainda assim, é útil aterrissar a discussão em escolhas reais. A tabela abaixo mapeia cada responsabilidade do sistema a categorias de tecnologia, com o porquê de cada uma. Trate como ponto de partida para avaliação, não como dogma — a regra de Uncle Bob vale: estas são todas decisões de detalhe, na periferia, substituíveis sem tocar o núcleo de domínio.
| Responsabilidade | Categoria de tecnologia | Racional |
|---|---|---|
| Ledger (fonte da verdade) | RDBMS transacional (Postgres) ou ledger dedicado (TigerBeetle) | Garantias ACID fortes, constraints (UNIQUE para idempotência), maturidade operacional |
| Event Store | Log append-only (Kafka, EventStoreDB) | Imutabilidade, ordenação, retenção, replay de projeções |
| Read model: saldo | Cache em memória (Redis) | Latência sub-ms para o caminho de autorização de cartão |
| Read model: extrato | Store write-optimized (Cassandra/Postgres particionado) | Volume alto de escrita de eventos, paginação temporal |
| Read model: analytics | Colunar (ClickHouse, BigQuery) | Agregações pesadas para BI e treino de modelos |
| Mensageria/eventos | Kafka ou equivalente | Throughput alto, ordenação por partição, durabilidade |
| Cache de sessão/velocity | Redis | Janelas deslizantes para regras de fraude, TTLs |
| Cold storage | Object storage em tiers (S3 + Glacier) | Retenção regulatória de 10 anos a custo baixo |
| Segredos e chaves | KMS/HSM gerenciado | Envelope encryption, rotação, conformidade |
| Observabilidade | OpenTelemetry + Prometheus + tracing distribuído | Métricas, logs estruturados e traces correlacionados (Seção 14) |
| Feature flags | Serviço de flags (LaunchDarkly ou equivalente) | Desacoplar deploy de release (Seção 18.4) |
| Orquestração | Kubernetes | Rolling deploys, bulkheads via resource limits, auto-scaling |
Usei TypeScript nos exemplos por sua clareza e pela tipagem que torna estados inválidos não-representáveis (o Money, os estados de conta) — exatamente o ponto pedagógico que eu queria ilustrar. Em produção, o caminho quente de um banco frequentemente usa linguagens com garantias de performance e segurança mais fortes para o núcleo crítico (Java/Kotlin pela maturidade do ecossistema financeiro, Go pela simplicidade de concorrência, Rust pela segurança de memória sem garbage collector imprevisível). Mas — e isto é o ponto — a arquitetura independe da linguagem. As partidas dobradas, a idempotência, as sagas, o CAP: tudo isso é verdadeiro em qualquer linguagem. A linguagem é, mais uma vez, um detalhe na periferia. O domínio é o que perdura.
Há uma armadilha sedutora em system design: confundir a lista de tecnologias com a arquitetura. "Usamos Kafka, Cassandra e Kubernetes" não é uma arquitetura — é um inventário. A arquitetura está nas decisões e nos trade-offs: por que o ledger é CP e o extrato é AP, por que reservamos antes de capturar, por que compensamos em vez de deletar. Troque cada tecnologia desta tabela por uma concorrente e, se as decisões de fronteira e consistência permanecerem, você ainda tem o mesmo sistema. É por isso que este artigo gastou dezessete seções no porquê e apenas um apêndice no com o quê. Ferramentas vêm e vão. A postura diante do risco — essa é a arquitetura de verdade.
Fechamos com uma lista de erros reais — o tipo que parece inofensivo no code review e vira incidente às 3h da manhã do dia 5 do mês. Cada um é o oposto exato de um princípio do artigo.
1. Dinheiro como float ou double.
0.1 + 0.2 !== 0.3 em ponto flutuante. Multiplique esse erro por milhões de transações e você tem uma divergência contábil inexplicável. Use sempre inteiros (centavos) ou tipos decimais exatos. (Seção 3.4)
2. Campo de saldo mutável como fonte da verdade. O pecado original. Sem histórico, sem auditabilidade, sem como reconciliar. Quando o cliente disputa um valor, você não tem resposta. Use o ledger imutável. (Seção 5.1)
3. Operações financeiras não-idempotentes. "O cliente nunca vai clicar duas vezes." Vai. E a rede vai reentregar. E o load balancer vai reciclar a conexão. Toda operação que move dinheiro precisa de idempotency key. (Seção 6)
4. Deletar ou alterar lançamentos para "corrigir" um erro. Você não corrige um lançamento errado apagando-o — você posta um estorno. Apagar destrói a trilha de auditoria e é, em essência, mentir para o regulador. (Seção 8.5)
5. Tratar consistência como binária ("tudo forte" ou "tudo eventual"). Ambos os extremos são errados. Mapeie cada componente ao seu modelo de consistência correto. O ledger é CP; o extrato é AP. (Seção 12.3)
6. Confiar na rede interna ("está atrás do firewall, é seguro"). Zero Trust. Um atacante que entra na rede não deve conseguir ler tráfego entre serviços. mTLS em tudo. (Seção 13.2)
7. Logar dados sensíveis. Um PAN de cartão ou CPF em texto plano nos logs transforma seu sistema de observabilidade (geralmente menos protegido) num vazamento ambulante. Redação automática, sempre. (Seção 14.4)
8. ALTER TABLE ingênuo numa tabela de bilhões de linhas.
Trava a tabela inteira e paralisa o banco. Use expand-contract, em etapas reversíveis, sem nunca travar. (Seção 18.2)
9. Retry sem backoff nem jitter. Dez mil clientes falham juntos, todos tentam de novo no mesmo segundo, e a tempestade de retries derruba o serviço que estava se recuperando. Backoff exponencial + jitter. (Seção 16.3)
10. Achar que a reconciliação é opcional. "Nossos testes passam, não precisamos reconciliar em produção." Testes validam código antes do deploy; a reconciliação valida dados em produção, 24x7. Ela é a última linha de defesa quando um bug escapa de tudo. Nunca a corte. (Seção 5.6)
Se você terminar este artigo lembrando de uma única coisa, que seja esta: em software financeiro, o caminho infeliz não é um caso de borda — é o requisito central. Projete para ele primeiro. O resto é detalhe.