Feature Flags: Decoupling Deploy from Release with Microsoft.FeatureManagement and OpenFeature
A feature flag lets you deploy code and release behavior with a config change. Targeting with Microsoft.FeatureManagement 4.8 (10.1% of 1000 users, sticky per user), variants, WithFeatureGate on minimal APIs and a kill switch that takes effect in 0.4 s; a signal-based flag service and a tested structural directive in Angular; the targeting boolean flag returning false through OpenFeature's preview provider, and flag debt.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), Microsoft.FeatureManagement.AspNetCore 4.8.0, OpenFeature 2.14.1, OpenFeature.Contrib.Provider.FeatureManagement 0.1.2-preview and Angular 22.2 (Vitest 5.1).
Guess First
You want to open the "one-click checkout" feature first to Ada only, then to the beta group, then to 10% of everyone. You wrote the flag with DefaultRolloutPercentage: 10. If the same user refreshes the page ten times, how many times do they see the feature? Is the dice rolled every time, or does the user always stay on the same side?
Second question: the recommendation service slowed down in production and drags page loads with it. You turned the flag off in config. Without restarting the app, how many seconds until the change takes effect?
Decoupling Deploy from Release
In the first post of the series we separated deploy from release. Blue-green and canary make that separation at the traffic level: the code is ready, when and how much will users see it? A feature flag makes the same separation at the code level. The code for the new behavior goes to production but waits behind a condition. The release is that condition changing, which is a config change. You can deploy the code days ahead and turn the feature on Monday morning together with the marketing team. If something breaks, going back isn't a new deploy but reverting the same config.
A side effect is that it makes trunk-based development possible: an unfinished feature can be merged to the main branch and deployed as long as its flag is off. The merge pain of long-lived feature branches goes away.
Flag Types and Lifetimes
Pete Hodgson's "Feature Toggles (aka Feature Flags)" article on Martin Fowler's site classifies flags along two axes: how long they live and how dynamic the decision is. Four types come out:
| Type | Purpose | Lifetime | Decision |
|---|---|---|---|
| Release toggle | Hiding an unfinished or not-yet-launched feature | Days, a few weeks at most | Usually the same for everyone, per user for gradual rollout |
| Experiment toggle | A/B testing, measuring two behaviors | The experiment's duration, hours to weeks | Per user, consistent |
| Ops toggle (kill switch) | Turning a feature off under load or during errors | Short or permanent | For everyone, must change instantly |
| Permission toggle | Opening a feature to certain users (premium, internal) | Long, years | Per user |
The distinction matters because each type carries different debt. A release toggle must be deleted once the feature is open to everyone; a forgotten release toggle lives on for years as an if in the code that nobody understands. An ops toggle is the opposite: it has to stay, but it must be exercised regularly to be sure it works at the worst moment. A permission toggle is really an authorization rule and should move into a proper authorization mechanism over time.
Flags with Microsoft.FeatureManagement 4.x
Microsoft.FeatureManagement reads flags from .NET configuration, so flags can live in appsettings.json, environment variables or Azure App Configuration. Version 4.x uses Microsoft's common feature_management schema:
{
"feature_management": {
"feature_flags": [
{
"id": "NewCheckout",
"enabled": true,
"conditions": {
"client_filters": [
{
"name": "Microsoft.Targeting",
"parameters": {
"Audience": {
"Users": [ "ada" ],
"Groups": [ { "Name": "beta", "RolloutPercentage": 100 } ],
"DefaultRolloutPercentage": 10
}
}
}
]
}
},
{
"id": "Recommendations",
"enabled": true
},
{
"id": "BulkImport",
"enabled": false
},
{
"id": "CheckoutButton",
"enabled": true,
"variants": [
{ "name": "Blue", "configuration_value": "#1f6feb" },
{ "name": "Green", "configuration_value": "#2da44e" }
],
"allocation": {
"seed": "checkout-button-2026-11",
"percentile": [
{ "variant": "Blue", "from": 0, "to": 50 },
{ "variant": "Green", "from": 50, "to": 100 }
]
}
}
]
}
}
The four flags represent the four types: NewCheckout is a release toggle with targeting, Recommendations a kill switch, BulkImport a disabled permission toggle and CheckoutButton an experiment with two variants. The targeting filter needs to know who the user is. The default accessor takes that from HttpContext.User; to keep the example testable with curl I wrote a small accessor that reads headers:
public sealed class HeaderTargetingContextAccessor(IHttpContextAccessor httpContextAccessor) : ITargetingContextAccessor
{
public ValueTask<TargetingContext> GetContextAsync()
{
HttpRequest request = httpContextAccessor.HttpContext!.Request;
string userId = request.Headers["X-User-Id"].ToString();
string[] groups = request.Headers["X-User-Groups"].ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return ValueTask.FromResult(new TargetingContext { UserId = userId, Groups = groups });
}
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddFeatureManagement()
.WithTargeting<HeaderTargetingContextAccessor>();
WebApplication app = builder.Build();
app.MapGet("/api/checkout", async (IVariantFeatureManager features, CancellationToken ct) =>
{
bool newCheckout = await features.IsEnabledAsync(FeatureFlags.NewCheckout, ct);
Variant button = await features.GetVariantAsync(FeatureFlags.CheckoutButton, ct);
return new
{
flow = newCheckout ? "new" : "classic",
button = button.Name,
color = button.Configuration?.Value,
};
});
app.MapGet("/api/books/{id:int}/recommendations", async (int id, IVariantFeatureManager features, CancellationToken ct) =>
{
if (!await features.IsEnabledAsync(FeatureFlags.Recommendations, ct))
{
return Results.Ok(Array.Empty<string>());
}
return Results.Ok(new[] { "Release It!", "Accelerate" });
});
app.MapPost("/api/admin/bulk-import", () => Results.Accepted())
.WithFeatureGate(FeatureFlags.BulkImport);
WithFeatureGate comes with 4.x for minimal APIs (Application Programming Interfaces); its MVC (Model-View-Controller) counterpart is the [FeatureGate] attribute. The results:
ada: {"flow":"new","button":"Green","color":"#2da44e"}
bob (beta): {"flow":"new","button":"Green","color":"#2da44e"}
carol: {"flow":"classic","button":"Green","color":"#2da44e"}
carol again: {"flow":"classic","button":"Green","color":"#2da44e"}
Ada sees the new flow because she's on the user list, Bob because he's in the beta group. Carol is in neither and falls outside the 10% slice, and both of her requests got the same answer. The targeting filter hashes the user id with the flag name and places the user in a fixed percentile; the dice isn't rolled on every request. That's the answer to the first opening question: the user always stays on the same side. The distribution across 1000 different users:
for u in $(seq 1 1000); do
curl -s -H "X-User-Id: user$u" localhost:18630/api/checkout; echo
done | sed 's/.*"flow":"\([a-z]*\)","button":"\([A-Za-z]*\)".*/\1 \2/' | sort | uniq -c
461 classic Blue
438 classic Green
48 new Blue
53 new Green
The new flow is on for 101 users (10.1%). The button variant split 509 blue to 491 green, roughly half and half. Variant assignment depends on the seed. Change the seed and users get reshuffled, so the seed must stay fixed for the duration of an experiment. The remaining two cases:
== bulk import
404
== anonymous
{"flow":"classic","button":"Blue","color":"#1f6feb"}
The endpoint behind the disabled flag returns 404; not even its existence is revealed. A request without an identity also lands on the off side of the flag. Deciding which experiment an anonymous user is in is your job: generating an anonymous id on the first visit and keeping it in a cookie is the most common way.
Kill Switch: How Fast Does It Turn Off?
An ops toggle is worth the time between turning it off and the moment it takes effect. While the app was running I set Recommendations to false in appsettings.json and polled the endpoint every 100 ms:
before: ["Release It!","Accelerate"]
edited at 06:51:59 1794109919.626985
kill switch effective after 0.4s
after: []
0.4 seconds. WebApplication.CreateBuilder loads appsettings.json with reloadOnChange, and FeatureManagement reads the current configuration on every evaluation. That's the answer to the second question for a single server. In production, though, changing the file on ten servers at once is a deploy of its own. That's why kill switches usually live in a central configuration source: Azure App Configuration, a database table or a flag service. Then the delay equals the provider's refresh interval, and you need to choose it consciously. In the Azure App Configuration provider that interval is set with the refresh options; a 30-second interval means 30 seconds of extra damage during an incident.
The Angular Side: A Signal-Based Flag Service
Rather than evaluating flags again in the frontend, it's safer to let the API evaluate them and return the result. Rules and user lists never reach the browser, and evaluation happens in one place:
app.MapGet("/api/features", async (IVariantFeatureManager features, CancellationToken ct) =>
{
Dictionary<string, bool> result = [];
foreach (string name in FeatureFlags.ExposedToClient)
{
result[name] = await features.IsEnabledAsync(name, ct);
}
return result;
});
{"NewCheckout":true,"Recommendations":true}
In Angular, a small service keeps that result in a signal:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export type FeatureName = 'NewCheckout' | 'Recommendations';
@Injectable({ providedIn: 'root' })
export class FeatureFlags {
private readonly http = inject(HttpClient);
private readonly flags = signal<Partial<Record<FeatureName, boolean>>>({});
async load(): Promise<void> {
try {
this.flags.set(await firstValueFrom(this.http.get<Record<FeatureName, boolean>>('/api/features')));
} catch {
// if the API can't be reached, every flag counts as off (fail closed)
this.flags.set({});
}
}
isEnabled(name: FeatureName): boolean {
return this.flags()[name] ?? false;
}
}
It's loaded once when the app starts:
provideHttpClient(),
provideAppInitializer(() => inject(FeatureFlags).load()),
A structural directive shows and hides content in templates based on a flag:
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';
import { FeatureFlags, FeatureName } from './feature-flags';
@Directive({ selector: '[appFeature]' })
export class FeatureDirective {
private readonly flags = inject(FeatureFlags);
private readonly template = inject(TemplateRef<unknown>);
private readonly viewContainer = inject(ViewContainerRef);
readonly appFeature = input.required<FeatureName>();
readonly appFeatureElse = input<TemplateRef<unknown> | null>(null);
constructor() {
effect(() => {
const enabled = this.flags.isEnabled(this.appFeature());
const template = enabled ? this.template : this.appFeatureElse();
this.viewContainer.clear();
if (template) {
this.viewContainer.createEmbeddedView(template);
}
});
}
}
@Component({
selector: 'app-book-list',
imports: [FeatureDirective],
template: `
<h1>Books</h1>
<button *appFeature="'NewCheckout'; else classicCheckout">One-click checkout</button>
<ng-template #classicCheckout><a href="/cart">Go to cart</a></ng-template>
`,
})
export class BookList {}
effect redraws the template whenever the flag signal changes. If flags are reloaded later, the screen updates by itself. I tested this with Vitest:
class FakeFeatureFlags {
readonly state = signal<Partial<Record<FeatureName, boolean>>>({});
isEnabled(name: FeatureName): boolean {
return this.state()[name] ?? false;
}
}
@Component({
imports: [FeatureDirective],
template: `
<p *appFeature="'NewCheckout'; else classic">new checkout</p>
<ng-template #classic><p>classic checkout</p></ng-template>
`,
})
class Host {}
describe('FeatureDirective', () => {
it('renders the else template until the flag is turned on', async () => {
const flags = new FakeFeatureFlags();
TestBed.configureTestingModule({ providers: [{ provide: FeatureFlags, useValue: flags }] });
const fixture = TestBed.createComponent(Host);
await fixture.whenStable();
expect(fixture.nativeElement.textContent.trim()).toBe('classic checkout');
flags.state.set({ NewCheckout: true });
await fixture.whenStable();
expect(fixture.nativeElement.textContent.trim()).toBe('new checkout');
});
});
✓ bookstore-web src/app/features/feature.directive.spec.ts (1 test) 33ms
Test Files 1 passed (1)
Tests 1 passed (1)
A note: hiding a flag in the frontend isn't security. Even if the button isn't visible, the endpoint can be called. That's why flags like BulkImport are also protected on the server with WithFeatureGate.
OpenFeature: A Vendor-Neutral API
Once you decide to read flags from a SaaS (Software as a Service) product, Azure App Configuration or your own database, every IsEnabledAsync call in the code becomes part of that decision. OpenFeature, a CNCF (Cloud Native Computing Foundation) project, binds application code to a single API and hides the actual source behind a "provider". When the provider changes, the application code doesn't. The .NET SDK is at 2.x and there's a provider for Microsoft.FeatureManagement. While planning we noted that this provider was a prerelease; at the time of writing it's still 0.1.2-preview on NuGet. I read the same flags through it:
await Api.Instance.SetProviderAsync(new FeatureManagementProvider(builder.Configuration));
app.MapGet("/api/of/checkout", async (HttpRequest request) =>
{
FeatureClient client = Api.Instance.GetClient("bookstore");
EvaluationContext context = EvaluationContext.Builder()
.SetTargetingKey(request.Headers["X-User-Id"].ToString())
.Set("UserId", request.Headers["X-User-Id"].ToString())
.Build();
bool newCheckout = await client.GetBooleanValueAsync("NewCheckout", false, context);
string button = await client.GetStringValueAsync("CheckoutButton", "Blue", context);
FlagEvaluationDetails<bool> typo = await client.GetBooleanDetailsAsync("NewChekout", false, context);
return new { flow = newCheckout ? "new" : "classic", button, typo = new { typo.Value, typo.Reason, typo.ErrorType } };
});
ada OF: {"flow":"classic","button":"#2da44e","typo":{"value":false,"reason":"ERROR","errorType":2}}
carol OF: {"flow":"classic","button":"#2da44e","typo":{"value":false,"reason":"ERROR","errorType":2}}
user7 OF: {"flow":"classic","button":"#1f6feb","typo":{"value":false,"reason":"ERROR","errorType":2}}
The result is instructive. For variant flags the provider works correctly: Ada and Carol get the green variant, user7 the blue one, matching Microsoft.FeatureManagement's own result. But the string value returned is the variant's configuration_value, not its name. The targeting boolean flag is false even for Ada, who is on the list. The provider's source code shows why: it builds the targeting context only from the UserId and Groups attributes (not from OpenFeature's TargetingKey), and evaluates boolean flags without variants without that context. For an unknown flag it returns errorType 2 (FlagNotFound) and reason ERROR; that behavior is correct and useful.
The takeaway: OpenFeature's abstraction is valuable, but an abstraction is only as good as its provider. Before taking a provider to production, run a real comparison test with your own flag types, as we did here. Choosing a stable flag source for the main provider (a SaaS product's official provider, or a small provider you write yourself) is a reasonable path too.
Flag Debt
Every flag means two paths in the code; both must be tested and both need maintenance. Three habits keep the debt under control:
- An expiry date: when adding a release toggle, put the work to delete it on the backlog the same day and add a date next to the flag. Some teams break the build for expired flags.
- Detecting dead flags with telemetry: Microsoft.FeatureManagement 4.x publishes every evaluation of flags with
telemetry.enabledthrough anActivitySourcenamedMicrosoft.FeatureManagement. A flag that has returned the same result for weeks, or was never evaluated, is a candidate for deletion. - Testing both paths: tests that run with the flag on and with it off. The Angular test above is a small example; on the API side you write the same test by faking
IVariantFeatureManager.
In Production
A flag change is as powerful as a deploy, but most of the time it doesn't go through the pipeline. So record flag changes too: who changed which value and when. During an incident, the answer to "what changed since the last deploy?" is often a flag. Also decide what happens when the flag source can't be reached: the Angular service above "fails closed", treating everything as off. That's usually right for kill switches. But if a feature being off also counts as an outage, keeping the last known value in a cache may be a better default.
Common Pitfalls
- Trying to hide a schema change behind a flag. A flag hides code, not the database. A migration that drops a column runs regardless of the flag; schema changes need the expand-contract from the sixth post of the series.
- Leaving release toggles in place forever. Two years later nobody knows what happens if
UseNewPricingis turned off, and nobody dares to turn it off. - Applying a flag only in the frontend. An invisible button doesn't mean an endpoint that can't be called.
- Changing the seed or salt in the middle of an experiment. Users switch groups and the experiment's result loses its meaning.
- Never testing the kill switch. You need to be sure the "off" path behind a flag also works before you turn it off for the first time during an incident.
A Small Experiment
★☆☆ Add an Exclusion section to the NewCheckout flag that excludes Ada. What does she see, even though she's on the user list?
★★☆ Add "telemetry": { "enabled": true } to the Recommendations flag and log the number of evaluations with an ActivityListener that listens to the Microsoft.FeatureManagement ActivitySource.
★★★ Write your own FeatureProvider for OpenFeature: one that wraps IVariantFeatureManager and maps TargetingKey to UserId. Run the comparison above again and see flow become new for Ada.
A feature flag gives you a way back that doesn't depend on a deploy. But not every problem sits behind a flag. In the next post we put all the ways back side by side: artifact rollback, roll forward, kill switches and the criteria that trigger them automatically.