Trilha 3 · Do conceito à produção · ~30 min · pleno/sênior

Uma fintech me chamou porque, "de vez em quando", um cliente era cobrado duas vezes. "De vez em quando" eram algumas dezenas de casos por dia num volume alto — dinheiro de verdade, gente furiosa, risco regulatório. O time tinha certeza de que era bug de código. Não era. O código estava certo para um mundo de uma requisição por vez. O problema aparecia só sob concorrência: dois requests do mesmo usuário (um duplo-clique, um retry do app) chegavam quase juntos, ambos liam "saldo de cobrança pendente = sim", ambos decidiam cobrar, ambos cobravam. Clássico. E a "solução" que alguém tinha tentado antes piorava tudo: envolveram toda a operação (cobrança + envio de e-mail + chamada a um serviço externo de antifraude) numa transação gigante que segurava locks por segundos, criando timeouts e deadlocks em cascata.

Duas lições nasceram ali, e são as desta aula. Primeira: consistência sob concorrência não é automática — você a projeta, escolhendo o nível de isolamento e o tipo de lock certos para cada caso. Segunda: o escopo de uma transação é uma decisão de arquitetura, não um detalhe. Transação larga demais mata performance; estreita demais deixa buracos de consistência. E, no mundo modular (aula Persistência e State Isolation), há um agravante: quando o estado está isolado por módulo, a transação ACID única nem existe mais — e você precisa saber quando isso importa e quando não.

O problema-raiz: concorrência quebra a intuição sequencial

Quase todo bug de consistência tem a mesma forma: dois fluxos leem o mesmo estado, ambos decidem com base nele, ambos agem — e a ação de um invalida a premissa do outro. Isso é uma race condition, e ela não aparece em teste sequencial (um request por vez passa liso). O código da fintech, simplificado:

// ❌ correto em sequência, quebrado sob concorrência
async chargeIfPending(userId: string) {
  const sub = await this.repo.findByUserId(userId);
  if (sub.pendingCharge) {                 // request A lê "pending = true"
                                           // request B lê "pending = true" também (quase junto)
    await this.gateway.charge(sub.amount); // A cobra
                                           // B cobra também → cobrança dupla
    sub.pendingCharge = false;
    await this.repo.save(sub);
  }
}

A janela entre "ler" e "escrever" é onde o outro fluxo entra. Fechar essa janela é o trabalho de isolamento e locks. Não existe "escrever o código com mais cuidado" que resolva — é uma propriedade que você configura no banco, conscientemente.

Níveis de isolamento: o que cada um previne (e custa)

Os bancos oferecem níveis de isolamento transacional (definidos pelo padrão SQL). Do mais fraco/rápido ao mais forte/lento, e o que cada um evita:

Nível Previne Ainda permite Custo
Read Uncommitted (quase nada) dirty reads mínimo (raro em Postgres)
Read Committed (default PG) dirty reads non-repeatable reads, phantoms baixo
Repeatable Read dirty + non-repeatable reads phantoms (mitigados no PG) médio
Serializable tudo (age como se serial) — alto (mais aborts/retries)

Os fenômenos, em português direto:

  • Dirty read: ler um dado que outra transação escreveu mas ainda não commitou (pode ser revertido). Read Committed já elimina.
  • Non-repeatable read: ler a mesma linha duas vezes na mesma transação e obter valores diferentes (outro commitou no meio). É a raiz da cobrança dupla: A lê pending=true, e mesmo que releia, sob Read Committed pode ver o que B commitou.
  • Phantom read: uma query de intervalo (ex.: "conte as faturas em aberto") retorna conjuntos diferentes em duas execuções porque outra transação inseriu/removeu linhas.

A escolha por tradeoff (aula Por que não existe arquitetura perfeita): quanto mais forte o isolamento, mais consistência e menos concorrência (mais locks/aborts, menos throughput). Você não sobe tudo para Serializable "por segurança" — isso derruba a performance sob carga. Você sobe o nível onde a corretude exige, e deixa o resto no default. A cobrança dupla precisa de proteção forte; a listagem do catálogo, não.

