Do it right: .NET feature toggling
Introduction
You can skip right to the fun part if you don’t need an introduction to feature toggling maintenance hell, or if you are looking for arguably the most convenient way to plug a permission guard because recent feature overexposure caused financial loss to your company.
Imagine you are a .NET software engineer who was given a task to temporarily gate some work-in-progress code from unauthorized access except for a team of QA engineers.
First, you would probably create something like a FeatureService class which should check a user’s access based on some records in a data store. Then, the next thing to do would be to call a method on an instance of the class from within another class to do something like this:
public string GetPizzaName(bool experimental)
{
if (experimental && featureService.IsFeatureEnabled
(FeatureNames.ExperimentalPizza, userService.GetCurrentUser()?.Id))
{
return "New pizza with salami, tomatoes and pineapples!".
}
return "Pizza with salami and tomatoes".
}Now let’s assume that the app was growing and new features were coming, so it was just a matter of time before you were going to realize that not only had your company been torturing people with pineapple pizza all this time, but also that there had to be a more elegant way to maintain feature flags than this.
Wouldn’t it be nice to have something like this?
public string GetPizzaName([ExperimentalPizzaAllowed] bool experimental)
=> experimental
? "New pizza with salami, tomatoes and pineapples!"
: "Pizza with salami and tomatoes";You might say, “the first implementation could’ve also been rewritten into an expression statement” — fair enough!
But here is when the first solution becomes impractical: suppose it was server-side code, and its client app mistakenly (this kind of situation is what happened to me on a real enterprise project) showed a button to users to let them order an experimental pizza while they were not in the feature allowlist.
In such a situation, it wouldn’t matter if they clicked the experimental button— they would always be given a regular pizza which would be confusing. And there is more to it: the trivial implementation is absolutely silent about someone trying to use experimental functionality, so customers are going to run into this bug for days/weeks/months (before the feature becomes fully available), probably downrating our lovely pizza app in the meantime.
Naturally, after discovering a feature leakage, a responsible engineer would do something like this:
public string GetPizzaName(bool experimental)
{
if (experimental)
{
if (featureService.IsFeatureEnabled
(FeatureNames.ExperimentalPizza, userService.GetCurrentUser()?.Id))
{
return "New pizza with salami, tomatoes and pineapples!".
}
else
{
logger.LogSevere
($"{FeatureNames.ExperimentalPizza} feature is wrongly exposed!");
}
}
return "Pizza with salami and tomatoes".
}Looking at this, having all this stuff handled in a compact attribute seems to make much more sense now.
Of course, that was a silly example of a pizza name retrieval method, while in reality the consequences could’ve been much more severe. Imagine that your competitors discovered a top-secret feature, or misleading data was shown in a financial app or it was something like a ‘Claim your guaranteed prize!’ button, which made a substantial number of users file lawsuits for being scammed.
But enough of the introduction, let’s go over a few material changes you can make today to address issues like these and improve your codebase quality or simply justify your tech lead salary.
The fun part: 5 tricks using En-Garde
As you might have guessed, you don’t need to build any feature guards from scratch — there is a ready-to-use Nuget package called En-Garde. We will cover its configuration in the boring part — for now, let’s see what one can achieve with it inside our pizza app:
Trick 1: Type guard
This one is useful when you introduce a new service, and need to protect all its endpoints at once:
[ApiController]
[Engarde("cupcakes-feature")] //guard the whole controller with just 1 attribute
public class CupcakeController
{
private static string CupcakeName { get; set; } = "Sweet cupcake"
[HttpGet]
public async Task<ActionResult<string>> Get()
{
return Ok(CupcakeName);
}
[HttpPut]
public async Task<ActionResult> Put(string cupcakeName)
{
CupcakeName = cupcakeName
return Ok();
}
}Now every time someone without access to the feature executes any of the endpoints above, our guard will call a failure method which can log / throw / do whatever you want, depending on the configuration explained further in the article. This applies to any other example shown in the next tricks.
Trick 2: Value guard
I love using this one when I need to extend request DTOs with new fields or add new values acceptable inside:
public enum SauceType
{
SauceType.None = 0,
SauceType.Ketchup = 1,
SauceType.Mustard = 2,
SauceType.Mayonnaise = 3,
}
public record PizzaOrderRequestDto
{
public string ClientFullName { get; init; }
public bool WithTomatoes { get; init; }
public bool WithSalami { get; init; }
public bool WithPineapples { get; init; }
//only default (None) is allowed without 'sauces' toggle
//'new-sauces' is needed to put mustard or mayonnaise
[Engarde("sauces")]
[Engarde("new-sauces", SauceType.Mustard)]
[Engarde("new-sauces", SauceType.Mayonnaise)]
public SauceType Sauce { get; init; }
}Just so you know: you can place these En-Garde attributes virtually anywhere! You can put them on classes or interfaces (guards are accumulated down the hierarchy), properties, fields, parameters or methods.
Trick 3: Fallback method
Instead of adding branching inside a method, you can simply direct users not allowed to use a feature to call a replacement (fallback) method:
[ApiController]
public class CupcakeController
{
private static string CupcakeName { get; set; } = "Sweet cupcake"
[HttpGet]
public async Task<ActionResult<string>> Get()
{
return Ok(CupcakeName);
}
[HttpPut]
[Engarde(nameof(LegacyPut), typeof(CupcakeController),
"update-cupcakes")]
public async Task<ActionResult> Put(string cupcakeName)
{
CupcakeName = "Cool new ${cupcakeName}!";
return Ok();
}
public async Task<ActionResult> LegacyPut(string cupcakeName)
{
CupcakeName = "Boring ${cupcakeName}...";
return Ok();
}
}Don’t worry about making a typo in method or confusing signatures: fallback definitions are compile-time verified and guaranteed to run.
Trick 4: Compiled feature toggles
If you don’t like using the same string constant over and over again, here is the solution:
[GenerateFeatureGuards("reports-view", "reports-edit")]
public partial class AccessGuard : EngardeAttribute;This will produce a pair of compiled feature-bound guards which can be used just like any other guard:
public interface IReportsService
{
[ReportsViewAccessGuard]
Task<ReportData> ViewReportAsync(CancellationToken ct);
[ReportsEditAccessGuard]
Task EditReportAsync(int reportId, ReportData data, CancellationToken ct);
}A feature-bound guard is a guard, that doesn’t accept an arbitrary feature name and has one hardcoded (is bound to it) inside.
Trick 5: DI auto-registration
If you prefer doing checking features inline, here is what you can do with En-Garde:
public class ReportsService(
IDataService dataService,
IReportsViewAccessGuard reportsViewGuard,
IEngarde engardeDefaultGuard) : IReportsService
{
public async Task<ReportData> ViewReportsAsync(CancellationToken ct)
{
if (!await reportsViewGuard.IsFeatureEnabledAsync(ct))
throw new AccessException();
return await dataService.GetReportAsync();
}
public async Task<ReportData> EditReportAsync
(int reportId, ReportData data, CancellationToken ct)
{
if (!await engardeDefaultGuard.IsFeatureEnabledAsync("reports-edit", ct))
throw new AccessException();
if (!await reportsViewGuard.IsFeatureEnabledAsync(reportId, ct))
throw new AccessException();
return await dataService.UpdateReportAsync(reportId, data, ct);
}
}Let’s clarify what is going on here:
- ReportsViewAccessGuard got pre-compiled into an interface, and was automatically registered in the DI container. We only needed to put a feature toggle name into the [GenerateFeatureGuards] attribute.
- We also injected default the guard (IEngarde) and specified feature names inline.
- We supplied inline reportId instead of pre-configured scope Id. This means that the feature is going to be checked against a specific report instead of anything else we normally configure globally, like a user ID.
The boring part: how to configure it
Now, let’s see what is needed to actually run this. Before doing anything else, you need to install the Nuget:
dotnet add package En-GardeQuickstart
Because we’ve been implementing a Web API earlier in the article, let’s assume it is configured inside an ASP.NET Core project:
builder.Services.AddEngarde(engarde =>
{
engarde.UseFeatureStore<IConfiguration>((config, feature) =>
new(bool.TryParse(config[feature], out var enabled) && enabled));
engarde.OnFeatureMissing((featureName, context)
=> throw new InvalidOperationException($"{featureName} is required to use {context.Member.Name}"));
});First, inside UseFeatureStore, we are telling the default guard to lookup a feature from appConfig.json. Then, we throw an exception in OnFeatureMissing which is going to be dispatched every time a feature check is negative.
Get Artem Ovechko’s stories in your inbox
Join Medium for free to get updates from this writer.
As another example, we could use some dedicated feature service and check it against the current user within an API request scope. Also, instead of throwing an exception, we simply log the fact of access denial.
builder.Services.AddEngarde(engarde =>
{
engarde.UseFeatureStore<IFeatureFlagProvider, IUserAuthService, User>(
(featureProvider, featureName, user) =>
featureProvider.IsEnabledAsync(featureName, user),
userAuthService => userAuthService.GetCurrentUserAsync());
engarde.OnFeatureMissing((featureName, context) =>
{
var logger = context.Services.GetRequiredService<ILogger<FeatureGuard>>());
logger.LogWarning(
"Feature {FeatureName} is unavailable for {Member}",
feature,
context.Member.Name);
});
});And also, we need to add it to the middleware chain so it can resolve the dependencies inside each individual API request scope:
var app = builder.Build();
...
app.UseHttpsRedirection();
app.UseEngarde();
app.MapControllers();That’s it! You can now use the [Engarde] attribute anywhere in your app as well as declare the IEngarde parameter inside any registered service’s constructor to perform inline checks.
Custom guards
If you want to have more than one feature toggle provider or an alternative scope resolution or handle failures differently for some specific functionality, then here is an example of how you can achieve this:
public partial class PizzaCourierPermissionGuard(params string[] featureNames)
: EngardeAttribute<IContractsStore, VendorDb, Courier>(featureNames)
{
protected override ValueTask<bool> IsEnabledAsync(
IContractsStore contracts,
string featureName,
Courier courier,
CancellationToken cancellationToken) =>
contracts.IsTermPresentAsync(featureName, courier, cancellationToken);
protected override ValueTask<Courrier> GetScopeIdAsync(
VendorDb vendorDb,
CancellationToken cancellationToken) =>
vendorDb.GetCurrentCourierAsync(cancellationToken);
protected override void OnFeatureMissing(
string featureName,
EngardeContext context)
{
Console.WriteLine(
$"Feature '{featureName}' is missing for member '{context.Member.Name}'.");
}
}I would like to mention this once more: as soon as you write a class like this, it becomes available as a [PizzaCourierPermissionGuard] attribute as well as a IPizzaCourierPermissionGuard interface to inject into and call it inline.
Failures setup
If you don’t want the failure behavior to run when guards are called inline or when there is a fallback method/type provided, then you can either add proper attributes to your guard:
[SkipInlineFailures]
[SkipFallbackFailures]
public partial class AppSettingsGuard(params string[] featureNames)
: EngardeAttribute(featureNames)
or set the properties inside the global configuration:
builder.Services.AddEngarde(engarde =>
{
engarde.SkipInlineFailures = true;
engarde.SkipFallbackFailures = true;
...
});Special configurations
Some apps don’t have HTTP request middleware, or maybe they use another DI provider — for example, Autofac. Let’s see how you can configure such projects.
var services = new ServiceCollection();
services.AddSingleton(ConfigurationManager.AppSettings);
services.AddEngarde(engarde => engarde
.UseFeatureStore<NameValueCollection>((settings, feature) =>
new ValueTask<bool>(
bool.TryParse(settings[feature], out var enabled) && enabled))
.OnFeatureMissing((feature, _) =>
throw new InvalidOperationException(
$"Feature '{feature}' is disabled.")));
autofacBuilder.Populate(services);
var container = autofacBuilder.Build();
var serviceProvider = new AutofacServiceProvider(container);
// Manual activation is required without middleware
Engarde.Engarde.Activate(serviceProvider);And even if you have no DI in your app, you can configure guards the following way:
public partial class AppSettingsGuard(params string[] featureNames)
: EngardeAttribute(featureNames)
{
protected override ValueTask<bool> IsEnabledAsync(
string featureName,
EngardeContext context,
CancellationToken cancellationToken)
{
var enabled =
bool.TryParse(
ConfigurationManager.AppSettings[featureName],
out var result) &&
result;
return new ValueTask<bool>(enabled);
}
protected override void OnFeatureMissing(
string featureName,
EngardeContext context)
{
throw new InvalidOperationException(
$"Feature '{featureName}' is disabled.");
}
}Then, if you need to perform an inline feature check, do the following:
var enabled = await AppSettingsGuard
.CreateInstance()
.IsFeatureEnabledAsync("pizza-with-no-dough")What’s next
Tests
At this point, you probably wonder what will happen inside unit tests? What if our guard relies on HTTP request middleware, while there is nothing like it (and shouldn’t be) in the tests?
Here is the answer: you need to install a dedicated package that will toggle evaluation per class hierarchy or test method. First, install the En-Garde.Testing Nuget package into your test assembly:
dotnet add package En-Garde.TestingThen you simply decorate test classes or individual methods:
[EnableAllEngarde()]
public abstract class UnitTestBase;
[DisableAllEngarde("new-pizzas")]
public sealed class NewExperienceTests : UnitTestBase
{
[DisablePineappleGuard]
[Fact]
public Task GetPineapplePizza_IngredientsMatch()
{
...
}
[EnableAllEngarde("new-pizzas")]
[Fact]
public Task GetNewPizza_AccessFailure()
{
...
}
}How it works: ‘Disable’-prefixed decorators force all matching guards to pass, skipping their normal implementation. On the contrary, ‘Enable’-prefixed ones make all matching guards fail. That means that with [DisableAllEngarde(“new-pizzas”)] applied to the test class, any method within (and anything recursively inside), using guards to test the ‘new-pizzas’ feature will deliberately pass the access check. If neither of the attributes was applied, then guards will act normally and try to use their configurations which will usually fail unless setup specifically within your tests.
In the example above, [DisableAllEngarde] and [EnableAllEngarde] are default attributes always present regardless of your custom guards configuration. They accept an optional list of feature names to handle. The [DisableReportsGuard] attribute is generated from the custom ReportsGuard class. Whether it accepts a feature name depends on whether the guard itself is feature-bound.
And here is some good news: if you happen to have API tests that borrow your application startup logic (like the WebApplicationFactory), then your guards don’t need any extra config for tests at all!
Conclusion
After all, it could be that feature toggling is not something you are currently interested in. But there are other applications of En-Garde which you can consider:
- Rate limiter.
- Logger.
- Authorization filter.
It wouldn’t be fair to say that En-Garde is the only tool that offers this functionality: there are a few other established libraries, each having its pros and cons. One of the major advantages of En-Garde is that it offers more conveniences compared to its alternatives, plus it is a Swiss knife in a way.
Finally, I would like to mention that the package is regularly updated, and if you ever need something that is not there — you are always welcome to fork and contribute:





