Arquitetura Legado

Strangler Fig: migrando um sistema legado sem downtime e sem big bang

O rewrite big bang falha porque exige congelar o produto por meses. O padrão Strangler Fig troca o motor com o carro andando — uma rota por vez.

· 10 min de leitura

Todo sistema legado chega no mesmo ponto. Uma feature simples leva três semanas. Ninguém entende mais o módulo de faturamento. O único dev que conhecia aquela parte saiu em 2019. E então alguém diz a frase: "é mais rápido reescrever do zero".

Quase nunca é.

Por que o rewrite big bang falha

A proposta parece racional: time paralelo, stack moderna, seis meses, e no dia D vira a chave. O problema é que essa aposta carrega quatro riscos que se multiplicam entre si.

O alvo se move. Enquanto você reescreve, o sistema antigo continua recebendo correções e features — porque o negócio não para. Ou você congela o produto por seis meses (inaceitável), ou implementa cada mudança duas vezes, em dois sistemas diferentes.

O escopo é desconhecido. O legado tem comportamento que ninguém documentou e que virou regra de negócio por acidente. Aquele if estranho no cálculo de frete existe porque um cliente grande pediu em 2017. Você só descobre isso depois de migrar — quando ele liga.

Não há entrega parcial. Você queima seis meses de orçamento sem colocar nada em produção. Se o projeto for cancelado no mês cinco, o valor entregue é exatamente zero.

O dia D é tudo ou nada. Um único deploy troca o sistema inteiro. Se der errado, o rollback é do sistema inteiro — sob pressão, num sábado de madrugada.

O padrão que se repete: o rewrite atrasa, o legado continua evoluindo, a distância entre os dois aumenta, e no fim a empresa mantém dois sistemas em produção. O pior dos dois mundos.

A figueira que estrangula

Martin Fowler batizou o padrão em 2004 depois de ver figueiras mata-pau na Austrália. A semente germina num galho da árvore hospedeira, joga raízes para baixo até o chão e vai envolvendo o tronco. Anos depois, a hospedeira morre e apodrece — e o que sobra é uma figueira oca, com a forma exata da árvore que substituiu.

Aplicado a software: você não desliga o legado, você o envolve. Cada pedaço novo entra em produção assim que fica pronto, atendendo tráfego real. O legado vai perdendo responsabilidades até não sobrar nada — e aí você o remove.

Big bangStrangler Fig
Primeira entrega em produção No fim do projeto Em semanas
Rollback Do sistema inteiro De uma rota
Se o projeto parar no meio Nada entregue A parte migrada continua valendo
Regras de negócio ocultas Descobertas no dia D Descobertas uma rota por vez
Custo total Menor no papel Maior — proxy e convivência custam

Repare na última linha. Strangler Fig não é mais barato. Você paga por infraestrutura de roteamento, por manter dois sistemas conversando e por sincronização de dados. O que você compra com esse custo é a eliminação do risco de tudo-ou-nada.

A peça central: o proxy

Tudo depende de uma coisa: interceptar o tráfego antes que ele chegue no legado. Esse ponto de intercepção é o que permite mover uma rota de cada vez.

Na forma mais simples, é configuração de nginx:

upstream legado  { server legado.interno:8080; }
upstream novo    { server novo.interno:3000; }