// ✅ isolamento forte SÓ na operação que precisa
await this.dataSource.transaction('SERIALIZABLE', async (manager) => {
  // aqui dentro, o banco garante execução como-se-serial;
  // se houver conflito, a transação aborta e você faz retry
  const sub = await manager.findOne(SubscriptionEntity, { where: { userId } });
  if (sub.pendingCharge) {
    await this.gateway.charge(sub.amount);
    sub.pendingCharge = false;
    await manager.save(sub);
  }
});

Serializable resolve a race, mas com um preço: sob conflito, o Postgres aborta uma das transações com erro de serialização, e você precisa ter lógica de retry. Isso nos leva à escolha entre as duas filosofias de lock.

Locking otimista vs. pessimista

Há duas maneiras de impedir que dois fluxos se atropelem numa linha. A diferença é filosófica e tem consequências de performance opostas.

Pessimista: "assuma que vai haver conflito; tranque antes"

Você bloqueia a linha ao lê-la (SELECT ... FOR UPDATE). Qualquer outro fluxo que queira a mesma linha espera até você commitar. Ninguém se atropela porque só um entra por vez.

// ✅ lock pessimista: trava a linha da assinatura durante a decisão
await this.dataSource.transaction(async (manager) => {
  const sub = await manager.findOne(SubscriptionEntity, {
    where: { userId },
    lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
  });
  if (sub.pendingCharge) {
    await this.gateway.charge(sub.amount);
    sub.pendingCharge = false;
    await manager.save(sub);
  }
}); // ao commitar, o lock é liberado e o próximo fluxo entra

Bom quando: conflitos são frequentes e a operação é curta. Você paga com contenção (fluxos esperam) e risco de deadlock se travar múltiplas linhas em ordens diferentes. É a escolha certa para a cobrança da fintech: o custo de errar (dinheiro dobrado) justifica serializar o acesso àquela linha.

Otimista: "assuma que conflito é raro; detecte no fim"

Você não trava nada. Cada linha tem uma coluna de versão. Ao salvar, você verifica se a versão ainda é a que você leu; se outro fluxo mudou a linha no meio (versão diferente), seu save falha e você faz retry.

// ✅ lock otimista: a entity tem uma coluna @VersionColumn
@Entity({ schema: 'billing', name: 'subscriptions' })
export class SubscriptionEntity {
  @PrimaryColumn() id: string;
  @Column() pendingCharge: boolean;
  @VersionColumn() version: number; // ⬅️ o TypeORM incrementa e checa isto
}

// no save, se a versão divergir, o TypeORM lança OptimisticLockVersionMismatchError
async chargeWithRetry(userId: string, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      const sub = await this.repo.findByUserId(userId);
      if (!sub.pendingCharge) return;
      await this.gateway.charge(sub.amount);
      sub.pendingCharge = false;
      await this.repo.save(sub);  // falha se outro fluxo mexeu na linha
      return;
    } catch (e) {
      if (e instanceof OptimisticLockVersionMismatchError && i < attempts - 1) continue;
      throw e;
    }
  }
}

Bom quando: conflitos são raros e você quer throughput máximo (ninguém espera ninguém no caso comum). Você paga com a complexidade do retry e com o risco de retries em cascata se, na verdade, os conflitos forem frequentes (aí o otimismo era injustificado e o pessimista teria sido melhor).

A regra de escolha: conflito raro → otimista (rápido, sem contenção, retry no caso raro). Conflito frequente → pessimista (serializa, evita o desperdício de tentar-e-falhar repetidamente). E há um detalhe de sistemas distribuídos crucial: quando a operação chama um serviço externo com efeito colateral (cobrar um cartão), retry cego é perigoso — você pode cobrar duas vezes ao tentar de novo. Aí entra idempotência (chave de idempotência no gateway), que a aula Event-Driven na prática: Transactional Outbox, idempotência e sagas aprofunda. Lock protege a linha; idempotência protege o efeito externo. São coisas diferentes e você frequentemente precisa das duas.

