Blog

Migração de monólito em partes: extrair um módulo para serviço com strangler fig sem reescrever tudo

Uma plataforma de pedidos B2B roda há seis anos em um monólito Node.js com 410 mil linhas, um deploy de 45 minutos e uma suíte de testes de 38. O time de faturamento precisa lançar regras fiscais toda semana e espera a fila de deploy de 14 pessoas. A primeira tentativa de resolver isso foi reescrever o faturamento do zero em um serviço novo: nove meses depois, a versão nova ainda não emitia as mesmas faturas que a antiga e foi abandonada. Este artigo mostra o caminho que funcionou na segunda vez, em 11 semanas: escolher o módulo pelos dados, criar uma costura única dentro do monólito, comparar o serviço novo em sombra, cortar o tráfego por tenant com recuo imediato, transferir a posse das tabelas sem copiar nada e só então apagar o código antigo.

2026-11-02 / Arquitetura / 15 min

01

Por que a reescrita total falha e o que o strangler fig muda

A reescrita total aposta que é possível reproduzir, de uma vez, o comportamento de um sistema que ninguém documentou por inteiro. O monólito acumula regras que só existem no código: arredondamento de desconto por item, vencimento que pula feriado municipal, fatura que não é emitida para cliente com crédito. A versão nova nasce sem elas, e cada uma é descoberta em produção ou numa homologação que nunca termina. Enquanto isso, o sistema antigo continua mudando, e a reescrita persegue um alvo móvel.

O strangler fig inverte a aposta. Em vez de trocar tudo no fim, o sistema novo cresce ao redor do antigo e assume uma responsabilidade por vez, com o tráfego real decidindo quando cada parte está pronta. O nome vem da figueira que envolve a árvore hospedeira até substituí-la. Na prática, isso exige três coisas: um ponto onde o tráfego pode ser desviado, um jeito de comparar o comportamento antigo com o novo, e a disciplina de remover o antigo assim que ele deixa de ser usado.

AspectoReescrita totalStrangler fig
Primeiro valor em produçãoNo fim, depois de mesesNa primeira fase, em semanas
Regras não documentadasDescobertas no corteDescobertas pela comparação em sombra
RecuoVoltar o sistema inteiroDesligar uma flag por tenant
Sistema antigo durante a obraCongelado ou divergindoContinua evoluindo, por uma costura única
Risco de abandonoAlto: nada entregue até o fimBaixo: cada fase já entrega valor

02

Escolher o primeiro módulo pelos dados, não pelo organograma

O primeiro módulo define se a estratégia ganha credibilidade ou vira mais uma iniciativa parada. O critério mais citado, o módulo que mais muda, é necessário mas não suficiente. O que decide a dificuldade é quantas partes do sistema leem e escrevem nas tabelas do módulo, e quantas chaves estrangeiras cruzam a fronteira. Essas duas respostas saem do próprio banco, sem depender da memória de ninguém.

-- Quem toca as tabelas de faturamento hoje, e quantas vezes. Exige pg_stat_statements.
SELECT left(regexp_replace(query, '\s+', ' ', 'g'), 140) AS consulta,
       calls,
       round(mean_exec_time::numeric, 2) AS media_ms
  FROM pg_stat_statements
 WHERE query ILIKE ANY (ARRAY['%faturas%', '%itens_fatura%', '%pagamentos%'])
 ORDER BY calls DESC
 LIMIT 30;

-- Chaves estrangeiras que cruzam a fronteira: cada uma vira uma decisao de contrato.
SELECT conrelid::regclass  AS tabela,
       confrelid::regclass AS referencia,
       conname
  FROM pg_constraint
 WHERE contype = 'f'
   AND (conrelid::regclass::text IN ('faturas', 'itens_fatura', 'pagamentos')
        OR confrelid::regclass::text IN ('faturas', 'itens_fatura', 'pagamentos'));

No cenário do artigo, o levantamento comparou três candidatos. Faturamento venceu porque tinha tabelas próprias, poucas escritas de fora e um motivo de negócio claro para deploy independente. Catálogo mudava mais, mas era lido por quase toda consulta do sistema, e extraí-lo primeiro criaria chamadas de rede no caminho de cada página.

CritérioFaturamentoNotificaçõesCatálogo
Tabelas próprias3 (faturas, itens_fatura, pagamentos)17
Pontos de escrita fora do módulo4019
Consultas com JOIN entre módulos6041
Chaves estrangeiras na fronteira2011
Deploys por semana desejados513
DecisãoPrimeiroFácil, mas pouco valorDepois, com cache de leitura

