Rafal Bielec
 
 

Feature switches / feature flags in .NET

Microsoft introduced some new functionalities to support feature switches / feature flags in .NET. I took some time to check it out and I also looked at the OpenFeature library.


Published on 15/12/2024. 7 min read.
Feature flags in .NET

Table of contents

Introduction

In this article, I will discuss why using feature flags can be a useful idea. I’ll demonstrate how to implement a simple feature flag system in C#, and I’ll show different approaches to using flags in .NET.

General theory and my personal experience

Feature switches or otherwise called feature flags are conditional statements in your code which allow us to release new features to production at a specific point in time and to a specific set of users. This approch allows us to perform regression or smoke testing without the new feature in operation yet, and we can also later perform testing with the feature on using a simple mechanism which doesn’t require redeployment. We can release a solution to production with a new feature switched off until we are ready to launch it which may not be the same time that we’ve deployed it.

I have a very pleasant experience with using feature switches in .NET from the time when I worked in Manchester, UK. We developed a separate API which provided an endpoint informing other services which flags are switched on. If we wanted to release a feature, it was a matter of changing the settings in the API by a simple PUT request, and the flag was on. We didn’t have to redeploy, the feature was already waiting on production for us to show it to the public. We sometimes also did A/B testing so only a given percentage of users saw the new feature.

The only issue with feature flags is that the database underneath the solution may not be fully compatible with parts of the code suddenly expecting new table columns or data formats so this must be coordinated via database migrations too. I think this can be resolved by having an intermediate data storage API layer in-between services so the data API provides appropriate endpoints for mulitple feature scenarios. We can later clean up the feature flag C# code, and remove redundant API endpoints, but only after we’re absolutely sure that we won’t have go back on the feature because of some critical error which magically has gone past QA. Automated database migrations would then affect only the data storage API which can mock or transform some missing legacy database structures required by the legacy code.

Flags configured in .csproj

I have prepared a repository on GitHub to demonstrate this approach. I’ve added two flags to the .csproj file and then I checked in C# which one is active. This approach is based on the [FeatureSwitchDefintion] attribute provided by Microsoft. It allows the compiler to “clean up” dead if code branches during compilation. This is not a flexible approach like we’d have with an API, but it’s worth considering with some scenarios where redreployment is not an issue for a smaller solution.

I checked the IL code after compilation and I’ve discovered that IL code trimming works for me only after I do ‘dotnet publish -c Release’ and not when I simply recompile the project with the flag set to false in the .csproj file.

This is what it looks like in the .csproj file. Trim=“true” means that the compiler is allowed to trim dead code branches.

<ItemGroup>
    <RuntimeHostConfigurationOption Include="FeatureOne" Value="true" Trim="true" />
    <RuntimeHostConfigurationOption Include="FeatureTwo" Value="false" Trim="true" />
</ItemGroup>

Here’s some exammple code which I used in the GitHub repository for this article.

internal static class FeaturesFromCsProj
{
    [FeatureSwitchDefinition(FeatureNames.FeatureOne)]
    internal static bool One => AppContext.TryGetSwitch(FeatureNames.FeatureOne, out bool isEnabled) && isEnabled;

    [FeatureSwitchDefinition(FeatureNames.FeatureTwo)]
    internal static bool Two => AppContext.TryGetSwitch(FeatureNames.FeatureTwo, out bool isEnabled) && isEnabled;
// ...
}
// This will work if it's set to true in the .csproj file
if (FeaturesFromCsProj.Two)
{
    FeaturesFromCsProj.AddFeatureTwoEndpoint(app);
    FeaturesFromCsProj.TrimmedIfNotUsed();
}

Here’s an example provided by Microsoft. The Implementaton method should be trimmed if IsSupported is set to false.

if (Feature.IsSupported)
    Feature.Implementation();

public class Feature
{
    [FeatureSwitchDefinition("Feature.IsSupported")]
    internal static bool IsSupported => AppContext.TryGetSwitch("Feature.IsSupported", out bool isEnabled) ? isEnabled : true;

    internal static Implementation() => ...;
}

I checked how this code trimming process works in .NET 9 with an IL disassembler. The IL code still contains the AddFeatureTwoEndpoint and the TrimmedIfNotUsed method definitions after Release compilation. I don’t think anything got trimmed. Maybe I am missing some additional settings in the .csproj file but I think I used appropriate options like for example <IsTrimmable>true</IsTrimmable>.