O escopo da transação: nem largo demais, nem estreito demais

O segundo erro da fintech foi a transação-monstro. O escopo de uma transação — o que está dentro do BEGIN...COMMIT — é uma decisão de arquitetura com dois perigos opostos.

Antipadrão A — Transação larga demais (o que a fintech fez):

// ❌ tudo dentro da transação: locks segurados por SEGUNDOS
await this.dataSource.transaction(async (manager) => {
  const sub = await manager.findOne(SubscriptionEntity, { where: { userId }, lock: {...} });
  await this.gateway.charge(sub.amount);          // 🐌 chamada de REDE (pode levar 2s)
  await this.antifraud.check(userId);             // 🐌 outra chamada de REDE
  await this.emailer.sendReceipt(userId);         // 🐌 e-mail dentro da transação!
  sub.pendingCharge = false;
  await manager.save(sub);
});

Enquanto essa transação está aberta, ela segura locks e uma conexão do pool. As chamadas de rede (gateway, antifraude, e-mail) podem levar segundos, e durante todo esse tempo os locks bloqueiam outros fluxos → contenção, timeouts, deadlocks, pool esgotado. Regra: nunca faça I/O de rede lento dentro de uma transação de banco. A transação deve envolver só as escritas de banco que precisam ser atômicas, e ser a mais curta possível.

Antipadrão B — Transação estreita demais (o buraco de consistência):

// ❌ operações que DEVERIAM ser atômicas, cada uma na sua transação
await this.repo.debitWallet(userId, amount);     // transação 1 (commita)
// 💥 se o processo cair AQUI, o dinheiro saiu mas o crédito não entrou
await this.repo.creditMerchant(merchantId, amount); // transação 2

Se as duas escritas precisam acontecer juntas ou nenhuma (débito e crédito de uma transferência), separá-las em transações distintas cria uma janela onde o sistema fica inconsistente se algo falhar no meio. Elas devem estar na mesma transação.

O equilíbrio — escopo certo: só o que precisa ser atômico, e nada de rede lenta dentro:

// ✅ decorator @Transactional demarca o escopo mínimo atômico
@Transactional() // TypeORM: abre transação, faz commit/rollback ao redor do método
async settleTransfer(userId: string, merchantId: string, amount: number) {
  await this.wallets.debit(userId, amount);      // escrita 1
  await this.wallets.credit(merchantId, amount); // escrita 2 — atômica com a 1
} // as chamadas de rede (notificar, e-mail) ficam FORA, disparadas depois via evento
// ✅ o I/O lento acontece FORA da transação, reagindo ao fato já commitado
async charge(userId: string) {
  await this.chargeInTransaction(userId);   // transação curta: só o banco
  this.events.publish(new ChargeSettledEvent(userId)); // e-mail/antifraude reagem depois
}

O padrão que emerge: transação curta para o que é atômico no banco; efeitos colaterais externos (e-mail, antifraude, notificação) saem para fora, tipicamente via evento (aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos). Isso mantém os locks curtos e separa o que precisa ser consistente-forte (as escritas de banco) do que tolera ser eventual (o e-mail de recibo). Repare como a decisão de escopo de transação já começa a desenhar a fronteira entre consistência forte e eventual — que é o tema final.

Consistência forte vs. eventual: a decisão de arquitetura

