Blog

Feature flag que virou dívida: como remover a bandeira sem quebrar produção

A busca por uma flag específica no código retornou setenta e quatro ocorrências e a data do commit que a criou era de vinte e dois meses atrás. O rollout terminou em uma semana, a variante nova ganhou, e ninguém voltou para apagar a chave. Esse é o estado normal de quase todo sistema que adotou flags: a ferramenta que existe para reduzir risco de deploy acumula um passivo que multiplica o número de caminhos possíveis do código a cada release. Este artigo mostra por que a flag esquecida é uma dívida com juros compostos e não apenas um if morto, por que a decisão de remover depende de dado de avaliação e não de memória de quem estava no time, qual é a ordem de remoção que não quebra a requisição que está em voo neste exato momento, por que o kill switch nunca deve entrar na mesma fila de limpeza que a flag de rollout, e qual verificação automática impede que a próxima flag repita o ciclo.

2026-08-27 / Arquitetura / 16 min

01

A flag esquecida não é um if morto, é um multiplicador de estados

O argumento mais comum contra a limpeza é que a flag desligada não custa nada, porque a condição resolve em falso e o corpo nunca executa. O custo real não está no tempo de execução, está no número de estados que o sistema declara suportar. Cada flag booleana viva dobra o espaço de combinação do código que ela envolve, e como as flags não são independentes entre si, dez flags ativas descrevem mil vinte e quatro configurações possíveis. Nenhum time testa mil vinte e quatro configurações. O que acontece na prática é que uma dúzia de combinações é exercitada em produção e o restante existe apenas como promessa não verificada dentro do repositório.

A consequência aparece quando alguém precisa mudar o código adjacente. O engenheiro que abre o arquivo dois anos depois não sabe se a variante antiga ainda tem tráfego, não sabe se pode apagar o ramo do else, e o caminho mais barato para ele é preservar os dois lados e adicionar o terceiro. É assim que a dívida cresce sem que ninguém tome uma decisão errada isoladamente: cada escolha individual de preservar o desconhecido é razoável, e a soma delas produz um arquivo onde ninguém mais consegue afirmar o que está em produção.

Existe ainda um custo que aparece só no incidente e que é o mais caro dos três. Quando algo quebra às duas da manhã, a primeira pergunta é qual código o cliente afetado estava executando, e a resposta depende de resolver o estado de todas as flags que atravessam aquele caminho para aquele usuário naquele instante. Com flags limpas, isso é ler o arquivo. Com setenta flags vivas e histórico de valores não retido, isso é uma investigação que consome o tempo do incidente inteiro antes de qualquer diagnóstico começar.

Tipo de flagVida esperadaSinal de que virou dívidaDestino correto
Rollout de releaseDias a semanasCem por cento em uma variante há mais de um cicloRemover código e chave
Experimento A/BDuração do testeAnálise já publicada e decisão tomadaRemover o braço perdedor e a chave
Kill switch operacionalPermanente por designNunca acionada e sem teste de acionamentoManter, documentar e exercitar
Permissão por plano ou clientePermanente por designRegra de negócio escondida em serviço de flagsMover para autorização, sair do sistema de flags
Configuração de ambientePermanenteValor idêntico em todos os ambientes há mesesFixar no código e remover a chave

A tabela existe porque o erro mais frequente na limpeza não é esquecer de remover, é remover a coisa errada. Um kill switch de dependência externa passa anos sem ser acionado e parece exatamente igual a uma flag de rollout esquecida quando você olha só a telemetria de uso. A diferença não está no dado, está na intenção declarada no momento da criação, e é por isso que o tipo precisa ser um campo obrigatório da flag e não uma convenção de nome.

02

Decidir com dado de avaliação, não com memória do time

A pergunta que trava a remoção é sempre a mesma: alguém ainda depende disso? Responder por memória não funciona porque a pessoa que criou a flag frequentemente já saiu do time, e responder por busca no código também não, porque a busca mostra onde a flag é lida e não quem a está recebendo como verdadeira. A única resposta confiável vem de instrumentar a própria avaliação, registrando cada consulta com a chave, o valor retornado, o motivo da decisão e o identificador do sujeito avaliado.

