DEV Community

Cover image for Feature Flags in .NET 8: ASP.NET Core, Minimal APIs, Blazor
Domenico Giordano
Domenico Giordano

Posted on • Originally published at rollgate.io

Feature Flags in .NET 8: ASP.NET Core, Minimal APIs, Blazor

This post was originally published on rollgate.io/blog/feature-flags-aspnet-core.

Every .NET team eventually hits the same wall: you have a feature ready in staging, but pushing it to production means flipping a switch for every user at once. If something breaks — wrong assumption, edge case, performance regression — your only recovery is a rollback and redeploy.

Feature flags solve this by separating deployment from release. You ship the code behind a flag, then control who sees it from a dashboard, without touching your pipeline. Start with 1% of users, watch your metrics, expand to 100%. If errors spike at any stage, disable the flag in seconds.

Quick Start: Feature Flags in .NET 8

Install the Rollgate SDK from NuGet:

dotnet add package Rollgate.SDK
Enter fullscreen mode Exit fullscreen mode

Initialize the client at application startup:

using Rollgate.SDK;

var client = new RollgateClient(new RollgateConfig
{
    ApiKey = Environment.GetEnvironmentVariable("ROLLGATE_API_KEY") ?? "",
});

await client.InitializeAsync();

if (client.IsEnabled("new-checkout", false))
{
    Console.WriteLine("New checkout enabled");
}

client.Dispose();
Enter fullscreen mode Exit fullscreen mode

After InitializeAsync(), every IsEnabled call reads from an in-memory dictionary — single-digit microsecond overhead.

Registering with Dependency Injection

In ASP.NET Core, register the client as a singleton plus a small IFeatureFlags abstraction so controllers stay testable:

// Program.cs
builder.Services.AddSingleton<RollgateClient>(sp =>
{
    var client = new RollgateClient(new RollgateConfig
    {
        ApiKey = builder.Configuration["Rollgate:ApiKey"] ?? "",
        RefreshInterval = TimeSpan.FromSeconds(30),
    });
    // Tutorial simplicity. In production, prefer IHostedService.
    client.InitializeAsync().GetAwaiter().GetResult();
    return client;
});

builder.Services.AddSingleton<IFeatureFlags, RollgateFeatureFlags>();
Enter fullscreen mode Exit fullscreen mode
public interface IFeatureFlags
{
    bool IsEnabled(string flagKey, bool defaultValue = false);
}

public sealed class RollgateFeatureFlags : IFeatureFlags
{
    private readonly RollgateClient _client;
    public RollgateFeatureFlags(RollgateClient client) => _client = client;
    public bool IsEnabled(string key, bool def = false) => _client.IsEnabled(key, def);
}
Enter fullscreen mode Exit fullscreen mode

Feature Flags in ASP.NET Core Controllers

Inject IFeatureFlags, not the SDK type directly:

[ApiController]
[Route("api/[controller]")]
public class CheckoutController : ControllerBase
{
    private readonly IFeatureFlags _flags;

    public CheckoutController(IFeatureFlags flags) => _flags = flags;

    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] OrderRequest request)
    {
        return _flags.IsEnabled("checkout-v2", false)
            ? Ok(await ProcessV2Async(request))
            : Ok(await ProcessV1Async(request));
    }
}
Enter fullscreen mode Exit fullscreen mode

Identifying the user — once per session, NOT per request

RollgateClient.IdentifyAsync issues an HTTP request and triggers a flag refresh. Do not call it on every request — that adds a network round-trip per endpoint and kills the in-memory eval model.

The right place is an action filter that runs once per user, then short-circuits:

public class FeatureFlagIdentityFilter : IAsyncActionFilter
{
    private readonly RollgateClient _client;
    private static readonly HashSet<string> _identified = new();
    private static readonly SemaphoreSlim _gate = new(1, 1);

    public FeatureFlagIdentityFilter(RollgateClient client) => _client = client;