Notificações seria o módulo mais fácil, e por isso é a escolha comum para "aprender o processo". O problema é que ele não resolve a dor que justificou o projeto, e o time sai da primeira extração sem ter enfrentado dados compartilhados, que é exatamente onde a migração costuma travar.

03

A costura: um único ponto de entrada dentro do monólito

Antes de existir qualquer serviço, o monólito precisa parar de falar com o faturamento de 23 lugares diferentes. A primeira entrega é uma porta: um módulo com uma interface pequena, por onde passam todas as chamadas, com a implementação atual movida para trás dela sem mudar comportamento. Essa técnica, chamada branch by abstraction, é o que torna o desvio possível sem tocar nos chamadores de novo.

// faturamento/porta.js - unico ponto pelo qual o resto do monolito fala com faturamento.
import * as local from './local.js';
import { criarClienteRemoto } from './remoto.js';
import { flags } from '../config/flags.js';

const remoto = criarClienteRemoto({
  baseUrl: process.env.FATURAMENTO_URL,
  timeoutMs: 800,
});

const implementacaoPara = (tenantId) =>
  flags.ativo('faturamento-remoto', { tenantId }) ? remoto : local;

export const faturamento = {
  emitirFatura: (tenantId, pedido) => implementacaoPara(tenantId).emitirFatura(tenantId, pedido),
  buscarFatura: (tenantId, faturaId) => implementacaoPara(tenantId).buscarFatura(tenantId, faturaId),
  listarEmAberto: (tenantId, clienteId) =>
    implementacaoPara(tenantId).listarEmAberto(tenantId, clienteId),
};