O campo mais importante desses quatro é o motivo, e é o que costuma faltar nas implementações caseiras. Saber que a flag retornou verdadeiro dez mil vezes não diz nada sobre poder removê-la. Saber que retornou verdadeiro dez mil vezes por causa da regra padrão e zero vez por causa de uma regra de segmento diz que o segmento pode ser apagado agora. Saber que retornou falso um milhão de vezes por causa do padrão e trinta e sete vezes por causa de uma lista nominal de clientes diz que existem trinta e sete contratos que quebram se você apagar o ramo antigo.

// flags/evaluate.js
// A avaliacao emite telemetria com o MOTIVO da decisao, nao so o valor.
// Sem o motivo, "retornou true 10k vezes" nao autoriza remover nada:
// pode ser a regra padrao ou uma regra de segmento com 3 clientes presos.

const REASONS = {
  DEFAULT: 'default',    // caiu no valor padrao da flag
  SEGMENT: 'segment',    // bateu numa regra de segmento
  OVERRIDE: 'override',  // override explicito por sujeito
  KILL_SWITCH: 'kill',   // desligada manualmente pela operacao
  MISSING: 'missing',    // chave nao existe mais no provedor
};

export const createFlagClient = ({ store, metrics, clock }) => {
  const evaluate = (key, subject) => {
    const definition = store.get(key);

    // Chave removida do provedor mas ainda lida pelo codigo. Este e o
    // caminho que produz o incidente silencioso: o fallback assume um
    // valor e ninguem percebe ate o comportamento divergir em producao.
    if (!definition) {
      metrics.increment('flag.evaluation', { key, value: 'false', reason: REASONS.MISSING });
      return { value: false, reason: REASONS.MISSING };
    }

    if (definition.killed) {
      metrics.increment('flag.evaluation', { key, value: 'false', reason: REASONS.KILL_SWITCH });
      return { value: false, reason: REASONS.KILL_SWITCH };
    }

    const override = definition.overrides?.[subject.id];
    if (override !== undefined) {
      metrics.increment('flag.evaluation', {
        key,
        value: String(override),
        reason: REASONS.OVERRIDE,
      });
      return { value: override, reason: REASONS.OVERRIDE, subjectId: subject.id };
    }

    const segment = definition.segments?.find((rule) => rule.matches(subject));
    if (segment) {
      metrics.increment('flag.evaluation', {
        key,
        value: String(segment.value),
        reason: REASONS.SEGMENT,
        segment: segment.name,
      });
      return { value: segment.value, reason: REASONS.SEGMENT, segment: segment.name };
    }

    metrics.increment('flag.evaluation', {
      key,
      value: String(definition.defaultValue),
      reason: REASONS.DEFAULT,
    });
    return { value: definition.defaultValue, reason: REASONS.DEFAULT };
  };

  // O relatorio de candidatas nao pergunta "ha quanto tempo a flag existe",
  // pergunta "ha quantos dias so existe uma resposta e ela vem do padrao".
  const removalCandidates = ({ window }) => {
    const since = clock.now() - window;

    return store.keys().flatMap((key) => {
      const definition = store.get(key);

      // Kill switch e permissao sao excluidos ANTES da analise. Eles sao
      // justamente os que retornam sempre o mesmo valor, e sem esta linha
      // o relatorio poe no topo da lista o disjuntor do provedor de pagamento.
      if (definition.type === 'kill_switch' || definition.type === 'permission') return [];

      const stats = metrics.query('flag.evaluation', { key, since });
      if (stats.length === 0) return [];

      const distinctValues = new Set(stats.map((row) => row.value));
      const nonDefaultReasons = stats.filter((row) => row.reason !== REASONS.DEFAULT);

      if (distinctValues.size !== 1) return [];
      if (nonDefaultReasons.length > 0) return [];

      return [{ key, winner: [...distinctValues][0], evaluations: stats.length }];
    });
  };

  return { evaluate, removalCandidates, REASONS };
};