Aqui as transações encontram a arquitetura modular (aula Persistência e State Isolation) e os eventos (aulas Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos e Event-Driven na prática: Transactional Outbox, idempotência e sagas). No monolito clássico, tudo estava no mesmo banco, então você podia ter consistência forte em tudo (uma transação abrangia qualquer coisa). No mundo modular com State Isolation, uma transação não atravessa módulos — billing e content têm schemas isolados; não há BEGIN que cubra os dois. Isso te força a uma decisão explícita, para cada operação: essa consistência precisa ser forte, ou pode ser eventual?

  • Consistência forte (transação ACID, tudo-ou-nada, imediato): obrigatória onde uma inconsistência, mesmo temporária, é inaceitável. Dinheiro (débito e crédito de uma transferência), estoque decrementado ao vender o último item, invariantes de segurança. O custo é contenção e o fato de ficar confinado a um único banco/módulo.
  • Consistência eventual (as partes convergem depois, via eventos): aceitável — e frequentemente preferível — onde uma janela de divergência não causa dano. "O catálogo mostra o vídeo como disponível alguns segundos após o processamento terminar" tolera atraso. "O status premium de content atualiza logo após billing confirmar o pagamento" tolera uma janela de segundos. O ganho é independência, escala e desacoplamento temporal (aula As dimensões do acoplamento: força, distância e tempo).

A regra de decisão: pergunte "o que acontece se essas duas coisas ficarem divergentes por 5 segundos?". Se a resposta é "prejuízo, fraude, bug grave" → consistência forte, mesma transação, mesmo módulo. Se é "nada de mais, elas se acertam" → eventual, via evento, permitindo isolar os módulos. A maior parte de um sistema tolera eventual; uma minoria crítica exige forte. O erro comum é tratar tudo como forte (mata escala e impede modularidade) ou tudo como eventual (introduz bugs de dinheiro). Modularidade madura é saber, caso a caso, qual é qual.

O elo com o curso todo: a decisão "forte vs. eventual" é o que determina se duas coisas podem viver em módulos separados. Consistência forte exige mesmo banco/transação (mesmo módulo). Consistência eventual libera a separação (módulos distintos + eventos). Ou seja: os limites da transação ajudam a desenhar os limites dos módulos. Onde você precisa de atomicidade, provavelmente é um módulo só; onde tolera eventual, pode dividir.

Antes → Depois

Antes Depois
Cobrança dupla sob concorrência Isolamento/lock adequado fecha a race
Serializable "em tudo por segurança" Isolamento forte só onde a corretude exige
Transação envolve gateway + e-mail (segundos) Transação curta (só banco); I/O fora, via evento
Débito e crédito em transações separadas Operações atômicas na mesma transação
"Tudo consistente-forte" (não escala/modulariza) Forte onde é crítico, eventual onde tolera

Tradeoffs e armadilhas

  • Isolamento forte não é grátis. Serializable e locks pessimistas reduzem throughput e podem gerar deadlocks/aborts. Subir o nível "por precaução" em operações não-críticas degrada o sistema sob carga. Meça e aplique cirurgicamente.
  • Retry + efeito externo = risco de duplicação. Lock otimista e transações Serializable pressupõem retry. Se a operação tem efeito colateral externo não-transacional (cobrar cartão, enviar e-mail), retryar pode duplicar o efeito. Isso exige idempotência (aula Event-Driven na prática: Transactional Outbox, idempotência e sagas), não só lock. Não confunda proteger a linha (lock) com proteger o efeito (idempotência).
  • Consistência eventual esconde bugs difíceis. A janela de divergência gera estados intermediários que a UI e a lógica precisam tolerar ("processando...", "aguardando confirmação"). Debugar problemas nessa janela é mais difícil que num fluxo transacional. Só pague esse custo onde o desacoplamento vale.
  • Transação distribuída (2PC) raramente vale. Quando você sente vontade de uma transação que abrange dois módulos/serviços, quase sempre a resposta certa é uma saga (sequência de passos locais com compensação — aula Event-Driven na prática: Transactional Outbox, idempotência e sagas), não um commit distribuído de duas fases, que é frágil e mata disponibilidade.
  • @Transactional mágico pode enganar. O decorator esconde onde a transação começa e termina; é fácil, sem querer, incluir I/O lento no método decorado. Saiba exatamente o que está dentro do escopo.

