The .NET ecosystem has a unique relationship with feature flags. Unlike most language communities where feature flagging requires a third-party library from the start, .NET ships with an official Microsoft-supported feature management library built directly on top of its configuration and dependency injection systems. This first-party support means feature flags in C# can feel natural and well-integrated -- but it also means teams accumulate flag debt faster because the barrier to creating new flags is so low.
Enterprise .NET applications tend to accumulate feature flags quickly, and in our experience, most of those flags outlive their intended expiration by months. The combination of easy flag creation, long-lived enterprise codebases, and the "just add another flag" culture in large organizations produces a specific kind of technical debt that demands specific cleanup strategies.
This guide covers the full spectrum of feature flags in .NET: the Microsoft.FeatureManagement library, third-party SDK integration, ASP.NET Core patterns, testing strategies, the anti-patterns that plague enterprise codebases, and the cleanup approaches that keep flag debt under control.
Microsoft.FeatureManagement: The built-in option
Microsoft's Microsoft.FeatureManagement NuGet package is the starting point for most .NET teams. It integrates with IConfiguration, supports dependency injection natively, and provides ASP.NET Core middleware, action filters, and tag helpers out of the box.
Basic setup
// Program.cs
using Microsoft.FeatureManagement;
var builder = WebApplication.CreateBuilder(args);
// Register feature management services
builder.Services.AddFeatureManagement();
var app = builder.Build();
// appsettings.json
{
"FeatureManagement": {
"NewDashboard": true,
"BetaCheckout": false,
"PremiumReporting": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": {
"Value": 25
}
}
]
}
}
}
Using flags in services
// DashboardService.cs
public class DashboardService
{
private readonly IFeatureManager _featureManager;
private readonly INewDashboardRenderer _newRenderer;
private readonly ILegacyDashboardRenderer _legacyRenderer;
public DashboardService(
IFeatureManager featureManager,
INewDashboardRenderer newRenderer,
ILegacyDashboardRenderer legacyRenderer)
{
_featureManager = featureManager;
_newRenderer = newRenderer;
_legacyRenderer = legacyRenderer;
}
public async Task<DashboardViewModel> GetDashboardAsync(string userId)
{
if (await _featureManager.IsEnabledAsync("NewDashboard"))
{
return await _newRenderer.RenderAsync(userId);
}
return await _legacyRenderer.RenderAsync(userId);
}
}
ASP.NET Core middleware and filters
Microsoft.FeatureManagement provides three integration points for ASP.NET Core that allow flag evaluation without polluting controller logic:
Action filters for gating entire endpoints:
// Controllers/ReportingController.cs
[FeatureGate("PremiumReporting")]
public class ReportingController : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
{
var reports = await _reportService.GetReportsAsync();
return View(reports);
}
[FeatureGate("ReportExport")]
[HttpPost]
public async Task<IActionResult> Export(ExportRequest request)
{
var file = await _exportService.GenerateExportAsync(request);
return File(file, "application/xlsx", "report.xlsx");
}
}
When the PremiumReporting flag is disabled, the entire controller returns 404. When ReportExport is disabled, only the Export action is gated. This is clean, declarative, and easy to understand -- but as we will see later, it is also easy to forget about when the flag becomes stale.
Middleware for request-level feature gating:
// Program.cs
app.UseMiddleware<FeatureFlagMiddleware>();
// FeatureFlagMiddleware.cs
public class FeatureFlagMiddleware
{
private readonly RequestDelegate _next;
private readonly IFeatureManager _featureManager;
public FeatureFlagMiddleware(RequestDelegate next, IFeatureManager featureManager)
{
_next = next;
_featureManager = featureManager;
}
public async Task InvokeAsync(HttpContext context)
{
if (await _featureManager.IsEnabledAsync("MaintenanceMode"))
{
context.Response.StatusCode = 503;
await context.Response.WriteAsJsonAsync(new
{
message = "Service is under maintenance. Please try again later."
});
return;
}
if (await _featureManager.IsEnabledAsync("EnhancedLogging"))
{
context.Items["DetailedLogging"] = true;
}
await _next(context);
}
}
Tag helpers in Razor views:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Microsoft.FeatureManagement.AspNetCore
<feature name="NewDashboard">
<div class="dashboard-v2">
@await Component.InvokeAsync("NewDashboardWidget")
</div>
</feature>
<feature name="NewDashboard" negate="true">
<div class="dashboard-legacy">
@await Component.InvokeAsync("LegacyDashboardWidget")
</div>
</feature>
Custom feature filters
Microsoft.FeatureManagement supports custom filters for targeting logic beyond simple boolean or percentage:
// UserGroupFeatureFilter.cs
[FilterAlias("UserGroup")]
public class UserGroupFeatureFilter : IFeatureFilter
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserGroupFeatureFilter(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var settings = context.Parameters.Get<UserGroupFilterSettings>();
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return Task.FromResult(false);
var userGroup = user.FindFirst("group")?.Value;
var isEnabled = settings.AllowedGroups.Contains(userGroup);
return Task.FromResult(isEnabled);
}
}
public class UserGroupFilterSettings
{
public List<string> AllowedGroups { get; set; } = new();
}
{
"FeatureManagement": {
"InternalBetaFeature": {
"EnabledFor": [
{
"Name": "UserGroup",
"Parameters": {
"AllowedGroups": ["engineering", "product", "qa"]
}
}
]
}
}
}
// Registration
builder.Services.AddFeatureManagement()
.AddFeatureFilter<UserGroupFeatureFilter>();
Third-party SDK integration
Enterprise .NET applications frequently use commercial feature flag platforms alongside or instead of Microsoft.FeatureManagement. The three most common integrations are LaunchDarkly, Unleash, and ConfigCat.
LaunchDarkly .NET SDK
// Program.cs
using LaunchDarkly.Sdk;
using LaunchDarkly.Sdk.Server;
var ldClient = new LdClient("sdk-your-key");
builder.Services.AddSingleton<ILdClient>(ldClient);
// Ensure clean shutdown
var app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => ldClient.Dispose());
// CheckoutService.cs
public class CheckoutService
{
private readonly ILdClient _ldClient;
public CheckoutService(ILdClient ldClient)
{
_ldClient = ldClient;
}
public async Task<CheckoutResult> ProcessCheckoutAsync(User user, Cart cart)
{
var context = Context.Builder(user.Id)
.Set("email", user.Email)
.Set("plan", user.PlanType)
.Set("country", user.Country)
.Build();
var useNewCheckout = _ldClient.BoolVariation("new-checkout-flow", context, false);
if (useNewCheckout)
{
return await ProcessNewCheckoutAsync(cart);
}
return await ProcessLegacyCheckoutAsync(cart);
}
}
Unleash .NET SDK
// Program.cs
using Unleash;
var unleashSettings = new UnleashSettings
{
AppName = "my-dotnet-app",
UnleashApi = new Uri("https://unleash.example.com/api"),
CustomHttpHeaders = new Dictionary<string, string>
{
{ "Authorization", "*:development.some-secret" }
}
};
var unleash = new DefaultUnleash(unleashSettings);
builder.Services.AddSingleton<IUnleash>(unleash);
// PaymentService.cs
public class PaymentService
{
private readonly IUnleash _unleash;
public PaymentService(IUnleash unleash)
{
_unleash = unleash;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
var context = new UnleashContext
{
UserId = request.UserId,
Properties = new Dictionary<string, string>
{
{ "region", request.Region },
{ "amount", request.Amount.ToString() }
}
};
if (_unleash.IsEnabled("stripe-v2-processor", context))
{
return await ProcessWithStripeV2Async(request);
}
return await ProcessWithStripeV1Async(request);
}
}
ConfigCat .NET SDK
// Program.cs
using ConfigCat.Client;
var configCatClient = ConfigCatClient.Get("your-sdk-key");
builder.Services.AddSingleton<IConfigCatClient>(configCatClient);
// NotificationService.cs
public class NotificationService
{
private readonly IConfigCatClient _configCat;
public NotificationService(IConfigCatClient configCat)
{
_configCat = configCat;
}
public async Task SendNotificationAsync(string userId, string message)
{
var user = new User(userId);
var useNewTemplate = await _configCat.GetValueAsync(
"notification_template_v2", false, user);
if (useNewTemplate)
{
await SendWithNewTemplateAsync(userId, message);
}
else
{
await SendWithLegacyTemplateAsync(userId, message);
}
}
}
SDK comparison for .NET
| Feature | Microsoft.FeatureManagement | LaunchDarkly | Unleash | ConfigCat |
|---|---|---|---|---|
| Cost | Free (included) | Paid | Free (OSS) / Paid | Freemium |
| Remote config | Via Azure App Config | Built-in | Built-in | Built-in |
| Targeting | Custom filters | Advanced rules | Strategies | Rules |
| Experimentation | None | Built-in | Basic | None |
| Real-time updates | Via Azure config polling | Streaming SSE | Polling / SSE | Polling |
| DI integration | Native | Manual singleton | Manual singleton | Manual singleton |
| ASP.NET Core helpers | Filters, tag helpers, middleware | Manual | Manual | Manual |
| Offline support | Config file | LDD mode | Bootstrapping | File override |
Dependency injection patterns for flag services
Enterprise .NET applications benefit from abstracting flag evaluation behind interfaces. This pattern decouples business logic from the flag provider, simplifies testing, and makes flag cleanup more systematic.
The feature flag service abstraction
// IFeatureFlagService.cs
public interface IFeatureFlagService
{
Task<bool> IsEnabledAsync(string flagKey, string? userId = null);
Task<T> GetValueAsync<T>(string flagKey, T defaultValue, string? userId = null);
}
// MicrosoftFeatureFlagService.cs
public class MicrosoftFeatureFlagService : IFeatureFlagService
{
private readonly IFeatureManager _featureManager;
public MicrosoftFeatureFlagService(IFeatureManager featureManager)
{
_featureManager = featureManager;
}
public async Task<bool> IsEnabledAsync(string flagKey, string? userId = null)
{
return await _featureManager.IsEnabledAsync(flagKey);
}
public Task<T> GetValueAsync<T>(string flagKey, T defaultValue, string? userId = null)
{
// Microsoft.FeatureManagement is boolean-only; extend as needed
throw new NotSupportedException(
"Use a commercial SDK for multi-variate flags");
}
}
// LaunchDarklyFeatureFlagService.cs
public class LaunchDarklyFeatureFlagService : IFeatureFlagService
{
private readonly ILdClient _ldClient;
public LaunchDarklyFeatureFlagService(ILdClient ldClient)
{
_ldClient = ldClient;
}
public Task<bool> IsEnabledAsync(string flagKey, string? userId = null)
{
var context = userId != null
? Context.New(userId)
: Context.New("anonymous");
return Task.FromResult(_ldClient.BoolVariation(flagKey, context, false));
}
public Task<T> GetValueAsync<T>(string flagKey, T defaultValue, string? userId = null)
{
var context = userId != null
? Context.New(userId)
: Context.New("anonymous");
if (typeof(T) == typeof(string))
{
var result = _ldClient.StringVariation(flagKey, context, defaultValue as string);
return Task.FromResult((T)(object)result);
}
if (typeof(T) == typeof(int))
{
var result = _ldClient.IntVariation(flagKey, context, (int)(object)defaultValue);
return Task.FromResult((T)(object)result);
}
throw new NotSupportedException($"Type {typeof(T)} is not supported");
}
}
Registration with conditional provider selection
// Program.cs
var flagProvider = builder.Configuration["FeatureFlags:Provider"];
switch (flagProvider)
{
case "LaunchDarkly":
var ldClient = new LdClient(builder.Configuration["LaunchDarkly:SdkKey"]);
builder.Services.AddSingleton<ILdClient>(ldClient);
builder.Services.AddSingleton<IFeatureFlagService, LaunchDarklyFeatureFlagService>();
break;
case "Unleash":
var unleash = new DefaultUnleash(new UnleashSettings
{
AppName = "my-app",
UnleashApi = new Uri(builder.Configuration["Unleash:ApiUrl"]!)
});
builder.Services.AddSingleton<IUnleash>(unleash);
builder.Services.AddSingleton<IFeatureFlagService, UnleashFeatureFlagService>();
break;
default:
builder.Services.AddFeatureManagement();
builder.Services.AddSingleton<IFeatureFlagService, MicrosoftFeatureFlagService>();
break;
}
Centralized flag definitions
The most impactful pattern for long-term maintainability is centralizing all flag keys in a single constants class:
// FeatureFlags.cs
/// <summary>
/// Central registry of all feature flags in the application.
/// Every flag key used anywhere in the codebase MUST be defined here.
/// When removing a flag, delete the constant and fix all compiler errors.
/// </summary>
public static class FeatureFlags
{
// --- Active Flags ---
/// <summary>New checkout flow with Stripe v2 integration.</summary>
/// <remarks>Owner: payments-team. Created: 2026-01-15. Target removal: 2026-03-15.</remarks>
public const string NewCheckoutFlow = "new-checkout-flow";
/// <summary>Enhanced reporting dashboard with real-time metrics.</summary>
/// <remarks>Owner: analytics-team. Created: 2026-01-20. Target removal: 2026-04-01.</remarks>
public const string EnhancedReporting = "enhanced-reporting";
/// <summary>Brotli compression for API responses.</summary>
/// <remarks>Owner: platform-team. Created: 2025-12-01. Target removal: 2026-02-28.</remarks>
public const string BrotliCompression = "brotli-compression";
// --- Permanent Flags (Operational) ---
/// <summary>Kill switch for payment processing.</summary>
/// <remarks>Owner: platform-team. Permanent operational flag.</remarks>
public const string PaymentCircuitBreaker = "payment-circuit-breaker";
/// <summary>Maintenance mode for scheduled downtime.</summary>
/// <remarks>Owner: platform-team. Permanent operational flag.</remarks>
public const string MaintenanceMode = "maintenance-mode";
}
This approach provides three critical benefits for cleanup:
- Compiler-driven removal: Delete the constant, and the C# compiler identifies every file that references it. Follow the errors to find every usage.
- Single inventory: Open one file to see every flag in the application, who owns it, and when it should be removed.
- Documentation: XML doc comments capture the context that is otherwise lost when the original developer moves on.
Common .NET anti-patterns
Enterprise .NET codebases exhibit a set of recurring anti-patterns around feature flags. Recognizing these patterns is the first step toward preventing and cleaning them up.
Anti-pattern 1: Flag evaluation in constructors
// BAD: Flag evaluated once at construction, never re-evaluated
public class PricingService
{
private readonly bool _useNewPricing;
private readonly IPricingEngine _engine;
public PricingService(IFeatureFlagService flags, IServiceProvider services)
{
// This evaluation happens once when the service is constructed
// For singleton services, this is once per application lifetime
_useNewPricing = flags.IsEnabledAsync("new-pricing").GetAwaiter().GetResult();
_engine = _useNewPricing
? services.GetRequiredService<NewPricingEngine>()
: services.GetRequiredService<LegacyPricingEngine>();
}
public decimal CalculatePrice(Order order) => _engine.Calculate(order);
}
This pattern is dangerous for two reasons. First, it evaluates the flag synchronously in a constructor using .GetAwaiter().GetResult(), which can deadlock in ASP.NET Core. Second, if the service is registered as a singleton (common in enterprise apps), the flag value is frozen for the lifetime of the application. Changing the flag in your management platform has no effect until the application restarts.
// GOOD: Flag evaluated at call time
public class PricingService
{
private readonly IFeatureFlagService _flags;
private readonly NewPricingEngine _newEngine;
private readonly LegacyPricingEngine _legacyEngine;
public PricingService(
IFeatureFlagService flags,
NewPricingEngine newEngine,
LegacyPricingEngine legacyEngine)
{
_flags = flags;
_newEngine = newEngine;
_legacyEngine = legacyEngine;
}
public async Task<decimal> CalculatePriceAsync(Order order)
{
if (await _flags.IsEnabledAsync("new-pricing"))
{
return await _newEngine.CalculateAsync(order);
}
return await _legacyEngine.CalculateAsync(order);
}
}
Anti-pattern 2: Deeply nested flag conditions
// BAD: Nested flags create exponential complexity
public async Task<ShippingResult> CalculateShippingAsync(Order order)
{
if (await _flags.IsEnabledAsync(FeatureFlags.NewShippingEngine))
{
if (await _flags.IsEnabledAsync(FeatureFlags.ExpressShipping))
{
if (await _flags.IsEnabledAsync(FeatureFlags.InternationalShipping))
{
return await _newExpressInternationalShipping.CalculateAsync(order);
}
return await _newExpressDomesticShipping.CalculateAsync(order);
}
return await _newStandardShipping.CalculateAsync(order);
}
return await _legacyShipping.CalculateAsync(order);
}
Three nested flags create 8 possible code paths. Removing any single flag requires understanding its interaction with every other flag in the nesting hierarchy. Instead, flatten the evaluation:
// GOOD: Flat evaluation, independent removal
public async Task<ShippingResult> CalculateShippingAsync(Order order)
{
var useNewEngine = await _flags.IsEnabledAsync(FeatureFlags.NewShippingEngine);
var useExpress = await _flags.IsEnabledAsync(FeatureFlags.ExpressShipping);
var useInternational = await _flags.IsEnabledAsync(FeatureFlags.InternationalShipping);
var calculator = _shippingCalculatorFactory.Create(
newEngine: useNewEngine,
express: useExpress,
international: useInternational);
return await calculator.CalculateAsync(order);
}
Anti-pattern 3: Flag-driven interface implementations
// BAD: The entire class hierarchy exists because of a flag
public interface INotificationSender { Task SendAsync(Notification n); }
public class LegacyEmailSender : INotificationSender { /* ... */ }
public class NewMultiChannelSender : INotificationSender { /* ... */ }
// In DI registration:
if (await featureManager.IsEnabledAsync("multi-channel-notifications"))
{
services.AddScoped<INotificationSender, NewMultiChannelSender>();
}
else
{
services.AddScoped<INotificationSender, LegacyEmailSender>();
}
When the flag is permanently enabled, LegacyEmailSender and everything it depends on is dead code. But because it implements an interface and is registered in DI, it looks like a legitimate component. No static analysis tool will flag it as unused -- it is used, just never by the active registration path.
Anti-pattern 4: Scattered string literals
// BAD: Flag key strings scattered across the codebase
// In CheckoutController.cs
if (await _featureManager.IsEnabledAsync("new-checkout"))
// In CheckoutService.cs
if (await _flags.IsEnabledAsync("new-checkout"))
// In CheckoutTests.cs
_mockFlags.Setup(f => f.IsEnabledAsync("new-checkout", null))
.ReturnsAsync(true);
// In appsettings.json
"new-checkout": true
When this flag is ready for removal, a developer must search for the string "new-checkout" across the entire codebase, including JSON files, test files, and configuration. Missing one reference means the cleanup is incomplete. Use the centralized FeatureFlags constants class described earlier to make this a compiler-verified operation.
Anti-pattern 5: Flags in Entity Framework queries
// BAD: Flag in EF query changes database behavior
public async Task<List<Product>> GetFeaturedProductsAsync()
{
if (await _flags.IsEnabledAsync("ml-product-ranking"))
{
// Completely different query with ML scoring table join
return await _context.Products
.Join(_context.MLScores, p => p.Id, s => s.ProductId, (p, s) => new { p, s })
.OrderByDescending(x => x.s.Score)
.Select(x => x.p)
.Take(20)
.ToListAsync();
}
// Simple query
return await _context.Products
.Where(p => p.IsFeatured)
.OrderByDescending(p => p.CreatedAt)
.Take(20)
.ToListAsync();
}
This pattern is particularly dangerous because the two code paths produce different SQL, hit different database indexes, and have different performance characteristics. When the flag is stale, the dead code path's index may still exist in the database, consuming storage and slowing writes.
Testing feature flags in .NET
xUnit testing with mocked flag services
// CheckoutServiceTests.cs
using Moq;
using Xunit;
public class CheckoutServiceTests
{
private readonly Mock<IFeatureFlagService> _mockFlags;
private readonly Mock<INewCheckoutProcessor> _mockNewProcessor;
private readonly Mock<ILegacyCheckoutProcessor> _mockLegacyProcessor;
private readonly CheckoutService _sut;
public CheckoutServiceTests()
{
_mockFlags = new Mock<IFeatureFlagService>();
_mockNewProcessor = new Mock<INewCheckoutProcessor>();
_mockLegacyProcessor = new Mock<ILegacyCheckoutProcessor>();
_sut = new CheckoutService(
_mockFlags.Object,
_mockNewProcessor.Object,
_mockLegacyProcessor.Object);
}
[Fact]
public async Task ProcessCheckout_WhenNewCheckoutEnabled_UsesNewProcessor()
{
// Arrange
_mockFlags
.Setup(f => f.IsEnabledAsync(FeatureFlags.NewCheckoutFlow, It.IsAny<string>()))
.ReturnsAsync(true);
var cart = CreateTestCart();
_mockNewProcessor
.Setup(p => p.ProcessAsync(cart))
.ReturnsAsync(new CheckoutResult { Success = true });
// Act
var result = await _sut.ProcessCheckoutAsync(cart);
// Assert
Assert.True(result.Success);
_mockNewProcessor.Verify(p => p.ProcessAsync(cart), Times.Once);
_mockLegacyProcessor.Verify(p => p.ProcessAsync(It.IsAny<Cart>()), Times.Never);
}
[Fact]
public async Task ProcessCheckout_WhenNewCheckoutDisabled_UsesLegacyProcessor()
{
// Arrange
_mockFlags
.Setup(f => f.IsEnabledAsync(FeatureFlags.NewCheckoutFlow, It.IsAny<string>()))
.ReturnsAsync(false);
var cart = CreateTestCart();
_mockLegacyProcessor
.Setup(p => p.ProcessAsync(cart))
.ReturnsAsync(new CheckoutResult { Success = true });
// Act
var result = await _sut.ProcessCheckoutAsync(cart);
// Assert
Assert.True(result.Success);
_mockLegacyProcessor.Verify(p => p.ProcessAsync(cart), Times.Once);
_mockNewProcessor.Verify(p => p.ProcessAsync(It.IsAny<Cart>()), Times.Never);
}
private static Cart CreateTestCart() =>
new() { Items = new List<CartItem> { new() { ProductId = "prod-1", Quantity = 1 } } };
}
NUnit testing with a test flag service
// TestFeatureFlagService.cs
public class TestFeatureFlagService : IFeatureFlagService
{
private readonly Dictionary<string, bool> _flags = new();
public void SetFlag(string key, bool value) => _flags[key] = value;
public Task<bool> IsEnabledAsync(string flagKey, string? userId = null)
{
return Task.FromResult(_flags.TryGetValue(flagKey, out var value) && value);
}
public Task<T> GetValueAsync<T>(string flagKey, T defaultValue, string? userId = null)
{
return Task.FromResult(defaultValue);
}
}
// PaymentServiceTests.cs
using NUnit.Framework;
[TestFixture]
public class PaymentServiceTests
{
private TestFeatureFlagService _flags;
private PaymentService _sut;
[SetUp]
public void SetUp()
{
_flags = new TestFeatureFlagService();
_sut = new PaymentService(_flags, new StripeV2Client(), new StripeV1Client());
}
[Test]
public async Task ProcessPayment_WithStripeV2Flag_UsesNewProcessor()
{
_flags.SetFlag(FeatureFlags.StripeV2Processor, true);
var result = await _sut.ProcessPaymentAsync(CreateTestPayment());
Assert.That(result.ProcessorVersion, Is.EqualTo("v2"));
}
[Test]
public async Task ProcessPayment_WithoutStripeV2Flag_UsesLegacyProcessor()
{
_flags.SetFlag(FeatureFlags.StripeV2Processor, false);
var result = await _sut.ProcessPaymentAsync(CreateTestPayment());
Assert.That(result.ProcessorVersion, Is.EqualTo("v1"));
}
}
Integration testing with WebApplicationFactory
// IntegrationTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
public class FeatureFlagIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public FeatureFlagIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task ReportingEndpoint_WhenFlagEnabled_ReturnsData()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Override feature flags for testing
services.AddSingleton<IFeatureFlagService>(
new TestFeatureFlagService
{
{ FeatureFlags.EnhancedReporting, true }
});
});
}).CreateClient();
var response = await client.GetAsync("/api/reports");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<ReportResponse>();
Assert.NotNull(content);
Assert.True(content.EnhancedMetrics); // V2 response includes enhanced metrics
}
[Fact]
public async Task ReportingEndpoint_WhenFlagDisabled_ReturnsBasicData()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IFeatureFlagService>(
new TestFeatureFlagService
{
{ FeatureFlags.EnhancedReporting, false }
});
});
}).CreateClient();
var response = await client.GetAsync("/api/reports");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<ReportResponse>();
Assert.NotNull(content);
Assert.False(content.EnhancedMetrics);
}
}
Pre-removal characterization tests
Before removing a flag, write characterization tests that capture the winning path's behavior without any flag evaluation. These tests should pass both before and after the flag is removed:
/// <summary>
/// Characterization tests for the NewCheckoutFlow flag removal.
/// These tests verify the behavior of the "enabled" path, which will become
/// the only path after the flag is removed.
/// RUN THESE BEFORE AND AFTER REMOVAL. Both runs must pass identically.
/// </summary>
[Collection("CharacterizationTests")]
public class NewCheckoutCharacterizationTests
{
[Fact]
public async Task Checkout_CalculatesCorrectTotal_WithDiscount()
{
var cart = new Cart
{
Items = new[] { new CartItem("SKU-001", 2, 29.99m) },
DiscountCode = "SAVE10"
};
var result = await _processor.ProcessAsync(cart);
Assert.Equal(53.98m, result.Total); // 2 * 29.99 * 0.9
}
[Fact]
public async Task Checkout_SendsConfirmationEmail_OnSuccess()
{
var cart = CreateValidCart();
await _processor.ProcessAsync(cart);
_mockEmailService.Verify(
e => e.SendAsync(It.Is<EmailMessage>(m =>
m.Template == "checkout-confirmation-v2")),
Times.Once);
}
[Fact]
public async Task Checkout_CreatesAuditRecord_WithNewFormat()
{
var cart = CreateValidCart();
await _processor.ProcessAsync(cart);
var audit = await _auditRepository.GetLatestAsync();
Assert.Equal("checkout.completed", audit.EventType);
Assert.Contains("processor_version", audit.Metadata.Keys);
}
}
Safe flag removal in .NET
Step-by-step removal process
Removing a feature flag from a .NET application follows a predictable sequence. Using the FeatureFlags.NewCheckoutFlow flag as an example:
Step 1: Delete the constant from FeatureFlags.cs
// Delete this line from FeatureFlags.cs:
// public const string NewCheckoutFlow = "new-checkout-flow";
Build the project. The compiler will generate errors at every location that references FeatureFlags.NewCheckoutFlow. This is the inventory of changes needed.
Step 2: Simplify each call site
// Before
public async Task<CheckoutResult> ProcessCheckoutAsync(Cart cart)
{
if (await _flags.IsEnabledAsync(FeatureFlags.NewCheckoutFlow))
{
return await _newProcessor.ProcessAsync(cart);
}
return await _legacyProcessor.ProcessAsync(cart);
}
// After
public async Task<CheckoutResult> ProcessCheckoutAsync(Cart cart)
{
return await _newProcessor.ProcessAsync(cart);
}
Step 3: Remove the dead dependency
// Before
public class CheckoutService
{
private readonly IFeatureFlagService _flags;
private readonly INewCheckoutProcessor _newProcessor;
private readonly ILegacyCheckoutProcessor _legacyProcessor;
public CheckoutService(
IFeatureFlagService flags,
INewCheckoutProcessor newProcessor,
ILegacyCheckoutProcessor legacyProcessor)
{
_flags = flags;
_newProcessor = newProcessor;
_legacyProcessor = legacyProcessor;
}
}
// After
public class CheckoutService
{
private readonly INewCheckoutProcessor _newProcessor;
public CheckoutService(INewCheckoutProcessor newProcessor)
{
_newProcessor = newProcessor;
}
}
If IFeatureFlagService is no longer used by this class, remove it from the constructor. If ILegacyCheckoutProcessor is no longer used anywhere in the application, remove the interface, its implementation, and its DI registration.
Step 4: Remove FeatureGate attributes
// Before
[FeatureGate("new-checkout-flow")]
[HttpPost]
public async Task<IActionResult> Checkout(CheckoutRequest request)
// After
[HttpPost]
public async Task<IActionResult> Checkout(CheckoutRequest request)
Step 5: Remove Razor tag helpers
<!-- Before -->
<feature name="NewCheckoutFlow">
<checkout-form-v2 />
</feature>
<feature name="NewCheckoutFlow" negate="true">
<checkout-form-legacy />
</feature>
<!-- After -->
<checkout-form-v2 />
Then delete the checkout-form-legacy component entirely.
Step 6: Clean up configuration
// Remove from appsettings.json:
// "new-checkout-flow": true
Step 7: Update tests
Delete all tests that test the disabled path. Simplify remaining tests to remove flag setup:
// Before
[Fact]
public async Task ProcessCheckout_WhenNewCheckoutEnabled_UsesNewProcessor()
{
_mockFlags
.Setup(f => f.IsEnabledAsync(FeatureFlags.NewCheckoutFlow, It.IsAny<string>()))
.ReturnsAsync(true);
// ... test code
}
// After
[Fact]
public async Task ProcessCheckout_UsesProcessor()
{
// No flag setup needed
// ... test code
}
Step 8: Clean up DI registrations
// Remove if no longer needed:
// services.AddScoped<ILegacyCheckoutProcessor, LegacyCheckoutProcessor>();
// services.AddScoped<LegacyStripeClient>();
Step 9: Remove dead code files
Delete the implementation files for the legacy path:
LegacyCheckoutProcessor.csILegacyCheckoutProcessor.csLegacyStripeClient.csLegacyCheckoutProcessorTests.cs
The removal checklist
| Step | Action | Verification |
|---|---|---|
| 1 | Delete flag constant from FeatureFlags.cs | Project does not compile (expected) |
| 2 | Fix each compiler error by keeping the enabled path | Project compiles |
| 3 | Remove unused constructor parameters and DI registrations | Project compiles, no warnings |
| 4 | Remove [FeatureGate] attributes | Endpoints accessible without flag |
| 5 | Remove Razor <feature> tag helpers | Views render correctly |
| 6 | Remove flag from appsettings.json / config | No config warnings on startup |
| 7 | Update tests (delete disabled-path tests, simplify setup) | All tests pass |
| 8 | Delete dead code files (legacy implementations) | No unused file warnings |
| 9 | Run full test suite | All tests pass |
| 10 | Run characterization tests | Behavior unchanged |
Automating flag cleanup in .NET
Manual flag removal works for individual flags, but enterprise codebases need automation to keep pace with flag creation. The .NET ecosystem is well-suited for automated cleanup because the strong type system provides guardrails that dynamic languages lack.
Static analysis with Roslyn
C# developers can build custom Roslyn analyzers that detect stale flag patterns:
// StaleFeatureFlagAnalyzer.cs (Roslyn Diagnostic Analyzer)
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class StaleFeatureFlagAnalyzer : DiagnosticAnalyzer
{
public static readonly DiagnosticDescriptor Rule = new(
id: "FF001",
title: "Stale feature flag detected",
messageFormat: "Feature flag '{0}' has exceeded its target removal date",
category: "Cleanup",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}
private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
// Check if this is a feature flag evaluation call
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess
&& memberAccess.Name.Identifier.Text == "IsEnabledAsync")
{
// Check the argument for a FeatureFlags constant reference
var argument = invocation.ArgumentList.Arguments.FirstOrDefault();
if (argument?.Expression is MemberAccessExpressionSyntax flagAccess
&& flagAccess.Expression is IdentifierNameSyntax identifier
&& identifier.Identifier.Text == "FeatureFlags")
{
// Look up the constant's XML doc for expiration date
var symbol = context.SemanticModel.GetSymbolInfo(flagAccess).Symbol;
var xmlComment = symbol?.GetDocumentationCommentXml();
if (IsExpired(xmlComment))
{
var diagnostic = Diagnostic.Create(
Rule,
invocation.GetLocation(),
flagAccess.Name.Identifier.Text);
context.ReportDiagnostic(diagnostic);
}
}
}
}
}
This analyzer runs during compilation and produces warnings for any flag that has passed its target removal date, directly in the developer's IDE.
Tree-sitter-based detection
For teams that need cross-repository flag detection beyond what Roslyn provides, tree-sitter offers language-agnostic AST parsing. Tree-sitter's C# grammar can identify flag evaluation patterns across diverse codebases:
;; Tree-sitter query for C# feature flag detection
;; IFeatureManager.IsEnabledAsync("flag-key")
(invocation_expression
function: (member_access_expression
name: (identifier) @method_name)
arguments: (argument_list
(argument
(string_literal) @flag_key))
(#any-of? @method_name
"IsEnabledAsync"
"IsEnabled"))
;; BoolVariation("flag-key", context, default)
(invocation_expression
function: (member_access_expression
name: (identifier) @method_name)
arguments: (argument_list
(argument
(string_literal) @flag_key)
(_)
(_))
(#any-of? @method_name
"BoolVariation"
"StringVariation"
"IntVariation"
"FloatVariation"))
;; [FeatureGate("flag-key")]
(attribute
name: (identifier) @attr_name
arguments: (attribute_argument_list
(attribute_argument
(string_literal) @flag_key))
(#eq? @attr_name "FeatureGate"))
FlagShark uses tree-sitter to detect feature flags across C# codebases, supporting Microsoft.FeatureManagement, LaunchDarkly, Unleash, ConfigCat, and custom wrapper patterns. The AST-based approach catches flag references that regex-based scanning misses -- multiline invocations, string interpolation in flag keys, and attribute-based gating.
CI pipeline integration
Add flag hygiene checks to your .NET CI pipeline:
# .github/workflows/flag-hygiene.yml
name: Feature Flag Hygiene
on: [pull_request]
jobs:
flag-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build with Roslyn analyzers
run: dotnet build --warnaserror:FF001
# Fails the build if any stale flags are detected
- name: Count feature flag references
run: |
count=$(grep -r "FeatureFlags\." src/ --include="*.cs" | wc -l)
echo "Total flag references: $count"
echo "## Feature Flag Report" >> $GITHUB_STEP_SUMMARY
echo "Total flag references in codebase: **$count**" >> $GITHUB_STEP_SUMMARY
- name: Check for flag strings outside FeatureFlags class
run: |
# Find flag key strings that are not using the FeatureFlags constants
violations=$(grep -rn "IsEnabledAsync(\"" src/ --include="*.cs" || true)
if [ -n "$violations" ]; then
echo "::warning::Found flag evaluations using string literals instead of FeatureFlags constants:"
echo "$violations"
fi
Measuring flag debt in .NET projects
Track these metrics to understand the health of your flag hygiene:
| Metric | Healthy | Warning | Action Required |
|---|---|---|---|
Total flag constants in FeatureFlags.cs | < 20 | 20-50 | > 50 |
| Flags past target removal date | 0 | 1-5 | > 5 |
| Average flag age (non-permanent) | < 30 days | 30-60 days | > 60 days |
| Dead code files from stale flags | 0 | 1-10 | > 10 |
| Test methods testing disabled paths of stale flags | 0 | 1-20 | > 20 |
[FeatureGate] attributes referencing stale flags | 0 | 1-3 | > 3 |
| Flag evaluation calls per request (P95) | < 5 | 5-15 | > 15 |
Flag debt cost in .NET
Flag cleanup in .NET takes real engineering time. Each stale flag removal involves tracing references, updating tests, removing dead code, and cleaning up DI registrations -- work that typically takes one to several hours depending on how deeply the flag is embedded. Multiply that across dozens of stale flags and a team of engineers, and the accumulated cost is significant.
The cleanup time per flag in .NET is typically lower than in dynamically typed languages because the compiler catches reference errors. But the total cost is still meaningful because enterprise .NET codebases tend to be larger and accumulate flags faster.
The .NET ecosystem provides strong foundations for feature flag management: Microsoft.FeatureManagement gives you built-in DI integration, ASP.NET Core filters, and Razor tag helpers. Third-party SDKs from LaunchDarkly, Unleash, and ConfigCat add advanced targeting and remote configuration. The type system and compiler provide guardrails during cleanup that dynamically typed languages lack entirely.
But these foundations only help if the team builds the right patterns on top of them. Centralizing flag keys in a constants class, abstracting flag evaluation behind an interface, testing both paths explicitly, and treating flag removal as a routine practice rather than an annual cleanup sprint are the patterns that separate teams with manageable flag debt from teams drowning in dead conditionals. The compiler will tell you where a flag is used. It will not tell you that a flag has been at 100% for three months and should have been removed weeks ago. That awareness requires tooling -- whether it is a Roslyn analyzer tracking expiration dates, a CI pipeline counting flag references, or an automated tool like FlagShark detecting stale flags across your repositories and generating the cleanup PRs. The strongest approach combines the .NET type system's compile-time safety with automated detection and removal, closing the full lifecycle from flag creation through code cleanup.