server {
    listen 80;

    # Já migrado → serviço novo
    location /api/clientes  { proxy_pass http://novo; }
    location /api/enderecos { proxy_pass http://novo; }

    # Todo o resto continua no legado
    location / { proxy_pass http://legado; }
}

Migrar uma rota vira mover uma linha de configuração. Fazer rollback também. Esse é o ponto inteiro do padrão: a decisão de migrar deixa de ser um evento e vira uma operação reversível.

Quando você precisa de mais controle — rollout percentual, feature flags por cliente, comparação de respostas — o proxy vira código:

const ROTAS_MIGRADAS = [
  { padrao: /^\/api\/clientes/,  percentual: 100 },
  { padrao: /^\/api\/pedidos/,   percentual: 10  }, // rollout gradual
];

function escolherDestino(req) {
  const rota = ROTAS_MIGRADAS.find(r => r.padrao.test(req.path));
  if (!rota) return LEGADO;

  // Hash estável do usuário: o mesmo usuário sempre cai no mesmo lado.
  // Sem isso, o cliente alterna entre sistemas a cada request.
  const bucket = hashEstavel(req.user?.id ?? req.ip) % 100;
  return bucket < rota.percentual ? NOVO : LEGADO;
}

Detalhe que quebra em produção: se o roteamento for aleatório em vez de determinístico por usuário, o mesmo cliente vai bater ora no sistema novo, ora no legado. Com estados diferentes nos dois lados, ele vê dados aparecerem e sumirem. Sempre use hash estável de um identificador do usuário.

Escolhendo a primeira fatia

A primeira migração define se o projeto ganha ou perde credibilidade interna. Ela precisa entregar valor visível e provar o mecanismo — sem arriscar o faturamento.

Michael Feathers chama de costura (seam) o ponto onde dá para alterar o comportamento do sistema sem editar o código naquele lugar. Uma boa primeira costura tem quatro características:

O que não escolher primeiro: autenticação, faturamento, fechamento contábil. São o núcleo transacional, têm as regras mais obscuras e o custo de errar é o mais alto. Eles vêm por último, quando você já domina o mecanismo.

O problema difícil são os dados

Rotear requisição é a parte fácil. O que realmente decide o sucesso da migração é o banco — e é aqui que a maioria dos projetos trava.

Enquanto legado e sistema novo coexistem, algum dado precisa estar visível para os dois. Existem três caminhos, em ordem crescente de custo e de correção.

1. Banco compartilhado

Os dois sistemas leem e escrevem na mesma base. É o mais simples de começar e o mais comum na prática.

O custo é que o schema vira contrato entre dois sistemas: você não pode renomear uma coluna sem quebrar o outro lado. Aceitável como estágio temporário, com a extração do banco planejada. Vira dívida quando o "temporário" dura três anos.

2. Dual write (evite)

A aplicação escreve nos dois bancos a cada operação. Parece resolver, mas tem uma falha estrutural:

await bancoLegado.salvar(pedido);   // ✓ commitou
await bancoNovo.salvar(pedido);     // ✗ timeout de rede

Não existe transação atômica entre dois bancos distintos. Quando a segunda escrita falha, os dados divergem silenciosamente — e você só descobre semanas depois, quando um relatório não bate. Reconciliar isso manualmente é caro e demorado.

3. Outbox transacional ou CDC

A abordagem correta: uma única transação grava o dado e o registro da mudança na mesma base. Um processo separado lê esses registros e propaga.

BEGIN;
  INSERT INTO pedidos (id, cliente_id, total)
       VALUES ('p-1', 'c-9', 250.00);

  -- Mesma transação: se der rollback, o evento some junto
  INSERT INTO outbox (agregado, evento, payload)
       VALUES ('pedido', 'PedidoCriado',
               '{"id":"p-1","cliente_id":"c-9","total":250.00}');
COMMIT;

Se a transação falhar, o dado e o evento somem juntos — nunca ficam inconsistentes. O consumidor da outbox propaga para o outro sistema com retry, e a propagação é idempotente: reprocessar o mesmo evento não duplica nada.

A alternativa sem tocar no código do legado é CDC (Change Data Capture): ferramentas como Debezium leem o log de replicação do banco e publicam as mudanças como eventos. Útil justamente quando alterar o legado é caro ou arriscado.

Nos três casos a consistência é eventual: existe uma janela em que os dois lados divergem. Isso precisa ser decisão consciente do negócio, não descoberta do time de suporte. Para saldo financeiro, a janela pode ser inaceitável — e aí aquela fatia não migra ainda.

Como saber que o código novo está certo

Testes cobrem o comportamento que você conhece. O risco no legado é justamente o comportamento que ninguém documentou.

A técnica que resolve isso é o parallel run: por um período, toda requisição vai para os dois sistemas. A resposta do legado é a que volta para o usuário; a do sistema novo é apenas comparada e registrada.

async function calcularFrete(pedido) {
  const legado = await freteLegado(pedido);

  // Não bloqueia e não pode derrubar a requisição
  Promise.resolve()
    .then(async () => {
      const novo = await freteNovo(pedido);
      if (!equivalente(legado, novo)) {
        logger.warn('divergencia_frete', {
          pedidoId: pedido.id, legado, novo
        });
      }
    })
    .catch(err => logger.error('parallel_run_falhou', err));

  return legado; // usuário recebe sempre a resposta do legado
}

Depois de algumas semanas, o log de divergências é o documento que ninguém escreveu: cada divergência é uma regra de negócio oculta ou um bug do código novo. Quando a taxa chega perto de zero em volume real, você inverte a origem da resposta com confiança de dados, não de opinião.

Dois cuidados: o parallel run dobra a carga naquela rota, então avalie a capacidade antes; e ele só serve para operações sem efeito colateral. Nunca rode em paralelo algo que cobra cartão, envia e-mail ou baixa estoque — a menos que o caminho novo esteja em modo simulado.

Quando terminar

A migração acaba quando o proxy não tem mais nenhuma rota apontando para o legado. Aí vêm as duas últimas etapas, e elas são as mais puladas:

  1. Desligar o legado, mas mantê-lo de pé por algumas semanas. Sem tráfego, mas ligável. É a sua apólice de seguro.
  2. Remover de verdade — código, servidores, jobs agendados, credenciais, acessos. Legado desligado que continua existindo volta a ligar sozinho um dia.

O proxy costuma ficar. Ele já é o ponto natural para autenticação, rate limiting e observabilidade — a migração acabou entregando um API gateway de brinde.

Quando não usar

O padrão não é universal. Ele cobra um preço em complexidade, e há casos em que esse preço não compensa:

Em resumo

Strangler Fig não é a opção mais rápida nem a mais barata. É a que permite errar barato: cada passo entra em produção sozinho, prova seu valor com tráfego real e volta atrás com uma linha de configuração.

Num rewrite big bang, você descobre se acertou depois de seis meses. Aqui, você descobre na primeira semana — e ainda dá tempo de corrigir o rumo.