Cola mental

  • Consistência sob concorrência é projetada, não automática. Race = dois fluxos leem, decidem e agem sobre o mesmo estado.
  • Isolamento (Read Committed → Serializable): mais forte = mais consistência, menos concorrência. Suba o nível só onde a corretude exige.
  • Lock pessimista (FOR UPDATE): conflito frequente, serializa, contenção. Otimista (@VersionColumn + retry): conflito raro, sem espera, retry no caso raro.
  • Escopo da transação: curto e atômico. Nunca I/O de rede lento dentro; efeitos externos saem via evento. Nem largo demais (contenção) nem estreito demais (buraco de consistência).
  • Forte vs. eventual: pergunte "e se divergir por 5s?". Prejuízo → forte (mesma transação/módulo). Tolerável → eventual (evento, módulos separados).
  • Os limites da transação ajudam a desenhar os limites dos módulos (aula Persistência e State Isolation). Retry com efeito externo exige idempotência (aula Event-Driven na prática: Transactional Outbox, idempotência e sagas), não só lock.

Referências para Aprofundamento

Fundamentos

Padrões de aplicação

Exercícios

Exercício 1 — Ache a race

No código abaixo, descreva o cenário de concorrência que gera bug e proponha duas correções (uma com lock pessimista, uma com otimista):

async redeemVoucher(userId: string, code: string) {
  const voucher = await this.repo.findByCode(code);
  if (voucher.remainingUses > 0) {
    await this.grantCredit(userId, voucher.value);
    voucher.remainingUses -= 1;
    await this.repo.save(voucher);
  }
}
Ver resposta

✓ Resposta: Race: dois resgates do mesmo voucher (com remainingUses = 1) chegam quase juntos; ambos leem remainingUses > 0, ambos concedem crédito, ambos decrementam — o voucher de uso único é resgatado duas vezes (crédito dobrado). A janela entre find e save é o furo. Correção pessimista: ler o voucher com lock: { mode: 'pessimistic_write' } dentro de uma transação; o segundo fluxo espera o primeiro commitar e então lê remainingUses = 0, não concedendo. Correção otimista: adicionar @VersionColumn ao voucher; o segundo save falha com OptimisticLockVersionMismatchError (versão mudou), e no retry ele relê remainingUses = 0 e não concede. (Alternativa robusta: um UPDATE ... SET remainingUses = remainingUses - 1 WHERE code = ? AND remainingUses > 0 atômico e checar linhas afetadas — mas atenção ao efeito externo grantCredit, ver exercício 5.)

Exercício 2 — Escolha o lock

Para cada cenário, recomende otimista ou pessimista e justifique: (a) editar o perfil do próprio usuário (colisão praticamente nunca acontece); (b) decrementar o estoque do último ingresso de um show concorridíssimo (milhares tentando ao mesmo tempo); (c) atualizar o contador de visualizações de um vídeo popular.

Ver resposta

✓ Resposta: (a) Otimista — colisão praticamente nunca ocorre (só o próprio usuário edita seu perfil), então travar seria contenção desnecessária; no caso raríssimo de conflito, o retry resolve. (b) Pessimista — milhares disputando o mesmo último ingresso significa conflito altíssimo; com otimista, quase todos falhariam a validação de versão e retentariam em cascata (desperdício massivo). Travar a linha do estoque serializa o acesso e é mais eficiente sob alta contenção. (c) Otimista, ou melhor, incremento atômico. Um contador de views tolera abordagem sem lock; o ideal é um UPDATE ... SET views = views + 1 atômico (o próprio banco serializa o incremento) — evita tanto a contenção do pessimista quanto o retry do otimista. Se precisar via ORM, otimista com retry serve, mas o incremento atômico é superior para contadores.

Exercício 3 — Conserte o escopo

A transação abaixo trava sob carga. Aponte o problema e reescreva separando o que é atômico do que deve sair da transação:

@Transactional()
async publishVideo(dto) {
  const video = await this.videos.save(dto);        // banco
  await this.transcoder.process(video.url);         // 🐌 minutos!
  await this.emailer.notifyFollowers(video.id);     // 🐌 rede
  await this.searchIndex.index(video);              // 🐌 rede
}
Ver resposta

