Feature flags (ou feature toggles) permitem que você faça deploy de código sem ativar funcionalidades, controle quem vê o quê e faça rollback instantâneo de uma feature sem novo deploy. São a base para trunk-based development, canary releases e A/B testing em ambientes .NET de produção.
Por que feature flags importam em produção
Sem feature flags, o fluxo comum é: branch de feature → PR longo → merge → deploy → torcer para não quebrar. Com feature flags:
- Desenvolvedores fazem merge diariamente para main (trunk-based development)
- Código novo está em produção, mas invisível para usuários
- Ativação gradual: 1% → 10% → 50% → 100% dos usuários
- Rollback em segundos — mude uma configuração, não faça redeploy
- A/B testing com grupos específicos de usuários
Setup inicial: Microsoft.FeatureManagement
dotnet add package Microsoft.FeatureManagement.AspNetCore
// Program.cs
builder.Services.AddFeatureManagement();
// Com filtros customizados
builder.Services.AddFeatureManagement()
.AddFeatureFilter<PercentageFilter>() // % de usuários
.AddFeatureFilter<TimeWindowFilter>() // Janela de tempo
.AddFeatureFilter<TargetingFilter>() // Usuários/grupos específicos
.AddFeatureFilter<TenantFeatureFilter>(); // Filtro customizado por tenant
// appsettings.json
{
"FeatureManagement": {
"NewDashboard": true,
"ExperimentalSearch": false,
"BetaCheckout": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": {
"Value": 10
}
}
]
},
"MaintenanceMode": {
"EnabledFor": [
{
"Name": "TimeWindow",
"Parameters": {
"Start": "2026-05-01T02:00:00+00:00",
"End": "2026-05-01T04:00:00+00:00"
}
}
]
}
}
}
Constantes de flags: evite strings mágicas
// src/Application/Features/FeatureFlags.cs
public static class FeatureFlags
{
public const string NewDashboard = "NewDashboard";
public const string ExperimentalSearch = "ExperimentalSearch";
public const string BetaCheckout = "BetaCheckout";
public const string MaintenanceMode = "MaintenanceMode";
public const string NewPricingEngine = "NewPricingEngine";
public const string AIRecommendations = "AIRecommendations";
}
Usando feature flags no código
Em controllers e Minimal APIs
// Controller
[ApiController]
[Route("api/[controller]")]
public class SearchController : ControllerBase
{
private readonly IFeatureManager _featureManager;
private readonly ISearchService _legacySearch;
private readonly IExperimentalSearchService _experimentalSearch;
public SearchController(
IFeatureManager featureManager,
ISearchService legacySearch,
IExperimentalSearchService experimentalSearch)
{
_featureManager = featureManager;
_legacySearch = legacySearch;
_experimentalSearch = experimentalSearch;
}
[HttpGet]
public async Task<IActionResult> Search([FromQuery] string query)
{
if (await _featureManager.IsEnabledAsync(FeatureFlags.ExperimentalSearch))
{
var result = await _experimentalSearch.SearchAsync(query);
return Ok(result);
}
var legacyResult = await _legacySearch.SearchAsync(query);
return Ok(legacyResult);
}
}
// Minimal API
app.MapGet("/api/checkout", async (IFeatureManager features, ICheckoutService checkout) =>
{
if (await features.IsEnabledAsync(FeatureFlags.BetaCheckout))
return Results.Ok(await checkout.ProcessBetaAsync());
return Results.Ok(await checkout.ProcessAsync());
});
Atributo de controller inteiro
// Bloqueia acesso ao controller inteiro se a flag estiver desabilitada
// Retorna 404 por padrão (configurável)
[FeatureGate(FeatureFlags.NewDashboard)]
[ApiController]
[Route("api/dashboard/v2")]
public class NewDashboardController : ControllerBase
{
// Este controller só é acessível se NewDashboard == true
}
Em serviços de domínio
public class PricingService : IPricingService
{
private readonly IFeatureManager _featureManager;
private readonly ILegacyPricingEngine _legacyEngine;
private readonly INewPricingEngine _newEngine;
public PricingService(
IFeatureManager featureManager,
ILegacyPricingEngine legacyEngine,
INewPricingEngine newEngine)
{
_featureManager = featureManager;
_legacyEngine = legacyEngine;
_newEngine = newEngine;
}
public async Task<decimal> CalculatePriceAsync(Product product, Customer customer)
{
if (await _featureManager.IsEnabledAsync(FeatureFlags.NewPricingEngine))
{
// Novo motor de preço (em rollout gradual)
return await _newEngine.CalculateAsync(product, customer);
}
// Motor legado (comportamento padrão)
return await _legacyEngine.CalculateAsync(product, customer);
}
}
Em Razor Pages e Blazor
@inject IFeatureManager FeatureManager
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.NewDashboard))
{
<NewDashboardComponent />
}
else
{
<LegacyDashboardComponent />
}
<!-- Tag helper (abordagem alternativa mais limpa) -->
<feature name="@FeatureFlags.AIRecommendations">
<AIRecommendationsSection />
</feature>
Targeting: rollout para usuários e grupos específicos
// appsettings.json
{
"FeatureManagement": {
"BetaCheckout": {
"EnabledFor": [
{
"Name": "Targeting",
"Parameters": {
"Audience": {
"Users": [
"danilo@neryx.com.br",
"admin@empresa.com"
],
"Groups": [
{
"Name": "beta-testers",
"RolloutPercentage": 100
},
{
"Name": "All",
"RolloutPercentage": 5
}
],
"DefaultRolloutPercentage": 5
}
}
}
]
}
}
}
// Registrar o contexto de targeting com o usuário atual
public class HttpContextTargetingContextAccessor : ITargetingContextAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
public ValueTask<TargetingContext> GetContextAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
var userId = user?.FindFirst(ClaimTypes.Email)?.Value
?? user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? "anonymous";
// Grupos: roles do usuário + grupos customizados
var groups = new List<string>();
if (user?.IsInRole("BetaTester") == true)
groups.Add("beta-testers");
if (user?.IsInRole("Premium") == true)
groups.Add("premium-users");
return new ValueTask<TargetingContext>(new TargetingContext
{
UserId = userId,
Groups = groups
});
}
}
// Registrar no DI
builder.Services.AddSingleton<ITargetingContextAccessor, HttpContextTargetingContextAccessor>();
Filtro customizado por tenant (multi-tenancy)
// Filtro que habilita feature para tenants específicos
[FilterAlias("Tenant")]
public class TenantFeatureFilter : IFeatureFilter
{
private readonly ITenantContext _tenantContext;
public TenantFeatureFilter(ITenantContext tenantContext)
=> _tenantContext = tenantContext;
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var settings = context.Parameters.Get<TenantFilterSettings>()
?? new TenantFilterSettings();
var currentTenant = _tenantContext.Current?.Id;
if (currentTenant is null)
return Task.FromResult(false);
// Habilitar para tenants específicos ou plano premium
var isEnabled = settings.TenantIds?.Contains(currentTenant) == true
|| (settings.EnableForPremium && _tenantContext.Current?.Plan == "Premium");
return Task.FromResult(isEnabled);
}
}
public class TenantFilterSettings
{
public string[]? TenantIds { get; set; }
public bool EnableForPremium { get; set; }
}
// Configuração do filtro por tenant
{
"FeatureManagement": {
"AIRecommendations": {
"EnabledFor": [
{
"Name": "Tenant",
"Parameters": {
"EnableForPremium": true,
"TenantIds": ["tenant-abc", "tenant-xyz"]
}
}
]
}
}
}
Feature flags remotas com Azure App Configuration
O ponto crítico de feature flags é poder mudá-las sem redeploy. Para isso, use Azure App Configuration como fonte de verdade:
dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore
dotnet add package Microsoft.FeatureManagement
// Program.cs — integração com Azure App Configuration
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AzureAppConfig:ConnectionString"])
// Recarrega configuração a cada 30 segundos
.ConfigureRefresh(refresh =>
{
refresh.Register("FeatureManagement:Sentinel", refreshAll: true)
.SetCacheExpiration(TimeSpan.FromSeconds(30));
})
// Carrega feature flags do App Configuration
.UseFeatureFlags(featureFlagOptions =>
{
featureFlagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
});
});
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();
// Middleware para refresh automático
app.UseAzureAppConfiguration();
Com isso, você muda uma feature flag no portal do Azure (ou via CLI) e em até 30 segundos todos os pods em produção refletem a mudança — sem redeploy, sem downtime.
# Ativar feature via CLI do Azure
az appconfig feature enable \
--name myapp-config \
--feature BetaCheckout \
--yes
# Rollout para 25% via CLI
az appconfig feature filter add \
--name myapp-config \
--feature BetaCheckout \
--filter-name Percentage \
--filter-parameters "Value=25"
# Rollback instantâneo
az appconfig feature disable \
--name myapp-config \
--feature BetaCheckout
Testando código com feature flags
// Teste com flag habilitada
public class PricingServiceTests
{
[Fact]
public async Task CalculatePrice_WithNewEngine_UsesNewAlgorithm()
{
// Arrange — simular flag habilitada
var featureManager = new Mock<IFeatureManager>();
featureManager
.Setup(f => f.IsEnabledAsync(FeatureFlags.NewPricingEngine))
.ReturnsAsync(true);
var newEngine = new Mock<INewPricingEngine>();
newEngine
.Setup(e => e.CalculateAsync(It.IsAny<Product>(), It.IsAny<Customer>()))
.ReturnsAsync(99.90m);
var service = new PricingService(featureManager.Object, /* legacy mock */, newEngine.Object);
// Act
var price = await service.CalculatePriceAsync(new Product(), new Customer());
// Assert
price.Should().Be(99.90m);
newEngine.Verify(e => e.CalculateAsync(It.IsAny<Product>(), It.IsAny<Customer>()), Times.Once);
}
[Fact]
public async Task CalculatePrice_WithLegacyEngine_UsesLegacyAlgorithm()
{
// Arrange — simular flag desabilitada
var featureManager = new Mock<IFeatureManager>();
featureManager
.Setup(f => f.IsEnabledAsync(FeatureFlags.NewPricingEngine))
.ReturnsAsync(false); // <-- flag off
// ...verificar que legacyEngine foi chamado
}
}
// Teste de integração com flag configurada no WebApplicationFactory
public class CheckoutApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public CheckoutApiTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Fact]
public async Task BetaCheckout_WhenFlagEnabled_UsesNewFlow()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((ctx, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string>
{
["FeatureManagement:BetaCheckout"] = "true"
});
});
}).CreateClient();
var response = await client.PostAsJsonAsync("/api/checkout", new { });
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verificar que resposta é do fluxo beta...
}
}
Limpeza de flags obsoletas
Feature flags têm um ciclo de vida. Flags esquecidas geram dívida técnica. Adote um processo de limpeza:
// Documentar o ciclo de vida da flag em código
/// <summary>
/// Flag de rollout do novo motor de preços.
/// Status: Em rollout (30% dos usuários) — Criada: 2026-04-01
/// Remover após: Rollout completo em 2026-05-01
/// Owner: time-pricing@neryx.com.br
/// </summary>
public const string NewPricingEngine = "NewPricingEngine";
Crie um backlog de limpeza: quando uma flag chegar a 100% ou for descontinuada, abra uma tarefa para remover o código condicional e a própria flag. Flags permanentes são dívida técnica inevitável — minimize-as.
Padrão de decisão
| Cenário | Tipo de flag |
|---|---|
| Nova feature sendo desenvolvida | Release toggle (temporária) |
| Rollout gradual para % de usuários | Percentage + Targeting |
| Funcionalidade paga/premium | Permission toggle (permanente por plano) |
| A/B testing | Experiment toggle (temporária) |
| Manutenção programada | TimeWindow (automática) |
| Kill switch de emergência | Ops toggle (Azure App Config remote) |
Feature flags são infraestrutura de entrega de software — não apenas uma técnica de desenvolvimento. Se você quer implementar trunk-based development e deploys seguros na sua equipe, fale com a Neryx.