Archiboard

Set C · Cloud & Operations

Feature flags with Azure App Configuration: ship dark, reveal per tenant

A flag is a switch you intend to remove. An entitlement is a promise you intend to keep. Mixing them is how a pricing rule ends up in a config store.

A new document rendering engine shipped to production in eleven releases across four months before any tenant saw it. Every release went through the full pipeline with the engine behind a flag that was off. In March 2022 we turned it on for three pilot tenants, one of which found within a day that the hyphenation broke on a twenty-eight character Dutch compound noun, which no test had covered. We fixed it, turned it on for a ring of thirty, then for everybody. Nobody outside the pilot ever knew there had been a change.

That is what flags are for: shipping dark and revealing on your terms. It is not what they are for when someone uses one to decide whether a tenant on the Starter plan gets the export feature, and I will spend a section on that distinction because it is the mistake I see most.

The library and the store

Microsoft.FeatureManagement reads flag definitions from configuration and evaluates them; Azure App Configuration stores them and pushes changes without a redeploy. The .NET 10 wiring is a few lines in Program.cs, with the provider refreshing every thirty seconds and feature management registered as scoped, because our filters need the tenant context and the tenant context is a scoped service. In 2022 the equivalent lived in Startup.ConfigureServices, used IFeatureManager, and could only register singleton filters, which is why the tenant filter of that era resolved its context through IHttpContextAccessor and worked nowhere except in a request.

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddAzureAppConfiguration(o =>
{
    o.Connect(new Uri(builder.Configuration["AppConfig:Endpoint"]!), new DefaultAzureCredential())
     .Select("App:*", builder.Environment.EnvironmentName)
     .UseFeatureFlags(f => f.SetRefreshInterval(TimeSpan.FromSeconds(30)));
});
builder.Services.AddAzureAppConfiguration();

builder.Services.AddScopedFeatureManagement()
    .WithTargeting<TenantTargetingContextAccessor>()
    .AddFeatureFilter<TenantFilter>();

var app = builder.Build();
app.UseAzureAppConfiguration();

The label is the environment name, so acceptance and production read different values for the same flag key. The store is one resource shared by all stamps; a flag that must differ per stamp gets the stamp name in its key, which has happened twice in three years.

Targeting, with the tenant as the user

The built-in targeting filter takes a user id, a list of groups and a default percentage, and decides deterministically by hashing the user id with the flag name, so the same user gets the same answer on every request. The trick that makes it useful for a B2B product is to feed it the tenant as the user. The accessor below reports the tenant id as UserId and the plan and the rollout ring as groups. From then on "enable for 25 percent of ring:canary" and "enable for tenant 4f2c" are both plain targeting rules edited in the portal, and the rollout is sticky per tenant rather than per person, which is what a customer expects: either their whole company has the new engine or none of it does.

public sealed class TenantTargetingContextAccessor(ITenantContext tenant) : ITargetingContextAccessor
{
    public ValueTask<TargetingContext> GetContextAsync() =>
        ValueTask.FromResult(new TargetingContext
        {
            UserId = tenant.Id,
            Groups = [$"plan:{tenant.Plan}", $"ring:{tenant.Ring}"]
        });
}

For the cases targeting does not cover, a custom filter is one class. Ours takes an explicit allowlist of tenant ids as a parameter, and we use it for the "these three pilots and nobody else" phase before a percentage rollout begins. Filters within a flag are combined with Any by default, so a flag can carry both a Tenant filter for the pilots and a Targeting filter for the ring, and the pilots stay on when the ring goes to zero.

[FilterAlias("Tenant")]
public sealed class TenantFilter(ITenantContext tenant) : IFeatureFilter
{
    public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
    {
        var allowed = context.Parameters.Get<string[]>("Tenants") ?? [];
        return Task.FromResult(allowed.Contains(tenant.Id, StringComparer.OrdinalIgnoreCase));
    }
}
Flag evaluation with the tenant context as input inputs tenant context id=4f2c plan=pro ring=canary flag definition Documents.NewEngine TimeWindow: after 1 Mar Targeting: 25% ring:canary Tenant: [4f2c, 9a11] IsEnabledAsync filters combined: Any hash(tenant, flag) is sticky refreshed every 30 s on: new engine off: old engine entitlements (plan) not a flag: lives in the tenant record and never expires
Fig. 1. The evaluator sees the tenant context and the flag definition and nothing else. Entitlements sit beside it, consulted by the code that needs them, and are not something a flag decides.

Flags are not entitlements

A flag is temporary and operational. It exists so that code can be in production before it is visible, and it is removed when the rollout is done. An entitlement is commercial and permanent: the Pro plan includes the export feature, the Starter plan does not, and that will still be true next year. The two look identical at the call site, one boolean before an if, and that is the trap.

A product I worked on had, for about a year, a flag called Export.Enabled with a targeting rule that included the group plan:pro. It worked. Then the export rollout finished, someone did the right thing and deleted the flag, and every Starter tenant got the export feature for a weekend. The pricing rule had been living in App Configuration, unversioned, unreviewed by anyone who owned pricing.

Now there is an IEntitlements service backed by the tenant record and the billing system, and the code that needs to know about plans asks it. A flag may gate whether the export feature exists in this build at all; it never decides who paid for it. When both apply, the check reads flag && entitled, in that order, and the day the flag is deleted the entitlement check is still there.

Flag hygiene and the removal ritual

In 2024 an audit of the store found 61 flags. Nine were in active rollout. The rest had been at 100 percent for between three months and two years, and four of them guarded code paths that no longer compiled to anything different. Every one of them was a branch in someone's head that did not need to be there.

The rules we adopted are few. A flag's description in App Configuration carries an owner and a remove-by date, and the pipeline lints new flags for both. A nightly job lists flags whose targeting is at 100 percent with no exclusions, and posts the ones older than thirty days to the team channel with the owner's name. The pull request that introduces a flag links to the issue that will remove it. And flag names are namespaced by feature, never by ticket number, because PLT-1432 tells you nothing in a year and Documents.NewEngine still will.

The trade-off I would make today

I would keep flags and entitlements in different places on purpose, even though it means two checks where one would do, because the two things change for different reasons and are owned by different people. I would use the built-in targeting filter with the tenant as the user before writing a custom filter, and write the custom filter only for explicit allowlists. And I would accept the nagging nightly job, because the alternative is 61 flags and a weekend of free exports.

Drawn from