✓ Resposta: Problema: a transação (@Transactional) envolve transcoder.process (que leva minutos) e duas chamadas de rede (e-mail, índice de busca). Durante todo esse tempo, a transação segura uma conexão do pool e eventuais locks — sob carga, o pool esgota e tudo trava. Além disso, transcodificar dentro do request nem deveria acontecer (aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos). Reescrita: manter na transação só a escrita atômica de banco (registrar o vídeo como pending), e mover todo o resto para fora, disparado por evento após o commit:

async publishVideo(dto) {
  const video = await this.saveVideoPending(dto);   // @Transactional curta: só o banco
  await this.videoProcessing.enqueue(video.id, dto.url); // fila (aula *Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos*), fora da transação
}
// transcodificação, notificação e indexação reagem ao término via eventos/consumers

Assim a transação dura milissegundos, e transcodificação/e-mail/índice acontecem de forma assíncrona e consistente-eventual (o vídeo aparece "processando" e depois "disponível").

Exercício 4 — Forte ou eventual?

Para cada par de operações, decida se precisa de consistência forte (mesma transação/módulo) ou tolera eventual (evento), aplicando o teste dos "5 segundos divergentes": (a) debitar a carteira do comprador e creditar a do vendedor numa transferência; (b) confirmar o pagamento em billing e liberar o acesso ao conteúdo em content; (c) decrementar o estoque e criar o pedido ao comprar o último item; (d) registrar que um vídeo foi assistido e atualizar as recomendações.

Ver resposta

✓ Resposta: (a) Forte — débito e crédito de uma transferência: se divergirem por 5s, existe um instante em que o dinheiro sumiu de um lado sem entrar no outro; inaceitável. Mesma transação, mesmo módulo (carteira). (b) Eventual — confirmar pagamento (billing) e liberar acesso (content) são módulos isolados (aula Persistência e State Isolation); uma janela de segundos entre "pagou" e "liberou" é tolerável e se resolve via evento PaymentConfirmed → content libera. Forçar forte aqui exigiria transação cross-módulo (que não existe com State Isolation) ou 2PC (evitar). (c) Forte — estoque e pedido do último item: se divergirem, você pode vender um item que não existe (oversell). Mesma transação (decrementar estoque condicional + criar pedido atômicos). (d) Eventual — registrar view e atualizar recomendações: se as recomendações demorarem segundos (ou minutos) para refletir a nova view, ninguém se prejudica; ideal via evento VideoWatched consumido por recommendations.

Exercício 5 — Lock não basta

Um dev protege a cobrança com lock otimista + retry (3 tentativas). Sob conflito, o save falha e ele retenta — mas o gateway de pagamento já foi chamado antes do save. Que bug isso causa e o que precisa ser adicionado (além do lock) para evitá-lo? Conecte com a aula Event-Driven na prática: Transactional Outbox, idempotência e sagas.

Ver resposta

✓ Resposta: Bug: ao retentar após o save falhar por conflito de versão, o fluxo executa de novo gateway.charge(...) antes do novo save — mas o gateway já havia sido chamado na tentativa anterior. Resultado: o cartão é cobrado uma vez por tentativa → cobrança dupla (ou tripla), exatamente o problema que se queria evitar. O lock protege a linha do banco, mas não o efeito externo (a cobrança no gateway), que não é transacional nem revertido pelo rollback. O que adicionar: idempotência na chamada externa — enviar uma chave de idempotência estável (ex.: derivada do userId + ciclo de cobrança) ao gateway, de modo que a segunda chamada com a mesma chave seja reconhecida e não gere nova cobrança. Assim, mesmo com N retries, o cartão é cobrado uma vez só. Idealmente, combina-se isso com o padrão Transactional Outbox (aula Event-Driven na prática: Transactional Outbox, idempotência e sagas), que garante que o comando de cobrança seja emitido de forma confiável e exatamente-uma-vez em relação à escrita de banco. Lição: sob retry com efeito colateral externo, lock não basta — precisa de idempotência.