Feature flags are useful right up until they start piling up.
Then they become one of those slow, boring sources of complexity that quietly make a codebase worse every month.
The problem usually is not the first flag. It is the tenth one that nobody owns anymore.
The real problem with feature flags
A feature flag looks harmless when you add it:
- one
if - one config value
- one rollout plan
- one promise to clean it up later
That last part is where things usually fall apart.
The flag stays. Then another flag arrives. Then a supposedly temporary kill switch becomes part of the architecture.
At some point, you are no longer using flags to reduce risk. You are using them to preserve old decisions forever.
Boolean names are not enough
Most teams put effort into naming the flag.
That is fine, but it is not enough.
A name like UseNewCheckoutPipeline or EnableSearchV2 tells you what the flag does. It does not tell you whether it should still exist.
That missing piece matters.
A good feature flag needs a few bits of metadata around it:
- why it exists
- who owns it
- when it should be reviewed
- what “done” looks like
- whether it is a rollout flag, an operational kill switch, or a long-lived permission flag
If you skip that, every flag starts to look equally permanent.
Not all flags are the same
This is where a lot of teams get sloppy.
They treat every flag like the same generic boolean, even though the lifecycle is completely different.
In practice, I think of flags in three buckets:
1. Release flags
These exist to ship safely.
You turn on the new path for a subset of traffic, watch for problems, then remove the old branch when confidence is high.
These should have the shortest life.
If a release flag is still in the code six months later, something went wrong.
2. Operational flags
These are emergency brakes.
Maybe an external API is failing. Maybe a background process needs to be disabled quickly. Maybe a non-critical integration is hurting stability.
These can live longer, but they still need ownership and review.
A permanent kill switch that nobody understands is not resilience. It is deferred design.
3. Permission or plan flags
These control access by tenant, customer tier, or environment.
These may be intentionally long-lived. That is fine.
But they should not be mixed mentally with rollout flags. If they are, temporary code tends to stick around because people assume every flag is permanent by default.
Give flags an expiry date in code or config
I like systems that make flag review visible.
That does not need to mean building a giant internal platform.
Even a simple model is better than a bag of loose booleans:
public sealed record FeatureFlagDefinition(
string Name,
string Owner,
string Purpose,
DateOnly ReviewBy,
FeatureFlagType Type);
public enum FeatureFlagType
{
Release,
Operational,
Permission
}
Now the flag is not just a key. It has context.
That gives you room to build basic guardrails:
- log warnings for overdue release flags
- fail a CI check when a release flag has passed its review date
- generate a dashboard of flags that need cleanup
- distinguish flags that should be removed from flags that are expected to stay
This is not glamorous work, but it is still worth doing.
Keep the branching shallow
Another common mistake is letting the flag leak everywhere.
Once a feature flag starts showing up across controllers, services, repositories, mappers, and tests, cleanup gets ugly fast.
The fix is boring but effective:
- evaluate the flag as close to the composition boundary as possible
- route to one path or the other
- keep the rest of the code explicit
That usually means you want this:
public async Task HandleAsync(Order order, CancellationToken cancellationToken)
{
if (_featureManager.IsEnabled("UseNewPricingEngine"))
{
await _newPricingService.CalculateAsync(order, cancellationToken);
return;
}
await _legacyPricingService.CalculateAsync(order, cancellationToken);
}
Not this:
var subtotal = _featureManager.IsEnabled("UseNewPricingEngine")
? await _pricingRepository.GetSubtotalV2(order.Id, cancellationToken)
: await _pricingRepository.GetSubtotal(order.Id, cancellationToken);
var tax = _featureManager.IsEnabled("UseNewPricingEngine")
? await _taxService.CalculateV2Async(subtotal, cancellationToken)
: await _taxService.CalculateAsync(subtotal, cancellationToken);
The second version spreads the migration across the codebase.
That makes testing harder, reasoning harder, and removal slower.
Make cleanup part of the rollout plan
If the rollout ticket ends when the flag reaches 100 percent, the code will probably never be cleaned up.
The cleanup should be the last step of the rollout, not a nice-to-have afterthought.
That means the plan should explicitly include:
- enable for internal users
- enable for a small production slice
- monitor behaviour and performance
- enable broadly
- remove the old path and delete the flag
That last step is where the real quality win happens. Without it, feature flags become a complexity subscription.
A little friction is healthy
I do not think flag systems should be frictionless.
If adding a temporary release flag requires an owner and a review date, that is not bureaucracy. That is a reminder that temporary code is supposed to be temporary.
Teams are usually too casual about feature flags because the damage arrives slowly.
But it does arrive.
Final thought
Feature flags are great for reducing release risk.
They are bad as a long-term storage unit for indecision.
Name them clearly, sure. But also give them an owner, a purpose, and an expiry mindset.
Otherwise the safest deployment trick in your stack slowly turns into maintenance debt with a config file attached.

A seasoned Senior Solutions Architect with 20 years of experience in technology design and implementation. Renowned for innovative solutions and strategic insights, he excels in driving complex projects to success. Outside work, he is a passionate fisherman and fish keeper, specializing in planted tanks.