Compile-time feature flags in C# using IL weaving and a Roslyn analyzer
Feature flags are one of those ideas that sound simple until you actually implement them at scale. The concept is clean: gate a feature behind a condition, flip the condition remotely, ship dark. The reality is messier.
After a few years of writing the same pattern everywhere, I decided to do something about it. What I ended up building changed how I think about feature flags entirely: not just the syntax, but the fundamental architecture.
The problem: two things that shouldn't be your problem
1. The if statement noise
Every feature flag in a traditional library adds ceremony at every call site:
if (await _featureManager.IsEnabledAsync("SendWelcomeEmail"))
{
await SendWelcomeEmailAsync(user);
}
Multiply that across a real codebase and you have if statements in your service layer, your controllers, your background jobs; everywhere. The feature logic and the gating logic are tangled together. When the flag is retired you have to hunt down every call site manually. And the string "SendWelcomeEmail" can drift out of sync with the actual method name silently.
2. No compile-time safety
String keys are the lingua franca of feature flag libraries. "SendWelcomeEmail", "new-checkout-flow", "payment_v2": magic strings that the compiler can't validate, that can be mistyped, that can go stale when you rename a method, that give you no indication at build time that anything is wrong.
Architectural decision 1: the method IS the feature
The first design decision in FtrIO is that [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.
Instead of wrapping every call site in an if block, you decorate the method itself:
[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 are ready to retire the flag, you remove [Toggle]. That is the entire change. There are no call sites to update because there were never any call sites wrapping the feature. The feature logic and the gating logic were never tangled together. The method goes back to being a normal method.
This is the design goal: a feature toggle should be a single attribute on a single method. Everything else should be unchanged.
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 design decision is about where your flag data lives.
FtrIO is built from the ground up around you owning your flag data. Not as a fallback, not as an offline mode, not as an escape hatch: 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)
The consequences of this are significant:
Your flag state is inspectable. Open appsettings.json and you can see exactly what every toggle is set to. No dashboard login, no API call, no SDK required. It's a text file sitting next to your code.
Your flag state is portable. It's JSON in a file you control. Copy it, back it up, diff it in git, deploy it with your app. It moves with your codebase because it lives in your codebase.
Your flag state is editable directly. In an emergency, edit appsettings.json and the change is picked up via ReloadOnChange with no restart. No external access required.
Fresh start resilience is free. On a cold start FtrIO reads whatever is in appsettings.json immediately. No connection to establish, no initialisation to wait for.
Remote providers are optional, not foundational. Use HTTP, Azure App Config, or env vars if you want remote control. Remove them and appsettings.json is still your fully functional flag store. The call sites don't change either way.
This is what radical ownership means in practice. The data lives with your app. You control it completely.
How IL weaving makes decision 1 possible
[Toggle] is not magic. It 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 is 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.
This is 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
The IL weaving solves the syntax problem. But there is still the question of what happens if you decorate a method with [Toggle] and forget to add the corresponding entry to appsettings.json.
Without any additional tooling, this is a silent runtime failure; the toggle evaluates as missing and the feature quietly does not run.
FtrIO ships 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 in appsettings.json, the build fails:
error FTRIO001: 'NewCheckoutFlow' is decorated with [Toggle]
but has no corresponding entry in the Toggles config section.
This closes the last gap in the safety net. 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 as a standard compiler error in the editor; you see it inline as you type, in the Problems view, and in the build output. No plugin required.
Strategy-based decisions beyond true/false
Honestly? This 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 thing. Should this method execute or not? The strategies are just different ways of answering that question. 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.
Not every feature flag is boolean. 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 strategy 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 full picture
FtrIO is not just a library. It is an ecosystem built around the two architectural decisions above.
FtrIO.Toaster is a self-hosted Docker web UI for managing toggles live without editing files. It implements FtrIO's own ToggleProviderBuffer internally; changes are staged and flushed atomically to appsettings.json, picked up via ReloadOnChange with no restart.
FtrIO.onetwo is a .NET CLI audit tool. It scans your source tree for every [Toggle] reference, 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. It also has experimental subcommands for importing flag state from LaunchDarkly, Flagsmith, and Microsoft.FeatureManagement, generating migration reports, and ejecting back out to another system with a structured report.
export-manifest-action and release-check-action are GitHub Actions that together gate deployments on missing toggle config. The export action scans the source tree and uploads a manifest of required toggle keys as a build artifact. The release-check action downloads the manifest and validates it against the target environment's config before deploying, blocking the deploy if any keys are missing.
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.
Links
- GitHub org: https://github.com/FtrOnOff
- Docs: https://docs.ftrio.dev
- NuGet: https://www.nuget.org/packages/FtrIO





























