Introduction
There are no strict rules that must be followed when writing code. Readability often depends on the developer's style. I don't enforce adherence to "clean code" or "simple code" principles. However, when implementing feature flags, certain considerations are essential for better maintainability and collaboration with other developers:
- Easily remove stale feature flag code once a feature is fully released.
- Ensure correct and automated removal using AI tooling.
- Make it easy for other developers to review the code.
- Prevent accidental deletion of the wrong code during cleanup.
- Avoid losing important code related to a feature flag that should be removed.
- Ensure the code is easily testable with unit tests.
This article focuses on best practices for implementing feature flags in Asp.NET Core controllers.
Using method attribute for feature flag
You can use method attributes to control feature access at the controller or method level. This approach works best for one-way feature flags.
Note: "One-way feature flag" means there are no A/B branches for different code paths - it's simply for turning a feature on or off.
public class ExampleController : MyApiController
{
[FeatureGate("FeatureX")]
public IActionResult ExampleMethod()
{
// some code
}
}
The advantages of this approach include:
- Feature flag control is clearly visible through method attributes, improving readability
- It's easy to remove stale feature flag code by simply removing the attribute
- It's easy for automated tools or AI to correctly identify and remove feature flag code
- It's easy for other developers to review since the feature flag control is explicit
- The feature flag implementation is separated from business logic
Avoid passing string as parameter when calling method
When calling feature flag evaluation methods, avoid passing string literals as parameters. Instead, use a static class to define all feature flags used in the application, then pass the static variable as parameter. The advantages:
- During build time, referencing a static variable guarantees that you won't miss related feature flag code when removing it
- You have a central place to manage all feature flags currently being used in the project
- You can easily document what each feature flag controls with detailed comments
- By making Flags a static class, you prevent instantiation and ensure all flags are accessed through the class name
- It enables better code analysis and AI-assisted maintenance with clear references
The approach I don't recommend:
public async Task<IActionResult> ExampleMethod()
{
if(!await featureManager.IsEnabledAsync("FeatureX"))
{
return NotFound();
}
// some code
}
The recommended approach:
public async Task<IActionResult> ExampleMethod()
{
if(!await featureManager.IsEnabledAsync(Flags.FeatureX))
{
return NotFound();
}
// some code
}
/// <summary>
/// Static class containing all feature flag definitions used in the
/// application. This implementation is thread-safe as const values are:
/// - Resolved at compile time
/// - Immutable and cannot be changed at runtime
/// - Shared safely across multiple threads
/// - Performance optimized
/// </summary>
public static class Flags
{
/// <summary>
/// Feature Flag for Feature X of Project A
/// </summary>
public const string FeatureX = "FeatureX";
// other feature flags
}
Avoid passing feature flag value to nested method
When the feature change or new feature logic is in a nested method, don't call feature flag evaluation at the controller level. Instead, call it within the service layer because:
- When removing the feature flag, you must modify the nested method or service input parameters
- It adds unnecessary complexity to code reviews when feature flag checks and their usage appear in different places
- It complicates writing unit tests for services
- It makes it harder for AI to recognize feature flag patterns when doing pull request reviews or automatic feature flag removal
- It violates separation of concerns by having the controller handle feature flag logic
Here's an example of what not to do and what to do instead:
Don't write code like this:
public class ExampleController(
IFeatureManager featureManager,
IExampleService exampleService) : MyApiController
{
public async Task<IActionResult> ExampleMethod()
{
// some code
var flagValue = await featureManager.IsEnabledAsync(Flags.FeatureX);
exampleService.ExampleMethod(flagValue);
// some code
}
}
public class ExampleService() : IExampleService
{
public void ExampleMethod(bool isEnabled)
{
// some code
if(isEnabled)
{
// feature-specific code
}
// some code
}
}
Write code like this instead:
public class ExampleController(IExampleService exampleService) : MyApiController
{
public async Task<IActionResult> ExampleMethod()
{
// some code
await exampleService.ExampleMethod();
// some code
}
}
public class ExampleService(IFeatureManager featureManager) : IExampleService
{
public async Task ExampleMethod()
{
// some code
if(await featureManager.IsEnabledAsync(Flags.FeatureX))
{
// feature-specific code
}
// some code
}
}
Avoid making multiple checks in one if statement
Combining feature flag checks with other conditions makes code harder to maintain and understand. When it's time to remove the feature flag, there's a risk of accidentally removing essential business logic.
Incorrect approach:
public async Task<IActionResult> ExampleMethod()
{
// some code
if(await featureManager.IsEnabledAsync(Flags.FeatureX) &&
user.HasPermission("Admin"))
{
// feature-specific code
}
// some code
}
Better approach:
public async Task<IActionResult> ExampleMethod()
{
// some code
if(user.HasPermission("Admin"))
{
if(await featureManager.IsEnabledAsync(Flags.FeatureX))
{
// feature-specific code
}
}
// some code
}
Clean way to handle two branches for a feature flag.
When implementing A/B testing or transitioning from old to new functionality, make the conditional branches clear and structured:
public async Task<IActionResult> ExampleMethod()
{
var flagEnabled = await featureManager.IsEnabledAsync(Flags.FeatureX);
if(flagEnabled)
{
// New implementation
return NewImplementationFunc();
}
else
{
// Old implementation
return OldImplementationFunc("OldFeature");
}
}
If the old implementation hasn't been encapsulated in a separate method, I prefer to not modify the existing code and not to use the else
statement. Instead, I return the result of the new implementation and let the old implementation be the default behavior:
public async Task<IActionResult> ExampleMethod()
{
var flagEnabled = await featureManager.IsEnabledAsync(Flags.FeatureX);
if(flagEnabled)
{
// New implementation
return NewImplementationFunc();
}
// Old implementation
// Include the return statement here if needed
}
But the code above is not elegant. For minimal changes, if the real-world case allows, I prefer to add a new method for the new implementation and let the caller side (e.g. front-end app) decide which implementation to use:
public async Task<IActionResult> ExampleMethod()
{
// keep the old implementation
}
// use FeatureGate or if statement to control
// the new implementation
[FeatureGate("FeatureX")]
public async Task<IActionResult> NewImplementationMethod()
{
if(!await featureManager.IsEnabledAsync(Flags.FeatureX))
{
return NotFound();
}
// new implementation
}
Test with Copilot for Edits
In the coming weeks, I'll demonstrate how AI can help:
- Remove stale feature flag code more efficiently following the practices outlined above
- Establish editing rules that all team members can follow
- Automate feature flag cleanup during code maintenance
- Identify potential issues in feature flag implementation
- Verify that feature flag removal doesn't break existing functionality
Tips: Performance Considerations for Feature Flag Keys
When working with feature flags, you might wonder about the performance impact of using static const properties versus inline string literals. Here's what you should know:
Performance Analysis
// Approach 1: Using string literals
if(await featureManager.IsEnabledAsync("FeatureX"))
{
// Feature-specific code
}
// Approach 2: Using static const properties
if(await featureManager.IsEnabledAsync(Flags.FeatureX))
{
// Feature-specific code
}
From a pure performance standpoint:
-
Compile-time optimization: Both approaches perform nearly identically at runtime because:
- String literals are interned by the compiler
- Const strings are also interned and treated as literals
- The JIT compiler optimizes both cases similarly
-
Memory usage: Both consume the same amount of memory since:
- Each unique string literal is stored only once in the string intern pool
- Const strings are also stored in the string intern pool
- References to the same string share the same memory location
-
String comparison: The
IsEnabledAsync
method will perform identical string lookups regardless of how the string was sourced
Why Static Constants Are Still Preferred
While the performance difference is negligible, static constants offer significant advantages:
- Compile-time safety: Typos in string literals won't be caught until runtime
- Refactoring support: Renaming a constant automatically updates all references
- Single source of truth: Changes to flag names need to be made in only one place
- IDE support: Intellisense and code completion work with constants
- Static analysis: Code analysis tools can track usage of constants
The minimal performance impact is greatly outweighed by these maintenance and reliability benefits.
Conclusion
Following these best practices for feature flag implementation in ASP.NET Core controllers will make your codebase more maintainable, easier to test, and simpler to clean up when features are fully released. By structuring your code with clear separation of concerns and consistent patterns, you'll enable both your team and your AI tools to work more efficiently with your feature flags.
Remember that good feature flag implementation isn't just about functionality—it's about creating code that can evolve cleanly as your features mature from development to full release.