Feature flags são uma das ferramentas mais úteis para reduzir risco em entrega de software. Elas permitem separar deploy de release, liberar funcionalidades de forma gradual, testar hipóteses com grupos específicos, desligar rapidamente um comportamento problemático e proteger produção sem precisar reverter todo o deploy. Quando bem usadas, ajudam o time a trabalhar com mudanças menores, observar produção com mais controle e diminuir o medo de colocar código novo no ar.
O problema começa quando feature flag é tratada apenas como um interruptor. Essa imagem parece simples: liga, desliga, controla e segue o jogo. Mas uma flag em produção não é só um botão. Ela cria um novo estado possível do sistema, adiciona um caminho de execução, aumenta a matriz de testes, muda a forma como o time lê o código, afeta observabilidade e precisa de alguém responsável por decidir quando ela deixa de existir. Se a equipe não trata esse ciclo de vida com disciplina, a flag deixa de ser ferramenta de controle e passa a ser uma fonte silenciosa de complexidade.
É por isso que eu gosto de pensar em feature flag como uma dívida operacional com prazo de validade. A dívida pode ser boa quando assumida conscientemente: o time aceita um pouco mais de complexidade temporária para reduzir risco, controlar rollout ou validar uma hipótese. O problema é quando essa dívida não tem dono, não tem data de revisão e não tem plano de pagamento. Nesse caso, a flag deixa de ser temporária e vira parte permanente de uma arquitetura que ninguém desenhou de verdade.
O valor real está em separar deploy de release
O valor de uma feature flag não está no if. Está na possibilidade de colocar código em produção sem necessariamente expor aquele comportamento para todos os usuários. Essa separação entre deploy e release é uma mudança importante na forma de entregar software. O deploy passa a ser o ato técnico de colocar uma versão no ambiente. O release passa a ser a decisão de produto ou operação sobre quem deve ser exposto àquele comportamento, em qual momento e sob quais critérios.
Essa diferença parece conceitual, mas muda bastante o fluxo do time. Uma funcionalidade grande pode ser integrada aos poucos no branch principal, evitando uma branch longa que só será mergeada semanas depois. Uma mudança sensível pode ser liberada primeiro para usuários internos, depois para 1% da base, depois para 10% e assim por diante. Uma integração nova pode ser ativada somente para uma região ou para um grupo de clientes. Se algo sair errado, o time pode desligar a mudança sem necessariamente reverter tudo que foi junto no deploy.
Essa é a parte boa. Feature flags permitem reduzir o tamanho do risco. Mas, como toda técnica poderosa, elas trazem custo. Cada flag adiciona uma pergunta permanente ao sistema: qual comportamento está ativo para este usuário, neste ambiente, neste momento? Para algumas flags, essa pergunta é necessária e faz parte da operação. Para outras, depois que o rollout termina, ela vira apenas ruído. A maturidade está em saber diferenciar uma coisa da outra.
Nem toda flag tem o mesmo ciclo de vida
Um erro comum é tratar todas as flags do mesmo jeito. Uma flag usada para liberar uma feature em rollout não deveria ter a mesma governança de uma flag operacional usada como kill switch. Um experimento A/B não deveria virar uma regra permanente de produto só porque ninguém removeu o código antigo. Uma permissão de acesso por plano ou perfil talvez nem devesse ser implementada como feature flag temporária, mas como parte explícita do modelo de autorização ou entitlement.
Uma classificação simples ajuda muito. Release flags existem para controlar a exposição de uma funcionalidade durante construção ou rollout e deveriam ter vida curta. Experiment flags existem para testar uma hipótese e precisam terminar com uma decisão: manter uma variante, remover a mudança ou testar outra hipótese. Ops flags existem para controle operacional, como desligar uma integração, reduzir carga ou ativar um modo degradado; podem durar mais, mas precisam de runbook, dono e observabilidade. Permission flags controlam acesso por plano, contrato, perfil ou grupo; muitas vezes representam uma regra permanente de produto, e não uma dívida temporária.
Essa classificação não é burocracia. Ela define como a flag deve ser implementada, testada, operada e removida. Uma release flag sem prazo é um risco. Uma ops flag sem runbook é um botão misterioso. Uma experiment flag sem métrica de sucesso é apenas variação sem aprendizado. Uma permission flag tratada como improviso pode virar uma regra de produto escondida em configuração.
Onde a dívida nasce
A dívida de feature flag raramente nasce de uma decisão claramente errada. Ela costuma nascer de uma decisão correta que não recebeu encerramento. O time precisava reduzir risco em uma entrega crítica, criou uma flag, fez o rollout, estabilizou a feature e seguiu para a próxima demanda. A flag ficou no código porque remover não parecia urgente. Depois outra flag foi criada. Depois outra. Alguns meses depois, aquele módulo passou a ter caminhos antigos, caminhos novos e combinações que ninguém testa por completo.
Esse acúmulo é perigoso porque flags antigas raramente gritam. Elas não aparecem como incidentes imediatamente. Elas aparecem como leitura mais difícil, onboarding mais lento, testes mais frágeis, bugs difíceis de reproduzir e medo de mexer em partes do sistema. Uma flag esquecida não ocupa apenas uma linha de código. Ela ocupa espaço na cabeça de quem precisa entender o comportamento real do sistema.
Imagine um serviço de checkout com uma flag para novo cálculo de frete, outra para novo provider de pagamento, outra para nova validação antifraude, outra para uma campanha promocional e outra para um experimento de parcelamento. Cada uma isoladamente parece razoável. Juntas, elas criam um conjunto de combinações que o time dificilmente valida por completo. Quando um erro acontece apenas para clientes de uma região, com um método de pagamento específico e uma variante ativa, o problema deixa de ser simples de diagnosticar.
O anti-pattern: espalhar condicionais pelo sistema
O pior uso de feature flag acontece quando a flag invade o fluxo principal da aplicação e se espalha por vários pontos do código. Isso geralmente começa de forma inocente: o time precisa mudar uma regra, adiciona um if, chama o serviço de feature flag e segue. O problema aparece quando esse padrão se repete em controllers, services, consumers, jobs e regras de domínio. A flag deixa de ser uma decisão de rollout e passa a contaminar o desenho do sistema.
Um exemplo simplificado em C# seria algo assim:
public async Task<CheckoutResult> CheckoutAsync(Cart cart, Customer customer)
{
var total = await _pricingService.CalculateAsync(cart);
if (await _featureManager.IsEnabledAsync("new-free-shipping-rule"))
{
if (customer.Region == "SP" && cart.Total >= 199)
{
total = total.WithFreeShipping();
}
}
else
{
if (cart.Total >= 299)
{
total = total.WithFreeShipping();
}
}
return await ConfirmAsync(cart, total);
}
Esse código não é ruim simplesmente porque usa feature flag. Ele é ruim porque mistura decisão de rollout com regra de negócio dentro do caso de uso principal. A regra antiga e a nova ficam acopladas ao fluxo de checkout, a string da flag aparece diretamente no código e a remoção futura exigirá que alguém entenda os dois caminhos antes de limpar. Em um exemplo pequeno, isso parece administrável. Em um sistema real, com múltiplos serviços e várias flags interagindo, esse padrão vira uma fonte relevante de dívida.
O cheiro ruim é a flag ter virado parte do domínio. O checkout não deveria precisar saber que existe uma flag chamada new-free-shipping-rule. Ele deveria saber que existe uma política de frete aplicável. A decisão sobre qual política usar pode estar em uma camada de seleção, em uma factory, em um provider ou em uma strategy. Isso não elimina a dívida, mas reduz o lugar onde ela vive.
Um desenho melhor: a flag escolhe uma política
Uma alternativa mais saudável é usar a flag para escolher uma implementação, mantendo o fluxo principal mais limpo. Em vez de espalhar condicionais, o sistema concentra a decisão em um ponto claro. O checkout continua pedindo uma política de frete, e a flag decide se a política antiga ou a nova será usada durante o período de rollout.
public interface IFreeShippingPolicy
{
bool IsEligible(Cart cart, Customer customer);
}
public class LegacyFreeShippingPolicy : IFreeShippingPolicy
{
public bool IsEligible(Cart cart, Customer customer)
=> cart.Total >= 299;
}
public class NewFreeShippingPolicy : IFreeShippingPolicy
{
public bool IsEligible(Cart cart, Customer customer)
=> customer.Region == "SP" && cart.Total >= 199;
}
public class FreeShippingPolicyProvider
{
private readonly IFeatureManager _featureManager;
private readonly LegacyFreeShippingPolicy _legacy;
private readonly NewFreeShippingPolicy _current;
public async Task<IFreeShippingPolicy> GetAsync()
{
var enabled = await _featureManager
.IsEnabledAsync("checkout.free-shipping-v2.release");
return enabled ? _current : _legacy;
}
}
O código continua simples, mas a diferença arquitetural é importante. A flag ficou concentrada em um provider. A regra antiga e a nova têm nomes e podem ter testes próprios. O caso de uso principal não precisa conhecer o mecanismo de rollout. Quando a nova regra virar padrão, a limpeza é óbvia: remover a política antiga, simplificar o provider e apagar a flag. O sistema foi desenhado para que a dívida tivesse um lugar conhecido para ser paga.
Esse é o ponto central: feature flag boa não é aquela que desaparece magicamente. É aquela que foi implementada de um jeito que torna sua remoção barata. Se remover a flag exige mexer em dez arquivos, conversar com três times e entender combinações invisíveis, a dívida já saiu cara demais.
A flag deve ficar na borda da decisão, não no coração do domínio
Uma regra prática útil é evitar que o core do domínio consulte feature flags diretamente. Em uma arquitetura em camadas, modular ou hexagonal, a flag deveria aparecer nas bordas de decisão: providers, factories, adapters, policies, strategies, middlewares ou serviços de aplicação. O domínio pode ter duas políticas de cálculo, duas estratégias de validação ou dois adapters para uma integração externa, mas não deveria depender diretamente do mecanismo de feature flag.
Isso é importante porque rollout e regra de negócio são preocupações diferentes. A regra de negócio define como o sistema deve se comportar. A flag define quando e para quem determinado comportamento será exposto. Quando essas duas coisas ficam misturadas, o código passa a carregar histórico de rollout como se fosse parte permanente do domínio. Esse é um dos motivos pelos quais flags antigas tornam sistemas difíceis de entender: elas fazem o leitor reconstruir não apenas a regra atual, mas também a história das versões anteriores.
Em alguns casos, a flag pode estar em uma camada de aplicação, escolhendo entre duas estratégias. Em outros, pode estar no adapter, escolhendo entre um provider externo antigo e um novo. Em uma API, pode estar em um middleware que decide se uma rota nova está disponível para determinado grupo. O importante é que a decisão seja concentrada e nomeada. Quanto mais espalhada a avaliação da flag, maior será o custo de remoção e maior a chance de comportamentos inconsistentes.
Crie uma camada interna para avaliar flags
Outro cuidado importante é evitar que cada parte do sistema avalie flags diretamente e decida sozinha o que fazer em caso de erro, timeout ou configuração ausente. Em produção, a avaliação de uma flag também pode falhar. O serviço de configuração pode estar indisponível, a rede pode oscilar ou uma chave pode estar mal configurada. Se cada serviço tratar isso de um jeito, o comportamento do sistema fica inconsistente justamente em um ponto que deveria aumentar controle.
Uma alternativa simples é criar uma camada interna, como IFeatureGate, para encapsular avaliação, valor padrão, logging e, se necessário, métricas. Isso não precisa ser sofisticado no começo. O objetivo é padronizar a semântica da decisão.
public interface IFeatureGate
{
Task<bool> IsEnabledAsync(string flagKey, bool defaultValue = false);
}
public class FeatureGate : IFeatureGate
{
private readonly IFeatureManager _featureManager;
private readonly ILogger<FeatureGate> _logger;
public async Task<bool> IsEnabledAsync(
string flagKey,
bool defaultValue = false)
{
try
{
return await _featureManager.IsEnabledAsync(flagKey);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to evaluate feature flag {FlagKey}. Using default {DefaultValue}.",
flagKey,
defaultValue);
return defaultValue;
}
}
}
Essa camada evita que o domínio dependa diretamente do mecanismo de feature flag e cria um ponto único para evoluir a operação. Se amanhã o time quiser adicionar métricas, tracing, auditoria, fallback por ambiente ou cache local, existe um lugar claro para fazer isso. Mais importante: o comportamento padrão deixa de ser acidental. Para uma release flag, talvez o default seguro seja false, preservando o comportamento antigo. Para um kill switch, o default pode ser mais conservador e depender do risco operacional. Essa decisão precisa ser explícita, não consequência de como cada pessoa escreveu um if.
Um exemplo teórico: nova integração de pagamento
Imagine que um time precisa migrar de um provider de pagamento antigo para um novo. Uma feature flag parece a escolha natural para fazer rollout progressivo. O uso ruim seria espalhar condicionais pelo fluxo: no checkout, na captura, no estorno, na conciliação, no webhook e nos relatórios. Cada parte verifica se a flag está ligada e decide qual provider usar. No começo funciona, mas a migração vira um emaranhado de decisões duplicadas.
Um desenho melhor é tratar o provider de pagamento como adapter. O domínio pede uma capacidade: autorizar pagamento, capturar, estornar, consultar status. Durante a migração, uma camada de roteamento decide se aquela transação usa o adapter antigo ou o novo. Essa decisão pode considerar a flag, o país, o método de pagamento, o risco da transação ou o grupo de rollout. O domínio não precisa conhecer todos esses detalhes. Ele continua falando com uma porta estável.
Esse exemplo mostra uma diferença importante entre “usar flag” e “desenhar rollout”. A flag é apenas o controle. A arquitetura precisa dizer onde a decisão mora, como os efeitos serão observados, o que acontece em caso de falha e como o caminho antigo será removido quando a migração terminar. Sem isso, a flag não reduz risco; ela apenas espalha a migração pelo código.
Um exemplo teórico: experimento de produto
Agora imagine um experimento de produto em uma página de checkout. O time quer testar uma nova mensagem de urgência para aumentar conversão. A flag libera a variante B para uma parte dos usuários e mede conversão, abandono e reclamações no suporte. Nesse caso, a flag não deveria virar uma bifurcação permanente do frontend ou do backend. Ela existe para responder uma hipótese.
Quando o experimento acaba, o time precisa tomar uma decisão. Se a variante B venceu, ela vira comportamento padrão e a variante A deve ser removida. Se perdeu, a variante B deve ser removida. Se o resultado foi inconclusivo, o experimento precisa ser encerrado ou redesenhado. O que não deveria acontecer é a flag continuar indefinidamente porque “talvez a gente use depois”. Esse talvez é uma das formas mais comuns de dívida operacional.
Experimentos também mostram que feature flags não são apenas assunto de engenharia. Produto precisa participar do ciclo de vida. Se ninguém decide que o experimento acabou, a engenharia não remove o código. Se ninguém sabe qual métrica validava a hipótese, a flag perde propósito. A técnica só funciona bem quando há clareza sobre a pergunta que ela está tentando responder.
Um exemplo teórico: kill switch operacional
Um kill switch é outro tipo de flag e precisa ser tratado de forma diferente. Imagine uma integração com um serviço externo de cálculo de risco. Se esse serviço começar a falhar ou responder lentamente, o sistema pode precisar desligar temporariamente essa integração e cair para um modo degradado. Aqui, a flag não é uma dívida temporária de feature; ela é um controle operacional que pode existir por bastante tempo.
Mas justamente por isso ela exige governança diferente. Um kill switch precisa de runbook. O time deve saber quando acioná-lo, quem pode acioná-lo, qual métrica justifica a decisão, qual comportamento degradado será entregue ao usuário e como voltar ao modo normal. Também precisa ser testado periodicamente. Um kill switch que ninguém testa pode falhar exatamente quando for necessário.
Esse exemplo ajuda a separar flags temporárias de controles permanentes. Uma release flag esquecida é dívida. Um kill switch bem documentado pode ser parte saudável da operação. A diferença está no contrato: dono, observabilidade, permissão, efeito esperado e rotina de validação.
Feature flag não substitui rollback
Feature flags ajudam muito em rollout e rollback lógico, mas não substituem uma arquitetura reversível. Se uma mudança envolve schema de banco, eventos publicados, cache, clientes móveis ou integrações externas, desligar a flag pode não ser suficiente para voltar ao estado anterior. O código novo pode parar de executar, mas os dados gerados e os efeitos colaterais já produzidos continuam existindo.
Imagine uma flag que controla uma nova regra de cálculo de imposto. Durante o rollout, o sistema começa a persistir campos novos, publicar eventos com uma interpretação diferente e alimentar relatórios downstream. Depois de uma hora, uma métrica degrada e o time desliga a flag. O comportamento novo para, mas os eventos já publicados continuam circulando, e os relatórios talvez já tenham consumido os dados. Nesse caso, a flag limitou a exposição, mas não apagou o efeito produzido.
Por isso, uma boa pergunta antes de criar uma flag é: se eu desligar isso depois de uma hora em produção, o sistema volta mesmo para um estado seguro? Se a resposta for incerta, a flag precisa ser acompanhada de outras práticas, como compatibilidade de schema, leitura tolerante, dual write, backfill, idempotência, versionamento de eventos e plano de reconciliação. Feature flag é uma peça da estratégia de rollout, não a estratégia inteira.
Observabilidade: uma flag precisa deixar rastro
Uma flag que altera comportamento em produção precisa aparecer na observabilidade. Não basta saber que a aplicação respondeu com sucesso. Em um incidente, o time precisa saber se determinada flag estava ligada, para quem estava ligada, quando mudou, quem mudou, qual variante foi usada e se a mudança teve correlação com erro, latência ou métrica de negócio.
Isso é especialmente importante em rollout progressivo. Se o time libera uma funcionalidade para uma fatia da base, precisa comparar comportamento entre usuários expostos e não expostos. Taxa de erro, latência, conversão, cancelamento, uso da funcionalidade e volume de suporte podem ser relevantes dependendo do caso. Sem essa visibilidade, rollout gradual vira apenas uma sensação de segurança. A equipe acha que reduziu risco, mas não tem instrumentos para perceber degradações pequenas antes que elas virem problemas maiores.
Em termos técnicos, isso pode significar enriquecer logs, métricas e traces com o estado da flag ou com a variante exposta. Não é necessário logar tudo de forma excessiva, mas decisões importantes de flag precisam ser rastreáveis. Se uma flag muda comportamento de checkout, pagamento, autorização, pricing ou comunicação com cliente, ela precisa aparecer nas ferramentas que o time usa para entender produção.
Testes: cubra comportamentos, não combinações infinitas
Feature flags aumentam a matriz de testes porque criam variações de comportamento. Isso não significa que o time precisa testar todas as combinações possíveis de todas as flags, o que seria inviável em sistemas grandes. Mas significa que flags críticas precisam ter testes explícitos para os comportamentos que importam. O caminho antigo e o novo devem ser cobertos enquanto ambos puderem existir em produção.
A melhor estratégia costuma ser testar em camadas. A política antiga e a nova devem ter testes próprios. A camada que seleciona a política deve ter testes simples para flag ligada e desligada. O fluxo principal deve ter testes de integração para os cenários mais críticos. Se várias flags interagem no mesmo fluxo, o time precisa definir quais combinações são oficialmente suportadas, em vez de permitir qualquer composição acidental.
Esse ponto é importante porque uma das piores dívidas de flags é a explosão combinatória invisível. O sistema aceita combinações que ninguém desenhou, ninguém testou e ninguém observou. Quando um bug aparece, a investigação vira arqueologia de estados. A solução não é tentar testar o universo inteiro, mas reduzir combinações desnecessárias, dar nome aos modos de operação e remover flags temporárias rapidamente.
Como criar uma flag sem criar uma armadilha
Uma forma simples de melhorar o uso de feature flags é mudar a pergunta de criação. Em vez de perguntar apenas “precisamos de uma flag?”, o time deveria perguntar “que tipo de dívida estamos assumindo e quando ela será paga?”. Essa pergunta força uma conversa mais completa sobre tipo, dono, comportamento, observabilidade e remoção.
Na prática, toda flag relevante deveria nascer com um contrato mínimo: nome claro, tipo, dono, motivo, comportamento quando desligada, comportamento quando ligada, ambientes afetados, métrica de sucesso, plano de rollback, data de revisão e critério de remoção. Para flags operacionais, o contrato também precisa incluir runbook, permissão de alteração, efeito esperado e rotina de teste. Para experimentos, precisa incluir hipótese, público, métrica primária, data de encerramento e decisão esperada.
Esse contrato pode viver no template da issue, no pull request ou na ferramenta de gestão de flags. O formato importa menos do que a disciplina. Se a criação da flag não gera nenhum lembrete de remoção, a limpeza dependerá de memória humana. E memória humana é uma péssima estratégia para pagar dívida técnica.
Como pagar a dívida
Pagar dívida de feature flag não deveria depender de uma grande faxina anual. O melhor caminho é transformar remoção em parte normal do fluxo de entrega. Se uma release flag foi criada para liberar uma funcionalidade, a entrega só deveria ser considerada completa quando o caminho antigo for removido depois do rollout. Se um experimento terminou, a flag precisa consolidar a variante vencedora ou remover a hipótese. Se uma ops flag continua necessária, ela precisa ser revisada periodicamente e testada como parte da operação.
Uma prática simples é criar a tarefa de remoção no mesmo momento em que a flag é criada. A issue que introduz a flag pode já gerar uma issue irmã para removê-la, com critério objetivo: rollout em 100% por determinado período sem regressão, experimento concluído, métrica validada, comportamento antigo sem uso ou data de revisão atingida. Ferramentas de mercado também ajudam marcando flags obsoletas, potencialmente stale ou candidatas a arquivamento, mas a decisão final continua sendo da equipe.
A arquitetura também pode facilitar ou dificultar esse pagamento. Se a flag está concentrada em um provider, factory, adapter ou policy, a limpeza é pequena. Se está espalhada por controllers, services, jobs, consumers e entidades, a remoção vira refactor arriscado. Por isso, a melhor hora para pensar na remoção é antes de escrever a flag.
Um teste simples para o seu time
Um time provavelmente está usando flags de forma saudável quando consegue olhar para o inventário e entender rapidamente por que cada flag existe, quem é o dono, que tipo de flag é, qual comportamento ela controla, que métrica valida seu uso e quando será removida ou revisada. Se a maioria dessas respostas depende de perguntar para uma pessoa específica, a flag já está criando memória tribal.
Por outro lado, o time provavelmente está acumulando dívida quando existem flags sem dono, nomes ambíguos, condicionais espalhados, flags aninhadas, experimentos sem conclusão, release flags antigas, kill switches nunca testados e permissões de produto implementadas como toggles improvisados. Nenhum desses sinais, isoladamente, significa desastre. Mas juntos eles indicam que a organização está usando flags para ganhar velocidade sem pagar o custo operacional correspondente.
A pergunta final é objetiva: se amanhã você precisasse remover 30% das flags do sistema, saberia quais remover primeiro e com qual risco? Se a resposta for não, o problema não está apenas no código. Está na ausência de ciclo de vida para comportamento em produção.
Fechando a ideia
Feature flag é uma ferramenta poderosa porque permite entregar software com menos risco e mais controle. Ela separa deploy de release, habilita rollout progressivo, protege produção e abre espaço para experimentação. Mas exatamente por ser poderosa, ela não deveria ser tratada como um simples interruptor. Uma flag em produção cria um novo estado possível do sistema, e esse estado precisa de contrato, teste, observabilidade, dono e prazo.
Do ponto de vista técnico, a boa prática não é espalhar condicionais pelo código e torcer para lembrar de removê-las depois. A boa prática é concentrar a decisão, proteger o domínio, testar os caminhos relevantes, observar o comportamento exposto e planejar a remoção desde o início. Feature flag bem usada reduz risco. Feature flag esquecida aumenta complexidade, piora onboarding, infla matriz de testes e transforma comportamento antigo em armadilha operacional.
A maturidade não está em usar muitas flags. Está em saber quais flags merecem existir, por quanto tempo, sob qual governança e com qual plano de saída. Em sistemas bem operados, feature flag não é apenas uma forma de ligar ou desligar comportamento. É uma decisão arquitetural temporária que precisa vencer, virar produto ou desaparecer.
Se ela não tem prazo, ela não é controle. É dívida.
