.NET & C# Development · Lesson 53 of 92

Feature Flags — Ship Code Without Switching It On

What Feature Flags Solve

Merging to main and deploying to production are two different things. Feature flags let you do both continuously while keeping unreleased code dark.

Use cases:

  • Deploy dark — ship code to production but don't turn it on yet
  • Gradual rollout — enable for 5% of users, then 25%, then 100%
  • Kill switch — disable a broken feature without deploying a hotfix
  • A/B testing — different behaviour for different user segments
  • Time-gated releases — enable automatically at launch time

Install:

Bash
dotnet add package Microsoft.FeatureManagement.AspNetCore

Setup

C#
// Program.cs
builder.Services.AddFeatureManagement();

Define flags in appsettings.json:

JSON
{
  "FeatureManagement": {
    "NewCheckout": true,
    "BetaDashboard": false,
    "RecommendationsEngine": true
  }
}

That's it for a basic on/off flag. The flag name is just a string key — define them as constants to avoid typos:

C#
public static class FeatureFlags
{
    public const string NewCheckout          = "NewCheckout";
    public const string BetaDashboard        = "BetaDashboard";
    public const string RecommendationsEngine = "RecommendationsEngine";
}

IFeatureManager in Services

C#
public class CheckoutService(IFeatureManager features, ICheckoutRepository repo)
{
    public async Task<CheckoutResult> CheckoutAsync(Cart cart, CancellationToken ct)
    {
        if (await features.IsEnabledAsync(FeatureFlags.NewCheckout))
        {
            return await NewCheckoutFlowAsync(cart, ct);
        }

        return await LegacyCheckoutFlowAsync(cart, ct);
    }

    private Task<CheckoutResult> NewCheckoutFlowAsync(Cart cart, CancellationToken ct) => ...;
    private Task<CheckoutResult> LegacyCheckoutFlowAsync(Cart cart, CancellationToken ct) => ...;
}

[FeatureGate] on Controllers and Actions

Gate entire controllers or individual endpoints declaratively:

C#
// Entire controller is gated — returns 404 when flag is off
[FeatureGate(FeatureFlags.BetaDashboard)]
[ApiController]
[Route("api/dashboard")]
public class BetaDashboardController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new { dashboard = "beta" });
}

// Individual action gated — rest of controller still works
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new { source = "standard" });

    [HttpGet("recommendations")]
    [FeatureGate(FeatureFlags.RecommendationsEngine)]
    public IActionResult GetRecommendations() => Ok(new { items = new[] { "Item A", "Item B" } });
}

When a [FeatureGate] flag is disabled, the default behaviour is a 404 response. Customise this by implementing IDisabledFeaturesHandler:

C#
public class ForbiddenWhenDisabledHandler : IDisabledFeaturesHandler
{
    public Task HandleDisabledFeatures(IEnumerable<string> features, ActionExecutingContext context)
    {
        context.Result = new ObjectResult("Feature not available") { StatusCode = 403 };
        return Task.CompletedTask;
    }
}

// Register it
builder.Services.AddFeatureManagement()
    .UseDisabledFeaturesHandler(new ForbiddenWhenDisabledHandler());

TimeWindow Filter

Enable a flag only during a specific window — for scheduled launches or maintenance:

JSON
{
  "FeatureManagement": {
    "BlackFridaySale": {
      "EnabledFor": [
        {
          "Name": "TimeWindow",
          "Parameters": {
            "Start": "2026-11-27T00:00:00+00:00",
            "End":   "2026-11-28T00:00:00+00:00"
          }
        }
      ]
    }
  }
}

Register the built-in filters:

C#
builder.Services.AddFeatureManagement()
    .AddFeatureFilter<TimeWindowFilter>()
    .AddFeatureFilter<PercentageFilter>();

Percentage Rollout

Enable for a random percentage of requests — useful for canary releases:

JSON
{
  "FeatureManagement": {
    "NewSearchAlgorithm": {
      "EnabledFor": [
        {
          "Name": "Percentage",
          "Parameters": {
            "Value": 10
          }
        }
      ]
    }
  }
}

Value: 10 enables the flag for approximately 10% of calls. This is stateless — the same user may get different results on consecutive requests. For sticky assignment (same user always sees the same state) you need a custom IContextualFeatureFilter.

Azure App Configuration for Remote Management

Hardcoded appsettings.json flags require a redeployment to change. Azure App Configuration lets you toggle flags at runtime from a portal UI.

Bash
dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore
dotnet add package Microsoft.FeatureManagement.AspNetCore
C#
// Program.cs
builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfig:ConnectionString"])
           .UseFeatureFlags(ff =>
           {
               ff.CacheExpirationInterval = TimeSpan.FromSeconds(30); // poll every 30s
           });
});

builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();

// Middleware to refresh config on each request
app.UseAzureAppConfiguration();

With UseAzureAppConfiguration() middleware in place, flag changes in the Azure portal propagate to your running app within the cache expiration window — no deployment needed.

Cleaning Up Old Flags

Feature flags are technical debt if left permanently. When a flag is 100% rolled out and stable:

  1. Remove all IsEnabledAsync / [FeatureGate] checks from code.
  2. Delete the dead code path (old implementation).
  3. Remove the flag from appsettings.json and Azure App Configuration.
  4. Delete the constant from FeatureFlags.

Track flag lifecycle in your ticket system. A flag that has been at 100% for more than a sprint is ready to be cleaned up.