.method hidebysig static default void AddFeatureTwoEndpoint(class [Microsoft.AspNetCore]Microsoft.AspNetCore.Builder.WebApplication app) cil managed
 {
    .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 01 00 00 ) // .....
    // Method begins at Relative Virtual Address (RVA) 0x286F
    // Code size 44 (0x2C)
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: ldstr "/api/second"
    IL_0006: ldsfld class [System.Runtime]System.Func`1<Microsoft.AspNetCore.Http.IResult> FeatureSwitches.FeaturesFromCsProj/<>c::'<>9__4_0'
    IL_000b: dup
    IL_000c: brtrue.s     IL_0025
    IL_000e: pop
    IL_000f: ldsfld class FeatureSwitches.FeaturesFromCsProj/<>c FeatureSwitches.FeaturesFromCsProj/<>c::'<>9'
    IL_0014: ldftn instance [Microsoft.AspNetCore.Http.Abstractions]Microsoft.AspNetCore.Http.IResult class FeatureSwitches.FeaturesFromCsProj/<>c::<AddFeatureTwoEndpoint>b__4_0()
    IL_001a: newobj instance void class [System.Runtime]System.Func`1<Microsoft.AspNetCore.Http.IResult>::.ctor([System.Runtime]System.Object, [System.Runtime]System.IntPtr)
    IL_001f: dup
    IL_0020: stsfld class [System.Runtime]System.Func`1<Microsoft.AspNetCore.Http.IResult> FeatureSwitches.FeaturesFromCsProj/<>c::'<>9__4_0'
    IL_0025: call [Microsoft.AspNetCore.Routing]Microsoft.AspNetCore.Builder.RouteHandlerBuilder class Microsoft.AspNetCore.Http.Generated.<GeneratedRouteBuilderExtensions_g>F31536C3CAE78740147874E041E0E3287F43D905B00F9A3CC96FDEC6E16221356__GeneratedRouteBuilderExtensionsCore::MapGet0([Microsoft.AspNetCore.Routing]Microsoft.AspNetCore.Routing.IEndpointRouteBuilder, string, [System.Runtime]System.Delegate)
    IL_002a: pop
    IL_002b: ret
 } // End of method System.Void FeatureSwitches.FeaturesFromCsProj::AddFeatureTwoEndpoint(Microsoft.AspNetCore.Builder.WebApplication)

 .method hidebysig static default void TrimmedIfNotUsed() cil managed
 {
    // Method begins at Relative Virtual Address (RVA) 0x289C
    // Code size 11 (0xB)
    .maxstack 8
    IL_0000: ldstr "Feature on"
    IL_0005: call void class [System.Console]System.Console::WriteLine(string)
    IL_000a: ret
 } // End of method System.Void FeatureSwitches.FeaturesFromCsProj::TrimmedIfNotUsed()

I tried publishing the project with both of the flags set to false. I didn’t find the method names in the IL. I only found the “FeatureFromManager” flag string which I used with the FeatureManager, but not the “FeatureOne” or “FeatureTwo” strings in the IL code either. I guess it works once you’ve published your solution. I think it would be a good idea to explore IL code trimming in my spare time but it’s outside of the scope of this article for now.

Frankly, I’ve never come across a situation where the size of the assemblies might be a problem, and I think this might be more for the AOT native compilation or some embedded stuff.

IL_0010: ldstr "FeatureFromManager"

Flags configured using the FeatureManager

Another approach is to use the Microsoft.FeatureManagement NuGet package provided by Microsoft. This approach is based on including feature flags in the appsetting.config file using JSON. I implemented my own InMemoryFeatureProvider class, and I set a test flag in code. Implementing my own feature provider allowed me to confirm that we could write our own API client in C# without relying on the appsettings.json file if we didn’t want to.

// Inject the feature manager to set a flag
app.MapGet("/api/fourth", async (IFeatureManager fm) =>
{
    var enabled = await fm.IsEnabledAsync(FeatureNames.FeatureFromManager);
    return Results.Ok($"Feature flag enabled: {enabled}");
});

OpenFeature.dev

I’ve also come across the OpenFeature website which is an indepedent project helping developers with feature flags. It’s been written not only for .NET but it has other SDKs too. The OpenFeature approach seems more straight-forward with in-memory provider already included in their solution.

 await Api.Instance.SetProviderAsync(new InMemoryProvider());

I’ve coded my own API provider for OpenFeature using Refit and it took only few lines of code to make it work. My example GitHub repository contains two projects: the OpenFeatureApp web client and the OpenFeatureProvider API. The first project connects to the second one to check if the flag is on. The OpenFeatureApp client project implements the ApiProvider class which is responsible for connecting out to the OpenFeatureProvider API.

public sealed class ApiProvider(string baseUri) : FeatureProvider
{
    private const string Name = "API Feature Provider";

    public interface IFlagsApi
    {
        [Get("/flags/bool/{name}")]
        Task<bool> GetBoolAsync(string name);
    }

    private readonly IFlagsApi _api = RestService.For<IFlagsApi>(baseUri);
/// ...

If Refit cannot find the API because it’s down, then it simply returns false for the flag instead of throwing an Exception.

public override async Task<ResolutionDetails<bool>> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default)
{
    var value = await _api.GetBoolAsync(flagKey);
    return new ResolutionDetails<bool>(flagKey, value);
}

GitHub repository with examples

All the aforementioned code is located here: rafalbielec/feature-switches.

Here’s a summary of what I found and also some other solutions which you might want to check.

Conclusions

I think using feature flags for controlable releases and/or A/B testing is a great approach, but you have to plan it all ahead and have the appropriate project architecture for it. You don’t want to be surprised by releasing something to production only to realise that you need to immediately switch the flag on because the database structure has been migrated to the new feature already without accounting for the flag being off after the deployment.