How I built a code-first feature flag system for trunk-based development — with per-environment state, client-safe filtering, expiry enforcement, and live toggling without a single redeployment.
My team ships to production every weekday. Not every sprint — every day. We merge to main, CI runs, and if it's green, it goes out.
This sounds risky. It isn't — because every in-progress feature sits behind a flag. You can merge half-built work, merge it early, merge it often. As long as the flag is off, users never see it. When it's ready, flip the flag. No big-bang release. No long-lived branches that rot.
But here's what nobody tells you: feature flags only work if the system managing them is solid. A poorly designed flag system creates more problems than it solves — scattered JSON files, flags nobody can find, dev flags leaking to production, flags that live forever because nobody dares touch them.
This is what I built to solve all of that.
Trunk-based development (TBD) means everyone works on one branch — main. No long-lived feature branches. You commit small, you commit often, and you merge before the day is out.
The problem is obvious: what do you do when a feature takes two weeks to build? You can't merge broken half-built code to main.
The answer is feature flags. Wrap the unfinished feature in a flag check. Merge freely. The flag keeps it hidden until it's ready. Simple in theory — chaotic in practice without a proper system.
Without a structured system, flags scatter across config files, environment variables, and hardcoded values. Nobody knows what's on, what's off, what's safe to delete, or what breaks if you touch it.
The first thing I built was a flag catalog. Every flag in the system must be declared in code — not in a config file, not in a portal, not in an environment variable. In code, with an attribute.
public static class TaskFlags { // A release flag — ships a finished feature [FlagMeta( team: "backend", type: FlagType.Release, environments: AppEnv.Dev | AppEnv.Staging, exposedToClient: false, expiresOn: "2026-09-01" )] public const string BulkTaskImport = "task.bulk-import"; // An experiment flag — A/B test [FlagMeta( team: "product", type: FlagType.Experiment, environments: AppEnv.Staging | AppEnv.Production, exposedToClient: true, expiresOn: "2026-10-15" )] public const string NewDashboardLayout = "task.new-dashboard";
Every flag has an owning team, a type, which environments it's active in by default, whether the browser is allowed to know about it, and an expiry date. That's it. Declaring the flag IS the source of truth.
A CLI tool scans these declarations at build time and generates the configuration JSON for each environment. Nobody hand-edits JSON. Nobody forgets a property. The code is always right because the code drives everything else.
A feature team never touches a platform file to ship a flag. They declare it, gate their code, and the system does the rest.
public class TaskService(IFeatureFlagGate flags) { public async Task<ImportResult> ImportBulkAsync(ImportRequest req) { if (!await flags.IsEnabledAsync(TaskFlags.BulkTaskImport)) throw new FeatureNotAvailableException(); // actual import logic return await _importer.RunAsync(req); } }
The classic mess: appsettings.Development.json, appsettings.Staging.json, appsettings.Production.json — each one a slightly different copy, all drifting out of sync over time. Someone enables a flag in dev, forgets to update staging, it ships to production in the wrong state.
My solution: the flag declaration itself carries its default state per environment via the environments field. The CLI emitter reads the catalog and generates correct JSON for each environment. The only way a flag is enabled in production by default is if the developer explicitly says Env.Production in the attribute.
dotnet run --project TaskManager.Flags.Cli -- emit --target production # Outputs: feature_management JSON for Azure App Configuration # No manual editing. Catalog is always right.
This JSON is then imported to Azure App Configuration in the CD pipeline. Live state lives there — not in the repo. The repo just defines the defaults. Operations can flip a flag in the portal without redeploying. The repo catches up on the next deploy.
[FeatureFlag] attribute in code. Owner, env defaults, expiry all set here.label=app. Overwrites previous state.This one catches people off guard. If you expose your full flag list to the frontend via an API, you're leaking your entire feature roadmap to anyone who opens DevTools. Upcoming features, experiments, kill switches — all visible.
The fix is exposedToClient: true/false on each flag. A flag marked exposedToClient: false never appears in the API response the browser polls. It simply doesn't exist as far as the client is concerned.
[HttpGet("/api/flags/browser")] public async Task<IActionResult> GetBrowserFlags() { // Only client-safe flags evaluated and returned var browserFlags = _catalog.GetBrowserVisibleIds(); var result = new Dictionary<string, bool>(); foreach (var id in browserFlags) result[id] = await _flags.CheckAsync(id); return Ok(result); } // Non-browser flags never evaluated here — invisible to the client
The Angular frontend polls this endpoint every 30 seconds. It only ever knows about flags that were explicitly marked safe. Route guards and UI elements check against this list. Everything else is invisible by design.
Leaking internal flag names to the browser is a real information disclosure risk. Flag names often reveal feature names, codenames, or architecture details. Client-safe filtering is not optional.
The whole point of feature flags is the ability to turn things on and off fast. If toggling a flag requires a deployment, you've added risk — the thing you were trying to avoid in the first place.
Azure App Configuration supports refresh intervals. The backend polls for changes every 30 seconds. The frontend polls the API every 30 seconds. End-to-end, a flag flip propagates in under 60 seconds with zero downtime.
builder.Configuration
.AddAzureAppConfiguration(opts =>
{
opts.Connect(endpoint, credential)
.UseFeatureFlags(ff =>
{
ff.SetRefreshInterval(TimeSpan.FromSeconds(30));
ff.Label = "app";
});
});
There's also an in-memory fallback for local development. If no App Configuration endpoint is configured, the app regenerates flag state directly from the catalog — dev environment flags on, everything else off. Developers get a working setup without any cloud dependency.
This is the problem nobody talks about until it bites them. A flag ships a feature. The feature is stable. The flag should be deleted — but nobody does it because "it's working, don't touch it." Six months later you have 80 flags, nobody knows which ones are safe to remove, and the codebase is littered with dead branches.
Every flag in my system has a mandatory expiry date. Before that date, an automated bot files a work item: "This flag is expiring — remove it or extend the date." Nobody forgets. Ignoring it means your sprint board is noisy until you deal with it.
| Flag kind | Typical lifetime | What happens at expiry |
|---|---|---|
Release |
Until feature is stable (weeks) | Remove flag, make behaviour permanent |
Experiment |
Until A/B test concludes | Keep winner, delete loser and flag |
KillSwitch |
Indefinite | Review annually, extend or remove |
Permission |
Indefinite | Migrate to a proper permissions system |
The expiry date isn't enforced at runtime — the flag doesn't auto-disable. It's a governance mechanism. The bot creates noise until you deal with it. That friction is intentional.
The flag system is infrastructure. Treat it that way from the start. A couple of booleans in appsettings.json works for one flag. It collapses under ten.
Code is the source of truth — not the portal. Portals are for emergency toggles. If the portal is how flags normally get configured, you'll have drift, undocumented state, and flags nobody owns.
Client-safe filtering is not optional. Your feature roadmap is not public information. A 30-line API filter keeps it that way.
Expiry dates feel bureaucratic until month six. Then they feel like the most important thing you shipped.
Trunk-based development without flags is chaos. With flags, it's calm. The system isn't about moving fast — it's about moving safely. Every daily deploy is boring. That's the goal.