How to remove a feature flag safely

A flag that's ON everywhere and staying that way is dead weight: two code paths to read, two to test, and one that never runs. Removing it is a refactor with a specific failure mode. You delete the wrong branch, or miss a reference the compiler can't see, and the behavior quietly changes. This is the procedure that avoids it, with or without a tool.

1. Confirm the flag is ON everywhere

"Fully rolled out" has to be true in every environment, not just production. Check each config file (appsettings.Staging.json, environment.prod.ts, .env files, Helm values) and the flag service if you use one. A flag that's ON in production but OFF in a test environment, or ON for a percentage of users, isn't ready. Removing it changes behavior for someone.

Get an explicit yes from whoever owns the flag. Then plan the order. Remove the code first, deploy it, and only then archive the flag in the service. If you switch a fully rolled-out flag off while deployed code still evaluates it, that code falls back to the OFF path in production.

2. Start from a clean, green baseline

Commit or stash everything, then note the commit, whether the build passes, and which tests pass. Write down the failures too. Without this, a failing test after the removal could be yours or could have been failing all along, and you can't tell which.

If your test runner can write JUnit XML or TRX, keep that file. Comparing individual tests is much more useful than comparing exit codes. It tells you which tests disappeared, not just that the suite still passes.

3. Find every reference, not just the name

Searching for NewCheckout finds the obvious uses. A flag usually has more names than that:

  • Constants and keys: FeatureFlags.NewCheckout, FLAGS.newCheckout, "new-checkout", FeatureManagement:NewCheckout, FeatureManagement__NewCheckout in env files.
  • Wrappers: IsNewCheckoutEnabledAsync(), a useNewCheckout() hook, a signal such as newCheckout = flags.watch(...).
  • Values that carry it: var useEngine = await IsEnabledAsync(...), a field set in the constructor, a bool parameter it's passed into, a DI-registered boolean.
  • Places the compiler doesn't check: templates (@if, *ngIf), JSON and YAML config, routes and guards, test setup, comments.

Every new name you find goes back into the search. Keep the list: it's what you'll check against at the end.

4. Keep the ON path, delete the OFF path

The flag is permanently true. Replace each evaluation with true and simplify. Don't lift anything out of the OFF branch into live code.

A fall-through return

The code after an if that returns is the OFF path.

// before
if (await _featureManager.IsEnabledAsync(FeatureFlags.NewCheckout))
    return await PlaceOrderAsync(cart);
return PlaceOrderLegacy(cart);

// after
return await PlaceOrderAsync(cart);

Combined conditions

a && flag becomes a. a || !flag becomes a. In if (flagA && flagB), removing flagA leaves if (flagB). Never touch the other flag.

A template

<!-- before -->
@if (newCheckout()) {
  <app-checkout-v2 [cart]="cart" />
} @else {
  <app-legacy-checkout [cart]="cart" />
}

<!-- after -->
<app-checkout-v2 [cart]="cart" />

An element with *ngIf="!newCheckout()" is deleted. A binding such as [disabled]="!newCheckout()" is always false now, so remove the binding.

A flag passed as a parameter

// before
function price(cart: Cart, useEngine: boolean) {
  return useEngine ? engine(cart) : legacy(cart);
}
price(cart, flags.isEnabled('NewCheckout'));

// after
function price(cart: Cart) {
  return engine(cart);
}
price(cart);

Pass true first and simplify the callee. Remove the parameter only once every caller passes true. A caller that passes something else keeps it.

Dependency injection

// before
if (config.GetValue<bool>("FeatureManagement:NewPayments"))
    services.AddScoped<IPaymentService, NewPaymentService>();
else
    services.AddScoped<IPaymentService, LegacyPaymentService>();

// after
services.AddScoped<IPaymentService, NewPaymentService>();

5. Delete what only the OFF path used

This is the step that gets skipped. In the DI example above, LegacyPaymentService now has no callers, nor do its tests and any helpers only it used. Look for:

  • Methods, classes, components and whole files the OFF branch called.
  • Constructor parameters, fields, DI registrations, imports and using directives that were only needed for it.
  • The wrapper itself, its interface member, and the interface if nothing is left on it.
  • The flag's definition: the constant, the enum member, the key in every environment's config, and a config section left empty.

Your compiler helps here. Turn on unused-code warnings (noUnusedLocals in TypeScript, the CS0169/IDE0051 family in .NET) and compare against the baseline, so you only look at the warnings your change introduced. Keep the flag system itself, any public API of a library, and anything else still uses.

6. Fix the tests the right way

  • Tests of the OFF path: delete them. A test that sets the flag to false, or asserts on text only the OFF path produced, tests code that no longer exists. Watch for OFF tests that never mention the flag by name.
  • Tests of the ON path: keep them, remove the flag setup (mocks, spies, overrides), and drop the flag from the test name.
  • Parameterized tests: keep only the ON case.

Never change a test's expected value to make it pass. A new failure in an ON-path test means the removal changed behavior. Fix the code, not the test.

7. Verify against the baseline

Before you call it done, all of these should hold:

  • No name from your list is left in code, templates or config. A mention in a comment is worth rewording.
  • The build passes. In typed code, deleting the flag's definition turns most missed references into compile errors, which is the cheapest check you have.
  • No test that passed at the baseline fails now.
  • Every test that no longer runs is one you deleted or renamed on purpose.
  • The compiler reports no unused code that wasn't there at the baseline.

Then review the diff like any other change, and put follow-ups in the pull request: keys in Key Vault or Helm values, the flag-service archive step, other services that read the same flag.

8. Know what no check will catch

Some references are invisible to search, the compiler and tests alike. Flag names built at runtime ("New" + area), reflection, flags read by other repositories or services, and config stored outside the repo. In dynamically typed code, a missed reference doesn't break the build, so your tests and searches have to carry more of the weight. When any of these apply, say so in the pull request instead of assuming the removal is complete.