O detalhe do relatório que costuma ser subestimado é a exclusão explícita de kill switches e permissões antes de qualquer análise estatística. Sem essa linha, o relatório coloca no topo da lista exatamente as flags que nunca devem ser removidas, porque elas são justamente as que retornam sempre o mesmo valor. Um time que confia num relatório sem esse filtro apaga o disjuntor da integração de pagamento no primeiro ciclo de limpeza e descobre o problema no próximo incidente do provedor.

A janela de observação também merece um critério explícito em vez de um número redondo. Ela precisa cobrir pelo menos um ciclo completo do processo mais lento que toca aquele caminho, o que na maioria dos sistemas significa o fechamento mensal ou o relatório trimestral. Uma flag que atende um fluxo executado uma vez por mês parece morta em qualquer janela de duas semanas, e apagá-la produz uma falha que só aparece trinta dias depois do merge, quando ninguém mais associa o incidente à limpeza.

03

A ordem de remoção que não quebra a requisição em voo

A tentação é resolver tudo em um pull request só: apaga o if, apaga o ramo perdedor, apaga a chave no provedor e fecha o ticket. Isso funciona no ambiente local e falha em produção pela mesma razão que uma migração de banco sem etapas falha. Durante o deploy existem instâncias antigas ainda respondendo, requisições que já leram a flag e ainda não terminaram, e consumidores de fila que capturaram o valor no início do lote. Remover a chave do provedor antes de remover a leitura no código deixa o código antigo caindo no caminho de chave inexistente, que resolve pelo valor de fallback e não necessariamente pelo valor que estava em produção.

ORDEM ERRADA (um PR so)

  apaga chave no provedor + apaga if + apaga ramo antigo
        |
        v
  instancias antigas ainda vivas leem chave inexistente
        |
        v
  fallback assume false, ramo perdedor volta a rodar
  por 3 minutos, sem alerta, sem rastro


ORDEM CORRETA (tres passos, cada um reversivel)

  P1  codigo    fixa a leitura no valor vencedor
                constante local no lugar da consulta
                chave AINDA existe no provedor
                rollback = reverter o commit

  P2  codigo    remove o if, o ramo perdedor e os
                testes exclusivos do ramo perdedor
                chave AINDA existe, agora sem leitor
                rollback = reverter o commit

  P3  provedor  arquiva a chave apos a janela de
                retencao, com metrica de avaliacao
                em zero durante toda a janela
                rollback = restaurar a definicao

  ^ em nenhum passo existe codigo vivo lendo chave ausente

O primeiro passo é o que quase todo time pula e é o que torna os outros dois seguros. Fixar o valor vencedor no código, mantendo a chave viva no provedor, cria uma janela em que o comportamento novo é permanente mas a reversão ainda é um revert de commit e não uma mudança de configuração sob pressão. Se algo estava dependendo do ramo antigo por um caminho que a telemetria não capturou, o sintoma aparece nessa janela e o custo é um revert, não um incidente com dado inconsistente.

Entre o segundo e o terceiro passo é obrigatório esperar, e o critério de espera é o tempo de vida do processo mais longo que pode ter capturado o valor. Em um serviço web isso é o tempo de drenagem do deploy, em minutos. Em um consumidor de fila que processa lotes grandes isso pode ser meia hora. Em um job agendado que roda semanalmente isso é uma semana. Arquivar a chave enquanto um consumidor antigo ainda está no meio de um lote produz exatamente o cenário do diagrama, com a diferença de que na fila o efeito é gravado no banco em vez de devolvido numa resposta HTTP.

  1. Confirmar no relatório de avaliação que a flag tem valor único e motivo padrão durante a janela que cobre o processo mais lento do domínio.
  2. Fixar o valor vencedor no código com uma constante local, mantendo a chave existente no provedor, e fazer deploy.
  3. Observar a janela de drenagem completa, verificando que a taxa de erro e as métricas de negócio do caminho afetado não mudaram.
  4. Remover o condicional, o ramo perdedor, os testes exclusivos do ramo perdedor e a constante local, em um pull request separado.
  5. Confirmar que a métrica de avaliação daquela chave caiu a zero e permaneceu em zero pela janela inteira.
  6. Arquivar a chave no provedor, mantendo a definição por um período de retenção antes da exclusão definitiva.