    public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
    {
        var userId = ctx.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (!string.IsNullOrEmpty(userId) && !_identified.Contains(userId))
        {
            await _gate.WaitAsync();
            try
            {
                if (!_identified.Contains(userId))
                {
                    await _client.IdentifyAsync(new UserContext { Id = userId });
                    _identified.Add(userId);
                }
            }
            finally { _gate.Release(); }
        }
        await next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Cleaner for production: call IdentifyAsync once at login, never again until logout.

Feature Flags in Minimal APIs

app.MapPost("/api/search", async (SearchRequest req, IFeatureFlags flags) =>
{
    return flags.IsEnabled("semantic-search", false)
        ? Results.Ok(await RunSemanticSearchAsync(req.Query))
        : Results.Ok(await RunKeywordSearchAsync(req.Query));
});
Enter fullscreen mode Exit fullscreen mode

Gate an entire route via endpoint filter, resolving IFeatureFlags from request services:

public static class FeatureFlagEndpointExtensions
{
    public static TBuilder RequireFeature<TBuilder>(this TBuilder builder, string flagKey)
        where TBuilder : IEndpointConventionBuilder
    {
        return builder.AddEndpointFilter(async (context, next) =>
        {
            var flags = context.HttpContext.RequestServices.GetRequiredService<IFeatureFlags>();
            if (!flags.IsEnabled(flagKey, false)) return Results.NotFound();
            return await next(context);
        });
    }
}

app.MapGet("/api/v2/analytics", GetAnalyticsV2Handler)
   .RequireFeature("analytics-v2")
   .RequireAuthorization();
Enter fullscreen mode Exit fullscreen mode

Feature Flags in Blazor Server

@page "/checkout"
@inject IFeatureFlags Flags
@inject AuthenticationStateProvider AuthStateProvider

@if (_showNewCheckout) { <NewCheckoutFlow /> } else { <LegacyCheckoutFlow /> }

@code {
    private bool _showNewCheckout;

    protected override void OnInitialized()
    {
        // Runs once per circuit, not per render.
        _showNewCheckout = Flags.IsEnabled("checkout-v2", false);
    }
}
Enter fullscreen mode Exit fullscreen mode

For Blazor WebAssembly, fetch flags from your server in Program.cs before RunAsync():

var host = builder.Build();
await host.Services.GetRequiredService<FlagService>().LoadAsync();
await host.RunAsync();
Enter fullscreen mode Exit fullscreen mode

Testing Feature Flags in C

Since controllers depend on IFeatureFlags, no mocking framework needed:

public class FakeFeatureFlags : IFeatureFlags
{
    private readonly Dictionary<string, bool> _flags;
    public FakeFeatureFlags(Dictionary<string, bool>? f = null) => _flags = f ?? new();
    public bool IsEnabled(string key, bool def = false)
        => _flags.TryGetValue(key, out var v) ? v : def;
}

public class CheckoutControllerTests
{
    [Fact]
    public async Task Returns_V2_When_Flag_Enabled()
    {
        var flags = new FakeFeatureFlags(new() { ["checkout-v2"] = true });
        var controller = new CheckoutController(flags);
        var result = await controller.CreateOrder(new OrderRequest { Amount = 99 });
        // assert v2 path
    }
}
Enter fullscreen mode Exit fullscreen mode

Always test both flag states.

Gradual Rollouts and User Targeting

Pass user attributes when you identify (once per session):

await _client.IdentifyAsync(new UserContext
{
    Id = userId,
    Email = userEmail,
    Attributes = new Dictionary<string, object?>
    {
        ["plan"] = user.SubscriptionPlan,
        ["country"] = user.Country,
    }
});
Enter fullscreen mode Exit fullscreen mode

Configure percentage rollouts and attribute targeting in the dashboard — the SDK evaluates all rules locally, no per-evaluation API call.

FAQ

Which .NET feature flag approach in 2026?

  • Simple toggles with no runtime changes → Microsoft.FeatureManagement
  • CNCF vendor-neutral SDK → OpenFeature .NET
  • Dashboard + targeting + gradual rollout → managed service (Rollgate, LaunchDarkly, etc.)

Does it work with .NET background services?
Yes. Register RollgateClient as singleton, inject into BackgroundService, use IsEnabled in ExecuteAsync.

What if InitializeAsync fails at startup?
If no cache exists, it throws. Either catch and proceed with defaults, or let it surface (fail-fast is usually safer).


Read the full version — including circuit breaker config, SSE streaming for kill switches, and Blazor WebAssembly patterns — on rollgate.io/blog/feature-flags-aspnet-core.

Create a free Rollgate account at https://app.rollgate.io/register — no credit card required.

Top comments (0)