Feature flags in .NET: a practical guide for developers
You've built a new payment processing feature, but you're not confident rolling it out to all customers at once. You want to test it with a subset of users, monitor its behavior, and pull it back if something goes wrong—without redeploying. Feature flags solve this problem by decoupling code deployment from feature release, letting you ship code dark and activate it when you're ready.
In this guide, we'll walk through how to implement feature flags in .NET using Microsoft.FeatureManagement, explore filtering strategies for gradual rollouts, and show you when to clean them up once they're no longer needed.
What are feature flags and why they matter
A feature flag is a configuration value that controls whether a piece of code runs or not. Unlike environment variables that tend to be static, feature flags are dynamic—you can change them at runtime without redeploying your application. In a production environment handling millions of requests, this distinction is critical.
Common scenarios where we reach for feature flags:
- Rolling out a feature to 5% of users, then 25%, then 100%
- A/B testing two implementations to measure impact
- Disabling a feature instantly if it causes issues
- Running experimental features only for internal teams
- Coordinating multi-region deployments across different timezones
The alternative—waiting for 100% of your application to be tested before production release—delays feedback and leaves you with no graceful exit if problems emerge.
Microsoft.FeatureManagement: the foundation
The .NET ecosystem provides Microsoft.FeatureManagement, a lightweight, extensible library for exactly this use case. It reads feature flags from configuration and evaluates them based on context.
Start by installing the NuGet package:
dotnet add package Microsoft.FeatureManagement.AspNetCoreRegister it in your Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFeatureManagement();
var app = builder.Build();Define your flags in appsettings.json:
{
"FeatureManagement": {
"NewCheckoutFlow": true,
"PaymentRetry": false,
"DarkModeUI": false
}
}
Inject IFeatureManager into your service and check flags at runtime:
public class OrderService
{
private readonly IFeatureManager _featureManager;
public OrderService(IFeatureManager featureManager)
{
_featureManager = featureManager;
}
public async Task ProcessOrderAsync(Order order)
{
if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
{
await ProcessWithNewFlowAsync(order);
}
else
{
await ProcessWithLegacyFlowAsync(order);
}
}
}
This is the simplest form: binary on/off. But real-world rollouts are rarely this straightforward.
Filters: percentage rollouts, time windows, and targeting
Microsoft.FeatureManagement ships with built-in filters that let you control which users see which features. The three most practical are percentage, time window, and targeting.
Percentage rollout
Roll out a feature to a percentage of your user base. This is how we went from 5% to 100% adoption without risk.
{
"FeatureManagement": {
"NewCheckoutFlow": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": {
"Value": 25
}
}
]
}
}
}
Now you'll evaluate the flag with a FeatureFilterContext that includes the user's ID:
var context = new FeatureFilterContext
{
Parameters = new FeatureFilterParameters(),
};
context.Parameters.Add(Microsoft.FeatureManagement.Filters.PercentageFilter.AliasName, userId.ToString());
if (await _featureManager.IsEnabledAsync("NewCheckoutFlow", context))
{
// user sees the feature
}
The library uses a deterministic hash of the user ID, so the same user consistently gets the same experience across requests.
Time window rollout
Enable a feature only during a specific time period. Useful for marketing campaigns or scheduled maintenance windows:
{
"FeatureManagement": {
"BlackFridayDiscount": {
"EnabledFor": [
{
"Name": "TimeWindow",
"Parameters": {
"Start": "2024-11-28T00:00:00Z",
"End": "2024-12-02T23:59:59Z"
}
}
]
}
}
}
The flag automatically activates and deactivates on schedule, no manual intervention needed.
Custom targeting
For complex scenarios, build a custom filter. Here's one that targets users in specific regions:
public class RegionFilter : IFeatureFilterMetadata, IAsyncFeatureFilter
{
public string Name => "Region";
public async Task EvaluateAsync(FeatureFilterContext context)
{
var parameters = context.Parameters.Get();
var userRegion = context.HttpContext?.User.FindFirst("Region")?.Value;
return parameters.AllowedRegions.Contains(userRegion);
}
}
public class RegionFilterParameters
{
public List AllowedRegions { get; set; } = new();
}
Register it during setup:
builder.Services.AddFeatureManagement()
.AddFeatureFilter();
And reference it in config:
{
"FeatureManagement": {
"NewUIForEU": {
"EnabledFor": [
{
"Name": "Region",
"Parameters": {
"AllowedRegions": ["DE", "FR", "NL"]
}
}
]
}
}
}
Multiple filters can be combined with AND or OR logic, letting you target very specific cohorts.
Gating endpoints with [FeatureGate]
In ASP.NET Core APIs, the [FeatureGate] attribute lets you block entire endpoints behind a feature flag:
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
[HttpPost("checkout")]
[FeatureGate("NewCheckoutFlow")]
public async Task CheckoutAsync([FromBody] CheckoutRequest req)
{
// Only accessible if NewCheckoutFlow is enabled
return Ok(await _orderService.ProcessAsync(req));
}
[HttpPost("checkout-legacy")]
public async Task CheckoutLegacyAsync([FromBody] CheckoutRequest req)
{
// Always available
return Ok(await _orderService.ProcessLegacyAsync(req));
}
}
When the flag is disabled, the endpoint returns a 404. Callers must handle this gracefully—typically by falling back to the legacy endpoint.
For more control, check the flag inside the method and return a specific status:
[HttpPost("checkout")]
public async Task CheckoutAsync(
[FromBody] CheckoutRequest req,
[FromServices] IFeatureManager featureManager)
{
if (!await featureManager.IsEnabledAsync("NewCheckoutFlow"))
{
return StatusCode(503, new { error = "Feature unavailable" });
}
return Ok(await _orderService.ProcessAsync(req));
}
This approach lets your client know the feature is temporarily unavailable rather than permanently gone.
Removing flags once fully rolled out
Feature flags are meant to be temporary. Once a feature is enabled for 100% of users and has proven stable in production for weeks, clean it up. Flags left in code indefinitely become technical debt.
The removal process:
- Verify the feature is stable and no rollback is planned
- Remove the [FeatureGate] attribute or if statements
- Delete the legacy code path (if it exists)
- Remove the flag from appsettings.json
- Deploy as normal
Example cleanup—before:
public async Task ProcessOrderAsync(Order order)
{
if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
{
await ProcessWithNewFlowAsync(order);
}
else
{
await ProcessWithLegacyFlowAsync(order);
}
}
After:
public async Task ProcessOrderAsync(Order order)
{
await ProcessWithNewFlowAsync(order);
}
Remove the now-unused ProcessWithLegacyFlowAsync method entirely. This keeps your codebase lean and reduces cognitive load on the team.
If you're worried about partial deployment visibility across multiple services, consider keeping simple flags that always return true for observation windows, but delete the conditional logic itself.
Common mistakes
Forgetting to pass context: Many developers call IsEnabledAsync without a FeatureFilterContext, expecting percentage or targeting filters to work. They don't—the library has no way to know which user is making the request. Always include context when using filters.
Hardcoding configuration: Feature flag values should live in configuration (appsettings.json, environment variables, or a remote config service), not in code constants. If they're hardcoded, you've lost the ability to toggle them without redeployment.
Never cleaning up: Old flags accumulate, making the codebase harder to understand and test. Set a team policy: flags live for at most 4-6 weeks. If a feature isn't fully rolled out by then, something went wrong and needs review.
Ignoring feature flag bloat in config: A large appsettings.json with 50+ unused flags slows down startup and confuses new team members. Treat flag removal with the same priority as adding them.
Key takeaways
- Feature flags decouple deployment from release, letting you ship code dark and activate it when ready
- Microsoft.FeatureManagement integrates seamlessly with ASP.NET Core configuration and dependency injection
- Use built-in filters (Percentage, TimeWindow) for gradual rollouts and custom filters for complex targeting logic
- The [FeatureGate] attribute is convenient for simple endpoint blocking, but inline checks give you more control over responses
- Always include FeatureFilterContext when using filters; otherwise, percentage and targeting logic won't work
- Remove flags aggressively once features stabilize—aim for a maximum lifecycle of 4-6 weeks to prevent technical debt
- Store flag configuration in your config system, never hardcode values






Comments
Loading comments…