O quinto item é o único que oferece prova em vez de confiança. Enquanto a métrica de avaliação daquela chave não chegar a zero e permanecer em zero pela janela inteira, existe pelo menos um caller vivo, e ele pode ser um serviço que ninguém mapeou, um script de operação ou um cliente móvel com versão antiga. Arquivar antes disso é trocar uma limpeza de código por uma investigação futura de causa desconhecida.

04

O que fazer quando a flag está espalhada em setenta lugares

A flag que aparece em setenta e quatro lugares não é uma flag, é uma decisão arquitetural implementada como condicional distribuído. Tratar cada ocorrência como item de checklist produz um pull request gigantesco que ninguém revisa com atenção, e revisar mal é justamente o modo mais fácil de apagar o ramo errado em três dos setenta e quatro pontos. A abordagem que funciona é reduzir o número de pontos de decisão antes de tentar remover qualquer coisa.

A redução acontece em duas fases. Primeiro, todas as leituras dispersas são substituídas por uma única leitura no ponto de entrada do fluxo, e o valor resolvido passa a viajar como parte do contexto da requisição. Isso não remove nenhuma condicional ainda, mas transforma setenta e quatro avaliações independentes em uma avaliação e setenta e três leituras de um valor imutável, o que já elimina a classe de bug em que a flag muda no meio da requisição. Segundo, com a decisão centralizada, o polimorfismo substitui a condicional: duas implementações da mesma interface, escolhidas uma vez, e o corpo do código deixa de saber que a flag existe.

// ANTES: 74 pontos consultam a flag de forma independente.
// Alem de ilegivel, a flag pode mudar no meio da requisicao e produzir
// uma execucao que seguiu os dois caminhos ao mesmo tempo.
function calcularFrete(pedido) {
  if (flags.enabled('novo_motor_frete', pedido.cliente)) {
    return motorNovo.cotar(pedido);
  }
  return motorAntigo.cotar(pedido);
}

function estimarPrazo(pedido) {
  if (flags.enabled('novo_motor_frete', pedido.cliente)) {
    return motorNovo.prazo(pedido);
  }
  return motorAntigo.prazo(pedido);
}

// DEPOIS, FASE 1: uma avaliacao na borda, valor congelado no contexto.
// As 74 condicionais continuam existindo, mas agora leem um valor
// imutavel em vez de consultar o provedor 74 vezes por requisicao.
export const resolverContexto = (req, { flags }) => ({
  cliente: req.cliente,
  motorFrete: flags.enabled('novo_motor_frete', req.cliente) ? 'novo' : 'antigo',
});

// DEPOIS, FASE 2: a decisao vira selecao de implementacao, uma vez so.
// O corpo do dominio nao sabe mais que existe uma flag, e a remocao
// futura e apagar uma linha do mapa, nao 74 condicionais.
const MOTORES = { novo: motorNovo, antigo: motorAntigo };

export const criarServicoFrete = (contexto) => {
  const motor = MOTORES[contexto.motorFrete];

  return {
    cotar: (pedido) => motor.cotar(pedido),
    prazo: (pedido) => motor.prazo(pedido),
  };
};

// FASE 3, quando o relatorio autorizar: o mapa perde a entrada antiga,
// resolverContexto perde a linha da flag, e motorAntigo sai do
// repositorio inteiro em vez de sair em 74 diffs espalhados.

A objeção legítima a essa sequência é que ela transforma uma limpeza em uma refatoração, e refatoração custa tempo que o time não tem. O contra-argumento é que a alternativa não é uma limpeza barata, é um pull request de setenta e quatro pontos que o revisor aprova por cansaço. A refatoração intermediária tem uma propriedade que o pull request gigante não tem: cada passo é individualmente revisável, individualmente reversível, e deixa o sistema em estado válido mesmo se o time abandonar a limpeza no meio, o que acontece com frequência.

05

A limpeza que se sustenta sozinha

Toda equipe que acumulou dívida de flags já tentou resolver com um mutirão, e o mutirão funciona uma vez. Seis meses depois o número volta ao patamar anterior porque o processo que gerou a dívida continua igual. O que muda o resultado não é a intensidade da limpeza, é tornar a criação da flag um evento que já carrega a data da própria remoção.

Isso significa exigir três campos obrigatórios no momento da criação, sem os quais o cadastro é recusado: o tipo, que separa rollout de kill switch e determina se a flag entra na fila de limpeza; a data de expiração esperada, que é uma estimativa e não um contrato; e o dono, que precisa ser um time e nunca uma pessoa, porque pessoas mudam de time e a flag continua. Nenhum desses campos impede a dívida sozinho, mas juntos eles transformam a pergunta de alguém sabe se isso ainda é usado em uma consulta.

A automação que faltava vem em seguida e é modesta de propósito. Uma verificação no pipeline que falha sempre que existe flag de rollout vencida produz atrito no momento errado, porque bloqueia um deploy sem nenhuma relação com a flag em questão. O que funciona é o alerta semanal para o time dono, com a lista de flags vencidas e o dado de avaliação de cada uma, mais um bloqueio no pipeline apenas para o caso específico da flag órfã, quando o time dono não existe mais.

// scripts/flag-debt-report.mjs
// Roda semanalmente e abre uma issue por time dono, nunca uma issue
// gigante com tudo. A lista precisa caber numa sprint para ser lida.

import { createFlagClient } from '../src/flags/evaluate.js';
import { store, metrics, equipesAtivas, abrirIssue } from './deps.mjs';

const DIA = 24 * 60 * 60 * 1000;

const client = createFlagClient({ store, metrics, clock: { now: () => Date.now() } });

// Janela de 45 dias: cobre um fechamento mensal completo mais margem.
// Com 14 dias, todo fluxo mensal aparece como morto e a limpeza gera
// um incidente 30 dias depois do merge.
const candidatas = client.removalCandidates({ window: 45 * DIA });

const definicoes = candidatas.map(({ key, winner, evaluations }) => {
  const def = store.get(key);

  return {
    key,
    winner,
    evaluations,
    owner: def.owner,
    idadeDias: Math.floor((Date.now() - def.createdAt) / DIA),
    vencida: def.expiresAt != null && Date.now() > def.expiresAt,
  };
});

// Flag orfa: o time dono nao existe mais no diretorio da organizacao.
// Esta e a unica categoria que bloqueia o pipeline, porque nao existe
// destinatario para o alerta semanal.
const orfas = definicoes.filter((flag) => !equipesAtivas.has(flag.owner));

if (orfas.length > 0) {
  console.error(`Flags sem time dono ativo: ${orfas.map((f) => f.key).join(', ')}`);
  process.exitCode = 1;
}

const porTime = definicoes.reduce((acc, flag) => {
  const lista = acc.get(flag.owner) ?? [];
  lista.push(flag);
  return acc.set(flag.owner, lista);
}, new Map());

for (const [owner, flags] of porTime) {
  const vencidas = flags.filter((flag) => flag.vencida);
  if (vencidas.length === 0) continue;

  await abrirIssue({
    time: owner,
    titulo: `${vencidas.length} feature flags prontas para remocao`,
    corpo: vencidas
      .map(
        (flag) =>
          `- ${flag.key}: valor unico "${flag.winner}" em ${flag.evaluations} ` +
          `avaliacoes, ${flag.idadeDias} dias de idade, motivo padrao em 100% das leituras`,
      )
      .join('\n'),
  });
}
  • O relatório é por time dono e nunca uma lista única, porque uma lista de oitenta itens não é acionável e uma de seis é.
  • A janela de análise cobre o processo mais lento do domínio, não um número redondo de dias escolhido por conveniência.
  • Kill switches e permissões são excluídos antes da análise, não filtrados depois por revisão humana.
  • O único bloqueio de pipeline é a flag órfã, porque nesse caso não existe destinatário para o alerta.
  • A data de expiração é tratada como estimativa que gera conversa, não como contrato que gera bloqueio automático.