// faturamento/remoto.js - mesma interface de local.js, falando HTTP com o servico novo.
export function criarClienteRemoto({ baseUrl, timeoutMs }) {
  async function chamar(caminho, { method = 'GET', body, idempotencyKey } = {}) {
    const resposta = await fetch(new URL(caminho, baseUrl), {
      method,
      headers: {
        'content-type': 'application/json',
        ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(timeoutMs),
    });
    if (resposta.status === 404) return null;
    if (!resposta.ok) throw new Error(`faturamento respondeu ${resposta.status} em ${caminho}`);
    return resposta.json();
  }

  return {
    emitirFatura: (tenantId, pedido) =>
      chamar(`/tenants/${tenantId}/faturas`, {
        method: 'POST',
        body: pedido,
        idempotencyKey: `pedido-${pedido.id}`,
      }),
    buscarFatura: (tenantId, faturaId) => chamar(`/tenants/${tenantId}/faturas/${faturaId}`),
    listarEmAberto: (tenantId, clienteId) =>
      chamar(`/tenants/${tenantId}/clientes/${clienteId}/faturas?status=aberta`),
  };
}

Três detalhes fazem diferença. A decisão de rota é por tenant, não por requisição, para que um mesmo cliente não veja faturas emitidas por dois sistemas no mesmo dia. A chave de idempotência derivada do pedido garante que um retry depois de timeout não emita fatura duplicada, desde que o serviço novo honre o cabeçalho. E o timeout de 800 ms é explícito: uma chamada que antes era uma função local agora pode esperar a rede, e o orçamento de latência da rota precisa contar com isso.

  • Um lint que proíbe importar faturamento/local.js fora de faturamento/ impede que a costura vaze de novo.
  • Os testes existentes passam a rodar contra a porta, e servem depois como contrato da implementação remota.
  • A interface nasce com as operações que o negócio usa, como emitirFatura e listarEmAberto, e não com o formato das tabelas.

04

Sombra e corte por tenant: deixar o tráfego real decidir

Com a porta no lugar, o serviço novo pode receber tráfego real sem responder a ninguém. Na fase de sombra, uma amostra das leituras é enviada aos dois lados: o monólito responde ao cliente, e a resposta do serviço novo é comparada campo a campo e descartada. Escritas não entram na sombra, porque executar duas vezes uma emissão de fatura é exatamente o erro que se quer evitar.

// faturamento/sombra.js - responde com a implementacao antiga e compara a nova em segundo plano.
// So leituras passam pela sombra: duplicar escrita duplicaria cobranca.
import * as local from './local.js';

const CAMPOS = ['status', 'total_centavos', 'vencimento', 'cliente_id'];

const normalizar = (valor) =>
  valor instanceof Date ? valor.toISOString().slice(0, 10) : String(valor ?? '');

function compararCampos(antigo, novo) {
  if (!antigo || !novo) {
    return Boolean(antigo) === Boolean(novo)
      ? []
      : [{ campo: '*', antigo: Boolean(antigo), novo: Boolean(novo) }];
  }
  return CAMPOS.filter((campo) => normalizar(antigo[campo]) !== normalizar(novo[campo])).map(
    (campo) => ({ campo, antigo: antigo[campo], novo: novo[campo] }),
  );
}

export function comSombra(remoto, { amostra = 0.1, registrar }) {
  return {
    ...local,
    async buscarFatura(tenantId, faturaId) {
      const atual = await local.buscarFatura(tenantId, faturaId);
      if (Math.random() < amostra) {
        remoto
          .buscarFatura(tenantId, faturaId)
          .then((novo) => {
            const diferencas = compararCampos(atual, novo);
            if (diferencas.length) registrar({ tenantId, faturaId, diferencas });
          })
          .catch((erro) => registrar({ tenantId, faturaId, erro: erro.message }));
      }
      return atual;
    },
  };
}

Em duas semanas de sombra com 10% das leituras, a comparação encontrou duas regras que a especificação não mencionava. O serviço novo arredondava o desconto no total, e o antigo arredondava por item, gerando diferença de um centavo em 0,4% das faturas com desconto. E o vencimento era calculado em UTC no serviço novo e no fuso do tenant no antigo, o que mudava a data de faturas criadas depois das 21h. Na reescrita abandonada, as duas teriam aparecido como reclamação de cliente.

Fases da extracao do faturamento (cada fase e reversivel ate a seguinte)

  1. Costura   rotas do monolito --> porta.js --> local.js --> tabelas em public
  2. Sombra    porta.js --> local.js (responde)
                        \-> remoto (compara e descarta, 10% das leituras)
  3. Corte     porta.js --flag por tenant--> remoto --> servico faturamento
                        \-> local.js (recuo imediato: desligar a flag)
  4. Posse     servico --> schema faturamento   |   monolito --> view somente leitura
  5. Remocao   local.js, sombra e views apagados; banco separado so se houver motivo medido
  1. Zerar as divergências da sombra por sete dias seguidos, incluindo um fechamento de mês.
  2. Ligar a flag para tenants internos e de teste, com emissão real.
  3. Avançar para 1%, 10%, 50% e 100% dos tenants, com pelo menos dois dias em cada etapa.
  4. Em cada etapa, comparar taxa de erro, p95 da rota de checkout e faturas emitidas por hora contra a etapa anterior.
  5. Manter local.js funcional até a fase de posse: o recuo é desligar a flag, e precisa continuar funcionando.

05

Dados: transferir a posse sem copiar tabelas

É aqui que a maioria das extrações vira um monólito distribuído. Se o serviço novo tem seu próprio código mas o monólito continua escrevendo nas mesmas tabelas, qualquer mudança de esquema exige deploy coordenado dos dois, e a independência prometida não existe. A regra que resolve é simples de enunciar: cada tabela tem um único dono que escreve, e os outros leem por um contrato.

Isso não exige banco separado no primeiro dia. Mover as tabelas para um schema próprio no mesmo banco, com papéis distintos, já impõe a fronteira e mantém backup, réplicas e transações locais funcionando. O ALTER TABLE SET SCHEMA é uma operação de catálogo, não copia linhas, e as chaves estrangeiras existentes continuam válidas porque referenciam a tabela pelo identificador interno, não pelo nome.

-- Transferir a posse sem copiar dados: schema proprio no mesmo banco, papeis separados
-- e views somente leitura para o codigo antigo que ainda consulta as tabelas.
SET lock_timeout = '2s';

BEGIN;
CREATE SCHEMA faturamento;
ALTER TABLE public.faturas      SET SCHEMA faturamento;
ALTER TABLE public.itens_fatura SET SCHEMA faturamento;
ALTER TABLE public.pagamentos   SET SCHEMA faturamento;

-- O monolito continua lendo public.faturas, mas so o que precisa e so lendo.
CREATE VIEW public.faturas AS
  SELECT id, tenant_id, pedido_id, cliente_id, status, total_centavos, vencimento, criado_em
    FROM faturamento.faturas;
COMMIT;

-- O servico e o unico que escreve.
GRANT USAGE ON SCHEMA faturamento TO svc_faturamento;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA faturamento TO svc_faturamento;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA faturamento TO svc_faturamento;

-- O monolito perde a escrita: qualquer INSERT esquecido falha em teste, nao em producao.
REVOKE ALL ON ALL TABLES IN SCHEMA faturamento FROM app_monolito;
REVOKE ALL ON public.faturas FROM app_monolito;
GRANT SELECT ON public.faturas TO app_monolito;

As seis consultas com JOIN entre pedidos e faturas foram o trabalho real desta fase. Quatro passaram a ler a view, o que é aceitável enquanto o contrato for só leitura e as colunas expostas forem estáveis. Duas, usadas no relatório de inadimplência, foram reescritas para consumir eventos fatura_emitida e fatura_paga publicados pelo serviço através de um outbox transacional, mantendo uma projeção local no monólito.

Necessidade do monólitoSoluçãoQuando usar
Ler status de uma fatura no fluxo do pedidoChamada à API do serviçoLeitura pontual, tolera 800 ms no pior caso
Listar faturas em telas internasView somente leitura com colunas estáveisTransição, enquanto o mesmo banco for compartilhado
Relatórios com JOIN e agregaçãoProjeção local alimentada por eventosConsultas pesadas, tolerância a segundos de atraso
Criar ou alterar faturaSomente pela API do serviçoSempre: escrita tem um dono só

06

Remover o antigo e saber quando parar

A extração só termina quando o código antigo sai do repositório. Enquanto local.js, a sombra e a flag existirem, o monólito carrega duas implementações, e a próxima regra fiscal acaba sendo escrita nas duas. O critério de remoção deve ser objetivo: 100% dos tenants no serviço novo por quatro semanas, nenhum uso da flag para recuo nesse período e nenhuma leitura de faturamento.* pelo papel do monólito, verificável em pg_stat_statements.

  1. Apagar local.js, sombra.js e a flag, deixando a porta como cliente fino da API.
  2. Remover as views à medida que as leituras migram para a API ou para a projeção, uma por vez.
  3. Remover as chaves estrangeiras que cruzam a fronteira e substituir por validação no contrato, quando o banco for separado.
  4. Separar o banco apenas se houver motivo medido: carga concorrente, requisito de isolamento ou ciclo de manutenção diferente.

No cenário, o deploy do faturamento caiu de 45 para 6 minutos, o time passou a lançar quatro vezes por semana e o p95 do checkout subiu 7 ms pela chamada de rede, valor que ficou dentro do orçamento. O sinal de que é hora de parar de extrair é quando o próximo candidato exige chamadas síncronas em cadeia para responder uma única tela. Nesse ponto, um módulo bem isolado dentro do monólito entrega mais do que outro serviço.

FAQ

Perguntas frequentes

Preciso de um API gateway ou service mesh para aplicar o strangler fig?

Não. Para rotas expostas diretamente ao cliente, um proxy reverso que já existe, como Nginx ou o balanceador da nuvem, consegue desviar por caminho. Para chamadas internas, que são a maioria num monólito, o ponto de desvio é a costura no próprio código, com uma flag por tenant. Ferramentas maiores podem ajudar quando houver vários serviços, mas não são pré-requisito para o primeiro.

E as transações que hoje envolvem pedido e fatura no mesmo COMMIT?

Elas deixam de ser uma transação única e passam a ser um fluxo com consistência eventual. O pedido é gravado junto com um evento no outbox, o serviço de faturamento consome o evento e emite a fatura com idempotência pela chave do pedido, e falhas são tratadas com compensação ou reprocessamento. Antes de extrair, liste essas transações: se forem muitas e críticas, o módulo talvez não seja o melhor primeiro candidato.

Quanto tempo a fase de sombra deve durar?

O suficiente para cobrir os ciclos do negócio, não um número fixo de dias. Para faturamento, isso inclui pelo menos um fechamento de mês, um feriado e o volume de pico. O critério de saída é ter divergência zero por um período contínuo depois da última correção, com cada divergência anterior explicada e coberta por teste.

Extrair um módulo é mover a fronteira dos dados, não copiar código

A reescrita de nove meses falhou porque tentou reproduzir de uma vez regras que só o tráfego real revela. A extração que funcionou criou uma costura única, comparou o serviço novo em sombra até a divergência zerar, cortou por tenant com recuo de uma flag, transferiu a posse das tabelas para um schema com dono único e só então apagou o código antigo. O resultado foi deploy independente em 11 semanas, sem janela de manutenção e sem fatura errada chegando ao cliente.