Designing Feature Flags in Code: Toggle Types, Microsoft.FeatureManagement and Flag Debt
Designing a feature flag well means identifying its type first (release, experiment, ops, permission), keeping the decision in a single toggle router and giving every flag an owner and an expiration date. The post shows a tenant filter with Microsoft.FeatureManagement 4.8.0, in-request consistency with snapshots, a kill switch with WithFeatureGate and a test that catches expired flags, with real output.
Version note: The C# code in this post was tested with .NET 10 and Microsoft.FeatureManagement.AspNetCore 4.8.0.
Guess First: What Does a Misspelled Flag Return?
There's a check that turns on the new flow on the leave approval screen. The developer typed the flag name by hand and dropped a letter:
bool typo = await fm.IsEnabledAsync("LeaveAprovalV2");
Console.WriteLine($"typo flag: {typo}");
The configuration has a flag called LeaveApprovalV2, and it's on. Does this line throw an exception, log a warning, or quietly return a value? A second question: if a flag that's 50 percent on is asked ten times within the same HTTP request, are all ten answers the same? Both answers show why you need to design flags carefully in code.
Context and Problem
The HR Portal team is working on two-level leave approval: leave longer than five days will go first to the manager, then to HR. The work will take six weeks, and the team doesn't want a long-lived branch; code will go into main every day, but the new flow will stay off until it's ready. When it's ready it will first be turned on as a pilot at Acme, then for everyone.
Two more requests arrive in the same weeks. The payroll PDF export strains the servers at month end; the operations team says "if load spikes, we want to turn it off without waiting for a deploy". The sales team wants "advanced reports visible only in the premium plan". All three look like they can be solved with an if, and all three get called a "feature flag". But their lifetimes, who turns them on and off, and how dynamic the decision is are completely different. This post is about the code design of flags: identifying the type, abstracting the decision point and deleting the flag on time. Doing canary or progressive delivery with flags is the subject of the planned Deployment Strategies series.
Symptoms
You'll see these when your flag design is going bad: the same flag name appears as a string in 40 different places. A "new flow" flag turned on two years ago is still in the code, and nobody dares to remove it, because nobody knows who last tested the off branch. The test team asks "which flag combinations should we test?". A feature is hidden in the UI but its endpoint is open; it can be called with Postman. Flags live as bools in appsettings.json, and turning off a kill switch requires a deploy. The premium plan is controlled with a flag, and when a customer changes plans the support team edits a configuration file.
The Naive Solution and Why It Breaks
The code in the opening question is the naive solution itself: the flag name is a string and the decision is made at each point of use. The output:
typo flag: False
No exception, not a single line at the default log level. Microsoft.FeatureManagement treats a flag with no definition as off; that's a safe default, but it also makes the typo invisible. When the pilot starts at Acme, the new flow will never turn on on this screen, and nobody will find out why. The second break is in the code structure: when the if (await fm.IsEnabledAsync(...)) line is written separately in a controller, a service, a report and an e-mail template, removing the flag means touching 40 files, and comparing old and new behavior means reading 40 else blocks. In Pete Hodgson's terms, the decision point (toggle point) and the decision logic (toggle router) are tangled together.
Options
Identify the Type First: Hodgson's Four Categories
Hodgson separates flags along two axes: how long they live (longevity) and how dynamic the decision is (dynamism). A release toggle hides unfinished code, lives a few weeks, and its decision is usually static (two-level approval). An experiment toggle is for A/B tests, lives until there's a statistically significant result, and its decision varies per request and per user. An ops toggle is an operational control; most are short-lived, kill switches can stay for years, and they must be changeable within seconds during an incident (the PDF export). A permission toggle opens a feature to specific user groups, lives for years and is evaluated per request (premium reports). The type determines the tool: the options below carry these four types to different degrees.
A) A bool in Configuration and IOptions
"Features": { "LeaveApprovalV2": false } and IOptions<FeatureOptions>. Zero dependencies, type-safe properties. Enough for a static release toggle. No per-tenant decisions, percentages, time windows or endpoint gates; the file can be watched with reloadOnChange, but an IOptions value stays fixed for the application's lifetime, so you need IOptionsMonitor.
B) Microsoft.FeatureManagement
Microsoft's open-source library: it reads flags from configuration, attaches filters to flags (percentage, time window, targeting and filters you write), offers a snapshot for consistent results within a request, and closes endpoints with [FeatureGate] in MVC and WithFeatureGate in minimal APIs. As of November 2026 the current version on NuGet is 4.8.0 (September 2026). Flags are defined with the feature_management schema:
{
"feature_management": {
"feature_flags": [
{
"id": "LeaveApprovalV2",
"enabled": true,
"conditions": {
"client_filters": [
{ "name": "HrPortal.Tenant", "parameters": { "Tenants": [ 1 ] } }
]
}
},
{
"id": "PayrollPdfExport",
"enabled": true
},
{
"id": "DashboardLayoutB",
"enabled": true,
"conditions": {
"client_filters": [
{ "name": "Microsoft.Percentage", "parameters": { "Value": 50 } }
]
}
}
]
}
}
enabled: false turns a flag off under all conditions; with enabled: true and no conditions the flag is on; with filters the result depends on the filters (requirement_type defaults to Any).
C) A Central Flag Service
A service like Azure App Configuration or a commercial flag platform: flags are managed from one panel, changes propagate to every instance, and audit and permissions live in the service. Because Microsoft.FeatureManagement can read Azure App Configuration as a configuration provider, moving from B to C can be done without changing code. The cost is an external dependency and money; whether to run with the last known value or the default when the service is unreachable has to be designed separately.
D) Your Own Database Table
Adding bool settings to the settings table from the previous post. Tenant and user levels are ready, audit is ready. But you rewrite percentages, time windows, in-request consistency and endpoint gates; and the "temporary" nature of a flag (owner, expiration date) gets lost in that table, so flags turn into permanent settings.
For the HR Portal we choose B; if the number of flags and the team grow, moving the configuration source to C is an open door.
Implementation: Filters, the Toggle Router and Endpoint Gates
We write our own filter for the list of pilot tenants. It takes the tenant from the ITenantContext of the previous post. Since that's a scoped service, we register feature management as scoped too; the docs recommend AddScopedFeatureManagement for filters that consume scoped services:
public sealed class TenantFilterSettings
{
public int[] Tenants { get; set; } = [];
}
[FilterAlias("HrPortal.Tenant")]
public sealed class TenantFilter(ITenantContext tenant) : IFeatureFilter
{
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
TenantFilterSettings settings = context.Parameters.Get<TenantFilterSettings>() ?? new();
return Task.FromResult(settings.Tenants.Contains(tenant.TenantId));
}
}
builder.Services.AddScopedFeatureManagement()
.AddFeatureFilter<TenantFilter>();
Flag names don't travel around as string literals; they live as constants in a single class, each with its type, owner and expiration date written next to it. A typo is now a compile error:
public enum ToggleKind { Release, Experiment, Ops, Permission }
[AttributeUsage(AttributeTargets.Field)]
public sealed class FlagInfoAttribute(ToggleKind kind, string owner, string expires) : Attribute
{
public ToggleKind Kind { get; } = kind;
public string Owner { get; } = owner;
public DateOnly Expires { get; } = DateOnly.Parse(expires);
}
public static class FeatureFlags
{
[FlagInfo(ToggleKind.Release, "team-leave", "2026-12-31")]
public const string LeaveApprovalV2 = "LeaveApprovalV2";
[FlagInfo(ToggleKind.Ops, "team-payroll", "9999-12-31")]
public const string PayrollPdfExport = "PayrollPdfExport";
[FlagInfo(ToggleKind.Experiment, "team-web", "2026-12-15")]
public const string DashboardLayoutB = "DashboardLayoutB";
[FlagInfo(ToggleKind.Release, "team-expenses", "2026-09-30")]
public const string ExpenseOcrBeta = "ExpenseOcrBeta";
}
The real design decision is at the decision point. The old and new approval flows are two implementations of the same interface; the only place that asks about the flag is a toggle router. The rest of the code sees only ILeaveApprovalPolicy and knows nothing about the flag:
public interface ILeaveApprovalPolicy
{
string Name { get; }
IReadOnlyList<string> StepsFor(LeaveRequest request);
}
public sealed class SingleStepApproval : ILeaveApprovalPolicy
{
public string Name => "single-step";
public IReadOnlyList<string> StepsFor(LeaveRequest request) => ["manager"];
}
public sealed class TwoLevelApproval : ILeaveApprovalPolicy
{
public string Name => "two-level";
public IReadOnlyList<string> StepsFor(LeaveRequest request) =>
request.Days > 5 ? ["manager", "hr"] : ["manager"];
}
public sealed class LeaveApprovalPolicyRouter(
IVariantFeatureManagerSnapshot features,
SingleStepApproval current,
TwoLevelApproval next)
{
public async Task<ILeaveApprovalPolicy> ResolveAsync(CancellationToken ct) =>
await features.IsEnabledAsync(FeatureFlags.LeaveApprovalV2, ct) ? next : current;
}
This is the Strategy pattern from the C# series post "A Few Design Patterns in C#: Factory, Strategy and Observer", wired to a flag. The work for removing the flag is clear: delete the router and SingleStepApproval, and bind ILeaveApprovalPolicy directly to TwoLevelApproval. The endpoint, and the same request from two tenants:
app.MapGet("/api/leave-requests/{id:int}/approval-steps", async (
int id, int days, LeaveApprovalPolicyRouter router, CancellationToken ct) =>
{
ILeaveApprovalPolicy policy = await router.ResolveAsync(ct);
return Results.Ok(new { policy = policy.Name, steps = policy.StepsFor(new LeaveRequest(id, days)) });
});
tenant 1 GET /api/leave-requests/7/approval-steps?days=8 -> 200 {"policy":"two-level","steps":["manager","hr"]}
tenant 2 GET /api/leave-requests/7/approval-steps?days=8 -> 200 {"policy":"single-step","steps":["manager"]}
Note that the router takes IVariantFeatureManagerSnapshot. Here's the answer to the second opening question: the Microsoft.Percentage filter decides again on every evaluation. A 50 percent flag asked ten times within the same request, with the regular feature manager and with the snapshot:
for (int i = 0; i < 10; i++)
{
plain += await features.IsEnabledAsync(FeatureFlags.DashboardLayoutB, ct) ? "B" : "A";
snap += await snapshot.IsEnabledAsync(FeatureFlags.DashboardLayoutB, ct) ? "B" : "A";
}
tenant 1 GET /api/dashboard-layout -> 200 {"plain":"ABABBABABB","snapshot":"BBBBBBBBBB"}
tenant 1 GET /api/dashboard-layout -> 200 {"plain":"BBBAAABAAB","snapshot":"AAAAAAAAAA"}
The snapshot keeps the first result for the whole request; the regular manager rolls the dice on every call. A page that draws its header with the new layout and its body with the old one, within one request, is the price of that difference. The snapshot only makes a single request consistent, though: the same user can get a different result on the next request. If you want a stable decision per user, you need the Microsoft.Targeting filter and an ITargetingContextAccessor; progressive rollout strategies are in the Deployment Strategies series.
Implementation: Kill Switches, Client Flags and Flag Debt
The real job of an ops toggle is to turn off a feature during an incident without waiting for a deploy. Binding an endpoint to a flag is one line in a minimal API:
app.MapPost("/api/payroll/{period}/pdf", (string period) => Results.Ok(new { period, status = "queued" }))
.WithFeatureGate(FeatureFlags.PayrollPdfExport);
The configuration file is read with reloadOnChange: true. While the application was running, we set enabled to false for PayrollPdfExport in the file:
tenant 1 POST /api/payroll/2026-10/pdf -> 200 {"period":"2026-10","status":"queued"}
tenant 1 POST /api/payroll/2026-10/pdf -> 404
tenant 1 GET /api/features -> 200 {"LeaveApprovalV2":true,"PayrollPdfExport":false}
An endpoint with a disabled flag returns 404; on the MVC side this behavior can be changed with IDisabledFeaturesHandler. Watching a file works for a single server; for a kill switch to reach every instance at the same time you need a central configuration source (option C).
The Angular side reads flags from an endpoint and builds its menu and route guards accordingly. Return only the flags the client needs, evaluated for that user; sending the whole configuration to the client exposes your internals and the list of pilot customers:
app.MapGet("/api/features", async (IVariantFeatureManagerSnapshot features, CancellationToken ct) =>
{
string[] clientFlags = [FeatureFlags.LeaveApprovalV2, FeatureFlags.PayrollPdfExport];
Dictionary<string, bool> result = [];
foreach (string flag in clientFlags)
result[flag] = await features.IsEnabledAsync(flag, ct);
return result;
});
tenant 1 GET /api/features -> 200 {"LeaveApprovalV2":true,"PayrollPdfExport":true}
tenant 2 GET /api/features -> 200 {"LeaveApprovalV2":false,"PayrollPdfExport":true}
The rule: a flag on the client changes only the view, not security. A PDF button hidden from the menu can still be called from Postman unless the endpoint is closed with WithFeatureGate. Every client flag must have a counterpart on the API side.
Flag debt is the most neglected part. Hodgson suggests treating flags as inventory with a carrying cost: a removal task in the backlog with every release toggle, an expiration date on flags, and a "time bomb" that breaks a test once the date has passed. The FlagInfo attribute makes this automatically checkable:
public static IEnumerable<(string Name, FlagInfoAttribute Info)> All() =>
typeof(FeatureFlags).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral)
.Select(f => ((string)f.GetRawConstantValue()!, f.GetCustomAttribute<FlagInfoAttribute>()!));
DateOnly today = DateOnly.FromDateTime(TimeProvider.System.GetLocalNow().DateTime);
foreach ((string name, FlagInfoAttribute info) in FeatureFlags.All())
{
string state = info.Expires < today ? "EXPIRED" : "ok";
Console.WriteLine($"{name,-18} {info.Kind,-10} {info.Owner,-14} {info.Expires:yyyy-MM-dd} {state}");
}
today: 2026-11-07
LeaveApprovalV2 Release team-leave 2026-12-31 ok
PayrollPdfExport Ops team-payroll 9999-12-31 ok
DashboardLayoutB Experiment team-web 2026-12-15 ok
ExpenseOcrBeta Release team-expenses 2026-09-30 EXPIRED
Put this loop in a unit test and make the test fail when it sees EXPIRED; the team that owns ExpenseOcrBeta either deletes the flag or opens a PR that extends the date with a reason. The kill switch's date is 9999-12-31 on purpose: ops toggles can be permanent, but the code says that's a deliberate decision. In the same test, also check that every constant in FeatureFlags is defined in configuration; that closes the opening typo category completely.
For the test matrix, Hodgson's advice is practical: test two configurations, not every combination. The one that will go live in production (current flags plus the flags being released turned on) and the fallback configuration (the flags being released turned off). For that to work you need to follow the rule "old behavior when the flag is off, new behavior when it's on"; inverted flags (DisableOldFlow) break that order.
Trade-off Table
| Criterion | A) Configuration bool | B) Microsoft.FeatureManagement | C) Central flag service | D) Your own table |
|---|---|---|---|---|
| Data integrity and consistency | A file per instance | Consistent within a request with a snapshot | Consistent across all instances | Transactional, but you write in-request consistency |
| Code complexity | Low | Low-medium; filter and router | Same as B + provider | High; rewriting filters |
| Dynamism | Static or file reload | Per request with filters | Per request with instant propagation | Per request, depends on the cache |
| Fit for toggle types | Release | Release, ops, experiment (with targeting) | All four | Release and permission-like |
| Reversibility | Deploy or file change | File or provider change | Instantly from a panel | Data change |
| Team cognitive load | Low | Medium | Medium; separate service and cost | High; your own framework |
When Not to Use It
Don't use a permission toggle in place of authorization. "Advanced reports in the premium plan" isn't a flag but an authorization rule: it depends on the customer's plan, lives for years, changes with billing, and a violation is a security hole. Solve it with the policies from the ASP.NET Core Web API series post "Authorization in ASP.NET Core Web API: Roles, Policies and Resource-Based Authorization" and a claim or entitlement table that carries the plan. Don't turn a business rule value (a due date, an approval limit) into a flag; that's a setting, the topic of the previous post. Don't hide a change that takes a few hours behind a flag; adding and removing the flag costs more than the change itself. Work that changes the database schema can't be hidden by a flag alone: the migration must be compatible with both behaviors (expand first, contract later). Progressive rollout, canaries and automatic rollback are deployment strategies; we'll discuss them in the Deployment Strategies series.
Checklist
- Every flag's type (release, experiment, ops, permission), owner and expiration date are written in code.
- Flag names are constants in a single class; a constant not defined in configuration breaks a test.
- The decision lives in a single toggle router; the rest of the code sees an interface, not the flag.
IVariantFeatureManagerSnapshotis used where consistency within a request is needed.- Every feature hidden on the client is closed on the API with
WithFeatureGateor[FeatureGate]. - The client receives only the flags it needs, already evaluated.
- An expired flag breaks a test; a removal task is opened with every release toggle.
- Permission needs are solved with an authorization policy, not a flag.
Related Patterns and Sources
This post is the sibling of the settings table in the previous post: a setting is permanent business data, a flag is a temporary code decision. The toggle router builds on the Strategy explanation in the C# series, the endpoint gate on the ASP.NET Core Web API series post "Filters in ASP.NET Core Web API: Action Filters, Result Filters and ServiceFilter", and reloading configuration at runtime on "Configuration and the Options Pattern in ASP.NET Core: Provider Order, User Secrets and Validation". Canaries and progressive rollout are in the planned Deployment Strategies series. Sources: Pete Hodgson's "Feature Toggles (aka Feature Flags)" article on martinfowler.com (the four categories, the toggle point/router split, carrying cost, time bombs and testing advice); the ".NET Feature Flag Management" reference on Microsoft Learn (the feature_management schema, requirement_type, built-in filters, AddScopedFeatureManagement, FilterAlias, IVariantFeatureManagerSnapshot, IDisabledFeaturesHandler); the Microsoft.FeatureManagement.AspNetCore 4.8.0 package on NuGet and the WithFeatureGate API documentation inside it.
A Small Experiment
★☆☆ Write the test that checks that every constant in FeatureFlags is defined in the feature_management:feature_flags array in configuration; it should fail because ExpenseOcrBeta isn't defined.
★★☆ Write the PR that removes the LeaveApprovalV2 flag: delete the router and SingleStepApproval, and change the DI registration to ILeaveApprovalPolicy → TwoLevelApproval. Count how many files you touched and compare with a version where the flag is scattered across the code.
★★★ Make an endpoint with a disabled flag return 503 with ProblemDetails instead of 404. Write an IDisabledFeaturesHandler for MVC and your own endpoint filter for minimal APIs, and note the difference between the two approaches.
Flags decide which path the code takes; in the next post we'll look at a value the code produces: invoice and order numbers. We'll discuss why an identity column can't be an invoice number, the price of a gapless sequence, and yearly resets based on GİB's 16-character number format.