06

Provar que a remoção não mudou comportamento

A parte que fecha o ciclo é a que garante que apagar o condicional produziu exatamente o mesmo comportamento que a flag ligada produzia. A intuição diz que isso é trivial porque o código do ramo vencedor não mudou, e a intuição erra em dois pontos específicos: o valor de fallback quando a chave some, que raramente é igual ao valor que estava em produção, e os efeitos colaterais que existiam apenas dentro do bloco condicional, como logs, contadores e caches que outros trechos consomem.

O teste que pega os dois casos é uma comparação de saída entre a versão com a flag forçada em verdadeiro e a versão já limpa, rodando sobre o mesmo conjunto de entradas. Ele é barato porque não precisa de provedor de flags nem de rede, e é a única evidência objetiva de que o pull request de remoção é neutro.

// test/remocao-flag-novo-motor-frete.test.js
// Compara a saida do codigo COM a flag forcada em true contra a saida do
// codigo JA LIMPO. Rodar antes do merge do PR de remocao e descartar
// depois: e um teste de transicao, nao de regressao permanente.

import { calcularFreteLegado } from './fixtures/frete-com-flag.js';
import { criarServicoFrete } from '../src/frete/servico.js';
import { pedidosDeAmostra } from './fixtures/pedidos.js';

describe('remocao da flag novo_motor_frete e neutra', () => {
  it('produz a mesma cotacao para toda a amostra de pedidos', () => {
    const servicoLimpo = criarServicoFrete({ motorFrete: 'novo' });

    for (const pedido of pedidosDeAmostra) {
      const antes = calcularFreteLegado(pedido, { flagLigada: true });
      const depois = servicoLimpo.cotar(pedido);

      expect(depois).toEqual(antes);
    }
  });

  it('mantem os efeitos colaterais que estavam dentro do condicional', () => {
    // O ramo da flag incrementava um contador que o painel de operacao
    // consome. Apagar o if apaga o contador junto, e o grafico vira uma
    // linha reta em zero que ninguem associa a limpeza de flag.
    const metricas = [];
    const servico = criarServicoFrete(
      { motorFrete: 'novo' },
      { metrics: { increment: (nome) => metricas.push(nome) } },
    );

    servico.cotar(pedidosDeAmostra[0]);

    expect(metricas).toContain('frete.cotacao');
  });

  it('nao consulta mais o provedor de flags', () => {
    // Se qualquer caminho do servico limpo ainda ler a flag, este stub
    // estoura. E a prova de que a leitura saiu do caminho de execucao e
    // nao apenas de que ela retorna sempre o mesmo valor.
    const provedorProibido = {
      enabled: () => {
        throw new Error('o servico limpo nao pode consultar flags');
      },
    };

    const servico = criarServicoFrete({ motorFrete: 'novo' }, { flags: provedorProibido });

    expect(() => servico.cotar(pedidosDeAmostra[0])).not.toThrow();
  });
});

A segunda asserção é a que mais paga por si mesma e a que quase nunca é escrita. Métricas emitidas de dentro do bloco condicional desaparecem junto com o bloco, e o efeito visível não é um erro e sim um gráfico que vira uma linha reta em zero. Ninguém associa isso à limpeza de flag três semanas depois, e o painel de operação perde um sinal permanentemente porque o alerta configurado em cima dele nunca mais dispara.

A terceira asserção protege contra a limpeza pela metade, que é o resultado mais comum quando o pull request é grande. Um caminho que continua consultando o provedor é indistinguível de um caminho limpo em qualquer teste de saída, porque o provedor devolve o valor certo. Ele só se revela no dia em que a chave for arquivada, e nesse dia o comportamento muda sem nenhum deploy associado, que é a pior forma possível de descobrir um problema.

FAQ

Perguntas frequentes

