02 - Sistema Bancário (Transações, Event Sourcing)
video: https://youtu.be/... no frontmatter da nota.Sistema Bancário: Transações e Event Sourcing
Este caso acompanha um banco digital com 3,5 milhões de contas ativas e 180 milhões de lançamentos por mês, com pico de 4.500 transações por segundo na janela do almoço, concentrado em PIX e cartão. Os objetivos arquiteturais derivam do regulador tanto quanto do cliente: cada lançamento é fato imutável e auditável, o saldo permanece correto sob concorrência e nenhum dinheiro desaparece quando um serviço externo falha no meio da operação. O desenho adota o ledger como fonte de verdade, com event sourcing no agregado conta e saldo derivado dos eventos. A consistência é forte dentro do agregado e eventual entre contextos, com sagas de compensação nas fronteiras. O resultado é uma plataforma onde a auditoria é subproduto do desenho, e não um projeto separado.
Requisitos e pressupostos
A carga e a regulação definem o desenho. O volume anual projetado passa de 2 bilhões de lançamentos, e a retenção regulatória é de 10 anos com consulta eficiente por conta e por período. As garantias exigidas do sistema são:
- RPO zero para lançamento confirmado: uma vez aceito pelo cliente, o fato sobrevive a qualquer falha de infraestrutura.
- RTO de 15 minutos para retomada de comandos após desastre, com teste trimestral do procedimento de restauração.
- Concorrência real por conta: o mesmo cliente com o app em dois aparelhos não pode produzir duplo saque aceito com o mesmo saldo.
- Falha externa sem perda de movimento: PIX, bandeiras e boleto podem cair, e o sistema precisa compensar ou reter de forma visível.
- Conciliação diária que bate centavo a centavo com cada contraparte, com divergência apontada por lançamento.
- Dados de cartão isolados em escopo próprio, com tokenização nos demais contextos, para reduzir a superfície de conformidade PCI.
Os pressupostos delimitam a modelagem. A conta é agregado com fronteira clara e titular único, e contas conjuntas são tratadas como permissões de acesso, e não como co-titularidade dentro do agregado. Os meios de pagamento externos aceitam idempotência por chave, o que permite repetir chamadas com segurança. O extrato na tela pode apresentar defasagem de segundos, enquanto o saldo usado para decisão de débito é sempre calculado sobre o estado real do agregado. Nada é editado ou apagado: toda correção entra como novo evento, inclusive estornos.
Segurança atravessa todos os requisitos. O acesso ao store de eventos é append-only por permissão de banco, nenhuma credencial de aplicação aceita update ou delete na tabela de fatos, e o acesso de auditoria é read-only com trilha própria. A tokenização de cartão nasce no gateway, então os demais contextos nunca tocam no número aberto.
O perfil de volume orienta o dimensionamento. PIX responde por 70% dos lançamentos, cartão por 20% e boleto por 10%, com concentração de 38% do movimento diário entre 12h e 14h. O tamanho do agregado também importa: 92% das contas acumulam menos de 200 eventos por ano, e o 1% mais movimentado justifica snapshot por conta, e não uma política global única.
Arquitetura proposta
O núcleo é um ledger append-only implementado sobre PostgreSQL, com o agregado conta persistido por event sourcing e reidratado por snapshot mais cauda de eventos. Projeções alimentam as leituras de saldo, extrato e bloqueios, seguindo o par CQRS, e sagas coreografadas coordenam a integração com PIX, bandeiras e boleto. Um fluxo de CDC replica os eventos para a plataforma de dados, servindo analítica e auditoria sem tocar na origem. A tabela resume as garantias e o mecanismo que sustenta cada uma.
| Garantia | Mecanismo | Alcance | Custo aceito |
|---|---|---|---|
| Nenhum débito sem saldo | Invariante no agregado com append condicional por versão | Por conta | Contenção baixa, concorrência por conta é rara |
| Transferência interna atômica | Débito e crédito em dois streams no mesmo commit do store | Entre contas | Transação única cobre os dois streams |
| Trilha auditável íntegra | Append-only com hash encadeado por evento | Global | Verificação periódica da cadeia |
| Integração sem perda | Saga com compensação e idempotência por chave | Entre contextos | Estado em progresso visível ao cliente |
| Leitura rápida | Projeções alimentadas pelo log de eventos | Por contexto | Defasagem de segundos nas telas |
| Histórico em 10 anos | Particionamento por ano com retenção fria em object storage | Global | Consulta antiga exige restauração parcial |
Cada contexto carrega desenho próprio sobre o mesmo fundamento. Contas concentra o event sourcing e a invariante de saldo; meios de pagamento orquestra sagas e idempotência; cartão autoriza com exigência de frescor alto na projeção de saldo; auditoria consome o log por replay e expõe extratos regulatórios. Os quatro compartilham o store de eventos como fronteira, e nenhum escreve na tabela do outro.
Fluxo principal: débito, crédito e transferência
O caminho de comando segue sempre o mesmo formato. O serviço carrega a conta por snapshot mais cauda, valida a invariante sobre o estado reidratado, e anexa o evento com a versão esperada do stream. O append condicional recusa a escrita se outro comando avançou a versão nesse intervalo, e o cliente recebe conflito para repetir sobre o estado novo. A projeção de saldo reage ao evento confirmado e atualiza a leitura, e a saga de integração assume quando há contraparte externa.
O vocabulário de fatos cobre o ciclo completo: AberturaDeConta, DepositoRealizado, SaqueRealizado, TransferenciaEnviada, TransferenciaRecebida, EstornoAutomatico e BloqueioAplicado. Cada evento carrega valor, contraparte quando existe e a chave de idempotência que originou o comando. O extrato na tela é a leitura desses fatos em ordem, e o mesmo arquivo alimenta a conciliação, o que elimina divergência entre o que o cliente vê e o que o regulador audita.
A transferência interna entre contas do próprio banco usa um truque importante: os dois fatos, débito na conta origem e crédito na conta destino, são gravados no mesmo commit do event store, em streams distintos. A atomicidade vem da transação relacional do store, sem coordenador distribuído. Se a segunda parte falha, o commit inteiro falha, e nenhum saldo fica pendente. Em números de produção, 120 mil transferências internas por dia seguem esse caminho sem coordenação adicional.
-- Event store: append-only, sem update e sem delete por permissão de banco
CREATE TABLE eventos (
id BIGSERIAL PRIMARY KEY,
stream_id VARCHAR(64) NOT NULL, -- "conta-{uuid}"
versao BIGINT NOT NULL, -- posição do evento no stream
tipo VARCHAR(64) NOT NULL, -- DepositoRealizado, SaqueRealizado
dados JSONB NOT NULL,
hash_anterior CHAR(64) NOT NULL, -- encadeamento para auditoria
hash_proprio CHAR(64) NOT NULL, -- sha256(id + hash_anterior + dados)
ocorrido_em TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stream_id, versao) -- concorrência otimista nativa
);
-- Snapshot por conta: descartável e reconstruível por replay
CREATE TABLE snapshots (
stream_id VARCHAR(64) PRIMARY KEY,
versao BIGINT NOT NULL,
estado JSONB NOT NULL, -- saldo, status e bloqueios
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
def debitar(conta_id, valor, chave_idempotencia):
foto, cauda = store.carregar(f"conta-{conta_id}") # snapshot + eventos após a foto
conta = Conta.reidratar(foto, cauda)
if conta.saldo < valor:
raise SaldoInsuficiente(conta_id) # invariante sobre o estado real
if chave_idempotencia in conta.chaves_usadas:
return conta.ultimo_lancamento(chave_idempotencia) # retry devolve o mesmo fato
evento = SaqueRealizado(conta_id, valor, chave_idempotencia)
# append condicional: só grava se a versão no banco for a esperada
store.acrescentar(
f"conta-{conta_id}",
versao_esperada=foto.versao + len(cauda),
eventos=[evento])
# projeções e sagas reagem ao fato confirmado a partir daqui
O conflito de versão é caso previsto, e não erro. O cliente recebe código 409 com a versão corrente, e o app repete o comando após reidratar o estado; no p99 a segunda tentativa passa, porque a disputa por uma mesma conta é rara. O conflito persiste apenas na disputa legítima de saldo, exatamente o cenário que a versão esperada existe para interromper.
O checkpoint da projeção grava a posição do log junto com o efeito, na mesma transação do banco de leitura, o que torna a reentrega inofensiva.
-- Checkpoint da projeção: posição do log e efeito avançam juntos
CREATE TABLE checkpoint_projecao (
nome VARCHAR(64) PRIMARY KEY, -- "saldo", "extrato", "bloqueios"
posicao BIGINT NOT NULL, -- último id de evento processado
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
A integração externa é uma saga coreografada. O evento TransferenciaIniciada dispara o débito com status de reserva, o adaptador chama o meio de pagamento externo usando a chave de idempotência como deduplicador, e a resposta produz TransferenciaConfirmada ou aciona o estorno automático como compensação. Cada passo é um fato no log, e o cliente acompanha o estado em progresso na tela, o que reduz chamados porque a operação nunca parece sumida.
Exemplos concretam o vocabulário no dia a dia. Um PIX recebido é um DepositoRealizado com origem externa; uma compra no cartão gera BloqueioAplicado no limite, e a captura converte o bloqueio em SaqueRealizado; um estorno parcial entra como DepositoRealizado com referência ao fato original, mantendo a cadeia intacta. Nenhum desses movimentos precisa de tabela de status: a leitura dos fatos em ordem já é o estado.
Decisões e trade-offs
| Decisão | Alternativa descartada | Trade-off aceito |
|---|---|---|
| Event store sobre PostgreSQL | EventStoreDB dedicado | Menor throughput bruto por nó; time já domina backup, failover e tuning do PG |
| Snapshot a cada 100 eventos | Replay completo sempre | Gestão de fotos descartáveis; reidratação de 3 ms contra 120 ms de replay total |
| Saga coreografada | Orquestrador central | Rastro distribuído mais difícil de seguir; nenhum ponto único de falha na coordenação |
| Saldo derivado com projeção | Saldo gravado na conta | Projeção pode atrasar segundos; mudança de regra de saldo não exige migração de dado |
| Forte no agregado, eventual entre contextos | Consistência forte global | Coordenador distribuído seria gargalo; cada contexto evolui no próprio ritmo |
| Retenção fria em object storage | Histórico sempre quente no banco | Consulta antiga exige restauração; custo de armazenamento cai cerca de 80% |
O fio condutor é concentrar a consistência forte onde o dinheiro decide, e relaxar onde a leitura só informa. O agregado conta é pequeno, com fronteira nítida, então a concorrência otimista resolve o caso raro de disputa sem lock de banco. Entre contextos, a saga assume: o custo é estado em progresso e a necessidade de projetar compensação para cada passo, preço pequeno perto da perda de movimento silenciosa.
Um custo menos visível merece registro: modelar por eventos exige disciplina de nomenclatura e de compatibilidade, porque o log de 10 anos não pode ser reescrito a cada refatoração. O time adotou versionamento de eventos com conversores, eventos antigos nunca mudam de significado, e correção entra como fato novo. A alternativa, editar histórico, foi descartada por quebrar auditoria e conciliação de uma vez.
Escalabilidade e falhas
A escala de escrita vem do sharding por hash de conta em 16 shards lógicos, cada um mapeado a um cluster de banco. Como a concorrência dentro de um stream é naturalmente baixa, o append condicional raramente recusa, e o throughput cresce com o número de shards sem reescrever o modelo. As projeções rodam sobre réplicas de leitura, com lag monitorado por contexto e frescor exigido apenas nas telas de decisão, como autorização de cartão.
Reconstruir projeções do zero é operação rotineira, e não emergência. A reconstrução das projeções de saldo e extrato para as 3,5 milhões de contas roda em 50 minutos em lote paralelo por shard, e é o teste padrão depois de qualquer mudança de regra de projeção. Como o log é a única verdade, a operação é idempotente e repetível sem janela de manutenção.
O monitoramento segue o log como referência. Lag de projeção por contexto, tempo de reidratação, taxa de conflito de versão e profundidade da DLQ de sagas são as quatro séries de primeira linha, com alerta em desvio de 2 desvios padrão da média horária. Taxa de conflito acima de 0,5% sinaliza hot spot de conta, e o shard problemático é isolado no painel antes que clientes sintam.
Os números medidos em produção sustentam o desenho: comando de débito com p99 de 120 ms, reidratação média de 3 ms com snapshot, e replay completo de um ano de eventos executado em lote noturno para auditoria sem afetar comandos. As falhas têm caminho previsto: o event store fora do ar bloqueia comandos por ser a fonte de verdade, mas as leituras continuam pelas projeções até a retomada. Sagas com timeout externo de 20 segundos acionam estorno automático, e a conciliação diária compara lançamento a lançamento com cada contraparte, apontando divergência por item. O lote mensal processa os 180 milhões de lançamentos em janelas de 40 minutos por contraparte, e a divergência mediana detectada no último ano foi de zero itens por dia útil.
A recuperação de desastre é ensaiada, e não presumida. Os commits usam standby síncrono na mesma região, o que sustenta RPO zero para lançamento confirmado, e uma réplica assíncrona cross-region serve leitura e contingência. O drill trimestral restaura o store em ambiente isolado, roda o replay até o último evento e mediu RTO real de 11 minutos nas três últimas execuções, dentro da meta de 15.
A auditoria depende do hash encadeado: cada evento registra o hash do anterior, e qualquer alteração retroativa quebra a cadeia e é detectada na verificação periódica. O regulador recebe extrato reconstruído por replay, e o mesmo replay reconstrói qualquer saldo histórico, na data exata, porque o log é a única verdade.
Lições aplicáveis
- Modele o dinheiro como log de fatos: o ledger imutável entregou auditoria, conciliação e depuração sem produto separado, porque a trilha é o próprio dado.
- Concentre consistência forte onde o estado decide: o agregado conta é o único lugar com append condicional, e tudo ao redor aceita eventualidade com compensação.
- Snapshot é otimização descartável: fotos podem ser perdidas sem dano, pois o replay recria o estado, e a cadência de 100 eventos foi calibrada por medição.
- Concorrência otimista por versão evita lock distribuído: a disputa real por conta é rara, e o conflito legível é preferível a serialização global.
- Compensação precisa ser projetada junto: todo passo externo da saga nasce com o caminho de desfazer e com idempotência por chave.
- Audite a integridade do log com hash encadeado: a cadeia transforma qualquer tentativa de alteração retroativa em evidência, e não em risco silencioso.
- Separe frescor por decisão: autorização de cartão lê projeção com exigência de frescor, extrato aceita segundos de defasagem, e a diferença está escrita no desenho.
- Ensine o negócio a ler o log: extrato por replay e saldo histórico por data viraram argumentos comerciais, e não apenas requisitos regulatórios.
- Projete o regulador como usuário: acesso read-only ao log e extrato por replay transformaram auditorias que levavam semanas em consultas de minutos.