Feature Flags in .NET: Safe Deployments and Gradual Rollouts
Implement feature flags in ASP.NET Core. Covers Microsoft.FeatureManagement, Azure App Configuration, percentage rollouts, user targeting, A/B testing, kill switches, and feature flag best practices.
Why Feature Flags?
Feature flags decouple deployment from release. You deploy code with a flag off — it's invisible in production. You turn it on when you're ready, for whoever you choose.
Use cases:
- Kill switch — turn off a broken feature without deploying
- Gradual rollout — roll out to 5% of users, watch metrics, expand
- A/B testing — different users get different experiences
- Beta programs — enable features for specific users or groups
- Dark launch — run new code paths in production without exposing them
Setup (Microsoft.FeatureManagement)
dotnet add package Microsoft.FeatureManagement.AspNetCore
dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore # for Azure App Config// Program.cs
builder.Services.AddFeatureManagement();
// Or with Azure App Configuration (recommended for production)
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AzureAppConfig:ConnectionString"])
.UseFeatureFlags(ff => ff.CacheExpirationInterval = TimeSpan.FromMinutes(5));
});
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();
app.UseAzureAppConfiguration(); // polls for changesDefining Flags
// appsettings.json — local development
{
"FeatureManagement": {
"NewCheckoutFlow": true,
"BetaDashboard": false,
"NewPricingEngine": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": { "Value": 20 }
}
]
}
}
}Using Flags in Code
// Option 1: IFeatureManager
public class CheckoutService
{
private readonly IFeatureManager _features;
public CheckoutService(IFeatureManager features) => _features = features;
public async Task<CheckoutResult> CheckoutAsync(Cart cart, CancellationToken ct)
{
if (await _features.IsEnabledAsync("NewCheckoutFlow"))
return await NewCheckoutAsync(cart, ct);
return await LegacyCheckoutAsync(cart, ct);
}
}
// Option 2: [FeatureGate] on controller actions
[FeatureGate("BetaDashboard")]
[HttpGet("dashboard/beta")]
public async Task<IActionResult> BetaDashboard()
{
return Ok(await _dashboardService.GetBetaDataAsync());
}
// Returns 404 when flag is off (configurable — can redirect instead)
// Option 3: Razor/Blazor tag helper
// <feature name="NewCheckoutFlow">
// <NewCheckout />
// </feature>
// <feature name="NewCheckoutFlow" negate="true">
// <LegacyCheckout />
// </feature>Targeting: Per-User and Per-Group
// Register targeting context (who is the current user?)
builder.Services.AddFeatureManagement()
.WithTargeting<UserTargetingContextAccessor>();
public class UserTargetingContextAccessor : ITargetingContextAccessor
{
private readonly IHttpContextAccessor _http;
public UserTargetingContextAccessor(IHttpContextAccessor http) => _http = http;
public ValueTask<TargetingContext> GetContextAsync()
{
var user = _http.HttpContext?.User;
return ValueTask.FromResult(new TargetingContext
{
UserId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "anonymous",
Groups = user?.FindAll("role").Select(c => c.Value).ToList() ?? new()
});
}
}// Azure App Configuration feature flag with targeting
{
"id": "BetaDashboard",
"enabled": true,
"conditions": {
"client_filters": [
{
"name": "Microsoft.Targeting",
"parameters": {
"Audience": {
"Users": ["alice@example.com", "bob@example.com"],
"Groups": [
{ "Name": "beta-testers", "RolloutPercentage": 100 },
{ "Name": "internal", "RolloutPercentage": 50 }
],
"DefaultRolloutPercentage": 5
}
}
}
]
}
}Time Windows
// Enable only during business hours
{
"id": "LiveSupport",
"conditions": {
"client_filters": [
{
"name": "Microsoft.TimeWindow",
"parameters": {
"Start": "Mon, 09:00:00 +00:00",
"End": "Mon, 18:00:00 +00:00"
}
}
]
}
}Custom Filters
// Feature active only for premium customers
[FilterAlias("PremiumCustomer")]
public class PremiumCustomerFilter : IFeatureFilter
{
private readonly ICurrentUserService _user;
public PremiumCustomerFilter(ICurrentUserService user) => _user = user;
public async Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var currentUser = await _user.GetCurrentUserAsync();
return currentUser?.SubscriptionTier == "Premium";
}
}
// Register
builder.Services.AddFeatureManagement()
.AddFeatureFilter<PremiumCustomerFilter>();{
"id": "AdvancedAnalytics",
"conditions": {
"client_filters": [{ "name": "PremiumCustomer" }]
}
}Feature Flag Lifecycle
Good flags have a lifecycle — they should be cleaned up:
1. Add flag (off by default) → deploy
2. Enable for internal team → test
3. Gradual rollout: 5% → 25% → 50% → 100%
4. Monitor metrics at each stage
5. Kill switch if issues found
6. After stable at 100%: remove flag + dead code pathAnti-patterns:
- Flags that are never cleaned up (technical debt)
- Flags guarding multiple unrelated features
- Flags checked in database queries (performance impact)
- Testing only the "flag on" path
Azure App Configuration Setup
# Create Azure App Configuration store
az appconfig create \
--name orderflow-config \
--resource-group orderflow-rg \
--sku Free
# Add a feature flag
az appconfig feature set \
--name orderflow-config \
--feature NewCheckoutFlow \
--yes
# Enable it
az appconfig feature enable \
--name orderflow-config \
--feature NewCheckoutFlowGrant managed identity access:
az role assignment create \
--role "App Configuration Data Reader" \
--assignee <managed-identity-principal-id> \
--scope /subscriptions/.../providers/Microsoft.AppConfiguration/configurationStores/orderflow-configInterview Questions
Q: What is the difference between a feature flag and a configuration value? A configuration value changes behaviour permanently (connection string, timeout). A feature flag is a boolean gate controlling whether a code path runs — designed to be changed frequently without deployment and eventually removed. Feature flags enable deployment without release.
Q: How would you implement a gradual rollout safely? Use percentage rollout (Microsoft.Targeting or custom filter). Start at 5%, monitor error rate, latency, and business metrics. If stable, increase to 25%, 50%, 100%. Automate the monitoring — if error rate spikes above threshold, the flag is automatically disabled (circuit-breaking the rollout).
Q: What is a kill switch and when would you use it? A feature flag set to "off by default" for a risky new feature. If the feature causes issues in production, you flip the flag off — instant rollback without a deployment. Useful for features that are hard to roll back via deployment (database changes, API contracts).
Q: Why should you clean up feature flags? Dead flags accumulate over time — two code paths to maintain, confusion about which is "real", and test coverage required for both. Flags guarding fully-released features add cognitive overhead and performance overhead if evaluated on hot paths. Set a policy: flags older than 90 days at 100% rollout must be removed.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.