Qual o número saudável de flags ativas por serviço?

A pergunta pelo número absoluto leva a uma meta arbitrária que o time contorna renomeando categorias, e o que importa é a composição da lista e não o tamanho dela. Um serviço com quarenta flags onde trinta e cinco são kill switches documentados e cinco são rollouts com menos de trinta dias está mais saudável que um serviço com doze flags onde nove são rollouts de mais de um ano. A métrica que funciona é a idade mediana das flags de rollout, porque ela captura exatamente o comportamento que se quer mudar: se a mediana está em nove dias, o processo de remoção está funcionando; se está em oito meses, existe uma dívida crescendo independente do total ser doze ou quarenta. A segunda métrica útil é a proporção de flags sem time dono ativo, que deveria ser zero e que na prática revela o quanto o cadastro virou formalidade. Vale acompanhar também quantas flags foram criadas contra quantas foram removidas no trimestre, porque um saldo positivo persistente diz que o processo não se sustenta, mesmo que o total absoluto ainda pareça confortável.

Como remover uma flag quando o ramo antigo ainda tem clientes específicos?

Esse é o caso em que a resposta correta não é remover, e reconhecer isso cedo economiza semanas. Uma flag que serve uma lista nominal de clientes deixou de ser flag de rollout e virou regra de negócio, e o problema não é a limpeza e sim que a regra está no lugar errado. A movimentação correta é migrar a condição para onde ela pertence, que geralmente é o modelo de autorização ou o cadastro de plano do cliente, deixando o código de domínio consultar uma capacidade nomeada em vez de uma chave de flag. Isso não reduz o número de caminhos no código, mas muda a natureza da dívida: uma capacidade de plano é uma decisão de produto documentada, com dono claro e ciclo de vida próprio, enquanto uma flag esquecida com trinta e sete clientes na lista é uma regra de negócio que só existe na configuração de uma ferramenta de deploy. Depois da migração a chave de flag é removida pelo procedimento normal, porque nesse ponto ela realmente não tem mais leitor. Se a lista tiver poucos clientes e o ramo antigo for caro de manter, existe ainda a opção de negociar a migração desses contratos, mas essa é uma conversa comercial que precisa acontecer antes e não durante o pull request de limpeza.

Vale a pena manter o código do ramo antigo em algum lugar depois da remoção?

Não, e a intuição contrária vem de confundir preservar informação com preservar código executável. O histórico do Git já mantém o ramo antigo integralmente, com contexto de quando foi escrito, por quem e junto de qual mudança, e recuperá-lo é uma operação de segundos. Manter o código no repositório em um arquivo de legado ou atrás de uma flag permanentemente desligada tem custo contínuo e valor nulo: ele aparece em toda busca, é lido por quem tenta entender o fluxo, precisa continuar compilando quando as dependências mudam de assinatura e é atualizado por refatorações automáticas que ninguém revisa com atenção porque o código está morto. Pior, se ele fica atrás de uma flag desligada, ele apodrece sem que ninguém perceba, e no dia em que alguém ligar a flag para tentar um rollback de emergência o código que roda não é mais aquele que funcionava, é aquele que sobreviveu a dezoito meses de mudanças não testadas. A prática que resolve a ansiedade legítima por trás da pergunta é registrar no commit de remoção o hash do commit anterior e um resumo do que o ramo antigo fazia, o que dá um ponto de partida imediato para quem precisar consultar sem manter nada vivo no repositório.

A flag só termina quando o código dela sai do repositório

A ferramenta que existe para reduzir risco de deploy vira, em dezoito meses, o maior obstáculo para entender o que está em produção. A saída não é um mutirão de limpeza, é tratar a remoção como parte do rollout e não como um trabalho separado que nunca é priorizado. Posso instrumentar a avaliação das flags do seu sistema com motivo e sujeito, montar o relatório que separa candidatas reais de kill switches, desenhar a sequência de remoção em três passos que não quebra requisição em voo, refatorar as flags espalhadas para um ponto único de decisão e deixar o alerta semanal por time dono funcionando sem virar ruído.