I got fed up of writing this everywhere:
if (await _featureManager.IsEnabledAsync("SendWelcomeEmail"))
{
await SendWelcomeEmailAsync(user);
}
Not because it's hard. Because it's noise. It's the same pattern, over and over, scattered through every service, every controller, every background job. The feature logic and the gating logic tangled together forever. Magic strings that can drift. Call sites to hunt down when you finally retire the flag. It's boring and it's everywhere and I decided to do something about it that probably shouldn't work but does.
The problem: two things that shouldn't be your problem
1. The if statement noise
Every feature flag adds ceremony at every call site. Multiply that across a real codebase and you've got if statements in places they have no business being. When the flag is retired you have to find every single one manually. And if you mistype the string key? Silent failure. Great.
2. No compile-time safety
String keys are the lingua franca of feature flag libraries. "SendWelcomeEmail", "new-checkout-flow", "payment_v2": magic strings the compiler can't validate, that can be mistyped, that go stale when you rename a method, that give you absolutely no indication at build time that anything is wrong.
Architectural decision 1: the method IS the feature
The first thing I decided: [Toggle] should be the only thing you need to add to ship a feature dark, and the only thing you need to remove to retire it.
So instead of wrapping every call site in an if block, you decorate the method:
[Toggle]
public void SendWelcomeEmail(User user)
{
// send the email
}
Then call it normally:
SendWelcomeEmail(user); // only executes if "SendWelcomeEmail": true in appsettings.json
No if. No injected service at the call site. No magic string. The method name is the toggle key.
When you want to retire the flag, you remove [Toggle]. That's it. There are no call sites to update because there were never any call sites wrapping the feature. The method goes back to being a normal method.
One attribute to ship dark. One attribute removal to retire. Nothing else changes.
For async methods:
[ToggleAsync]
public async Task SendWelcomeEmailAsync(User user)
{
await _emailClient.SendAsync(user.Email);
}
await SendWelcomeEmailAsync(user); // safely awaitable whether on or off
When the toggle is off, [ToggleAsync] returns Task.CompletedTask; always safely awaitable, no null reference risk.
Architectural decision 2: radical ownership
The second decision: your flag data lives with your app.
FtrIO is built around you owning your flag state. Not as a fallback, not as an offline mode: as the primary design. Your flag state lives in appsettings.json, alongside your application code, on your server, in your repository. It is a first-class citizen of your app, not a remote dependency.
If you use a remote provider, it runs in the background, polls for changes, and writes the updated state into your local file via an atomic buffer. The read path always reads from your file.
Remote provider ToggleProviderBuffer appsettings.json
(HTTP / Azure / → (stages changes, → (source of truth,
env vars) flushes atomically) always yours)
↓
[Toggle] method
(reads at call time)
Your flag state is a text file sitting next to your code. Open it and you can see exactly what every toggle is set to. Edit it in an emergency and ReloadOnChange picks it up with no restart. Diff it in git, deploy it with your app, take it with you if you ever change tooling.
Remote providers are optional, not foundational. Remove them and appsettings.json is still your fully functional flag store. The call sites don't change either way.
How IL weaving makes decision 1 possible
Here's where it gets interesting.
[Toggle] is an aspect powered by AspectInjector, a compile-time IL weaving library. When you build your project, AspectInjector walks the compiled IL, finds every method decorated with [Toggle], and weaves a gate check directly into the method's IL, before the method body executes.
The gate reads the toggle state from ToggleParser, which reads from appsettings.json. If the toggle is off, the method returns immediately. If it's on, the method body executes as normal.
The weaving happens at compile time. At runtime there is no reflection, no proxy, no dynamic invocation; just a direct IL call. The method behaves identically to a method that was always written with the gate logic inline, except you didn't have to write it.
You can see exactly what AspectInjector wove into your method using Rider's IL Viewer — Tools → IL Viewer. It's not magic. It's just IL. And it's right there for you to inspect.
This is also why removing the attribute is a clean operation. There is no call site to update. The aspect is gone from the IL, the gate is gone, and the method is back to normal behaviour.
Roslyn closes the loop
IL weaving handles the syntax. But what about the case where you decorate a method with [Toggle] and forget to add the corresponding entry to appsettings.json?
Without anything else, that's a silent runtime failure; the toggle evaluates as missing and the feature quietly does not run. That's not good enough.
So I built a Roslyn analyzer, FTRIO001, that catches this at build time.
Add appsettings.json as an AdditionalFile in your .csproj:
<ItemGroup>
<AdditionalFiles Include="appsettings.json" />
</ItemGroup>
Now if you write this:
[Toggle]
public void NewCheckoutFlow() { ... }
Without adding "NewCheckoutFlow" to your Toggles section, the build fails:
error FTRIO001: 'NewCheckoutFlow' is decorated with [Toggle]
but has no corresponding entry in the Toggles config section.
The compiler validates the contract between your code and your config. Magic string drift is caught before it ever reaches a running application. Rider surfaces FTRIO001 inline as you type — no plugin required.
Strategies: honestly an afterthought
Honestly? This whole section was an afterthought.
When I first started this project years ago, I only ever expected features to run or not run. On or off. That was the whole mental model. I put it down for a long time, picked it up again with fresh eyes, and realised the world is a bit more nuanced than that. Percentage rollouts. Blue-green deployments. A/B tests. Per-user targeting. These are real things real teams need.
So I added them. But I want to be clear about what they are: add-ons that all ultimately boil down to the same question. Should this method execute or not? The strategies are just different ways of answering it. The call site never changes.
I'm genuinely excited about where this goes next. The foundation is solid and there's a lot of interesting territory still to explore. But the core idea — one attribute, run or not run — is still what drives everything.
FtrIO's StrategyToggleParser routes raw config values through a chain of IToggleDecisionStrategy implementations, with BooleanStrategy always appended as the final fallback.
{
"Toggles": {
"NewCheckoutFlow": "20%",
"PaymentV2": "blue",
"BetaFeature": "users:alice,bob",
"PremiumDashboard": "attribute:plan equals premium",
"Experiment": "ab:50"
}
}
ToggleParserProvider.Configure(new StrategyToggleParser(
new UserTargetingStrategy(accessor),
new AttributeRuleStrategy(accessor),
new ABTestStrategy(accessor),
new PercentageRolloutStrategy(),
new BlueGreenStrategy("blue", "blue", "green")
));
Each strategy implements CanHandle(string rawValue) and ShouldExecute(string key, string rawValue). The parser tries each in order; first match wins, BooleanStrategy catches everything else.
PercentageRolloutStrategy handles "20%": probabilistic, runs approximately 1 in 5 calls.
BlueGreenStrategy handles "blue" / "green": compares the config value to the current deployment slot.
UserTargetingStrategy handles "users:alice,bob": checks the current user ID against a comma-separated allow list via IFtrIOContextAccessor.
AttributeRuleStrategy handles "attribute:plan equals premium": evaluates an attribute rule against the current user context. Supports equals, notEquals, startsWith, endsWith, contains, in, notIn.
ABTestStrategy handles "ab:50": deterministic bucketing via SHA-256 of the user ID and toggle key, mod 100. The same user always gets the same result for a given toggle. Salt support ("ab:50:round2") allows population reassignment without changing the toggle key.
All of this is driven by the value in appsettings.json. The call site is always the same:
[Toggle]
public void NewCheckoutFlow() { ... }
The strategy chain is wired up once at startup. The config value determines which strategy fires. The [Toggle] attribute knows nothing about strategies.
The rest of the ecosystem
The library is the core but it's not the whole thing.
FtrIO.Toaster is a self-hosted Docker web UI for managing toggles live. It implements FtrIO's own ToggleProviderBuffer internally; changes flush atomically to appsettings.json and your app picks them up via ReloadOnChange with no restart. Boolean, percentage, blue-green, per-user targeting, A/B test salts, per-user overrides — all manageable from the UI.
FtrIO.onetwo is a .NET CLI audit tool. Scans your source tree, cross-references against appsettings.json, and reports the state of each toggle: ON, OFF, 20%, BLUE, AB-TEST(50%), TARGETED(alice,bob), MISSING, with file and line number. Also has experimental subcommands for importing flag state from LaunchDarkly, Flagsmith, and Microsoft.FeatureManagement, generating migration reports, and ejecting back out to another system.
export-manifest-action and release-check-action are GitHub Actions that together gate deployments on missing toggle config. The full safety net:
| Stage | What catches it |
|---|---|
Write [Toggle] without config |
Roslyn analyzer: compile time |
| Deploy with key missing from target env |
release-check-action: deploy time |
Getting started
dotnet add package FtrIO
<PackageReference Include="AspectInjector" Version="2.9.0" />
{
"FtrIO": { "ReloadOnChange": true },
"Toggles": {
"SendWelcomeEmail": true,
"NewCheckoutFlow": false
}
}
[Toggle]
public void SendWelcomeEmail() { ... }
SendWelcomeEmail(); // runs only if "SendWelcomeEmail": true
Everything else is opt-in. Toaster when you want a UI. onetwo when you want audit tooling. Strategies when you need targeting. GitHub Actions when you want deployment safety. You never need all of it.
There's a lot more you can do with IL weaving and Roslyn than people realise. I built a feature flag library with it. What are you going to build?
Links
- GitHub org: https://github.com/FtrOnOff
- Docs: https://docs.ftrio.dev
- NuGet: https://www.nuget.org/packages/FtrIO
Top comments (0)