Deploying code and exposing new behavior do not have to happen at the same moment.
That distinction matters when a change is difficult to reverse quickly, needs a gradual rollout, or should be available only to a small group while engineers observe its behavior. A feature flag provides a runtime decision point: the deployed code can contain both behaviors while configuration chooses which one is active for a particular request, user, tenant, or environment.
The useful part is not the if statement. It is the ability to control exposure without rebuilding the software. The cost is that the system temporarily has more than one possible behavior. If flags accumulate, that temporary flexibility becomes permanent complexity.
This article develops a practical mental model for feature flags: treat them as temporary control points with an explicit lifecycle. You will see how to place a flag, reason about its states, test it, operate it safely, and remove it when the decision it represents is finished.
Separate deployment from release
Start with a small example. An application is changing how it calculates a delivery estimate.
Without a flag, deployment and release are coupled:
function deliveryEstimate(order):
return newEstimate(order)As soon as that version reaches production, every caller uses the new calculation.
With a flag, the deployed program contains a controlled choice:
function deliveryEstimate(order, flags):
if flags.enabled("new-delivery-estimate"):
return newEstimate(order)
return oldEstimate(order)Now two events are separate:
deployment: put code containing both paths into production
release: enable the new path for the intended trafficThis separation can reduce the size of a release decision. For example, a team can deploy during normal engineering hours, enable the behavior later for internal users, then expand exposure after observing results.
A flag does not make the new implementation correct. It changes how the team can expose and withdraw that implementation.
Think of a flag as a temporary branch in behavior
A source-control branch separates versions of code before they are merged. A feature flag is different: after deployment, both branches of behavior may exist inside the running system.
That means every active flag adds states the software may need to support.
With one Boolean flag, there are two relevant configurations:
flag off -> old behavior
flag on -> new behaviorWith two independent Boolean flags, there can be four combinations. With three, there can be eight. Not every combination will be reachable or meaningful, but the general lesson is important: flags multiply possible runtime states.
This is why a feature flag should not be treated as free configuration. Each flag introduces questions:
- What does
offmean? - What does
onmean? - Who decides the value?
- What happens if the flag service or configuration source is unavailable?
- Which states must tests cover?
- When can one branch be deleted?
If those questions have no owner, the flag is likely to outlive its purpose.
Put the decision near the behavior it selects
A flag is easier to reason about when it controls one clear decision.
Suppose a checkout flow has several steps. Passing the raw flag throughout the application can spread rollout policy into unrelated code:
validate(cart, newCheckoutFlag)
price(cart, newCheckoutFlag)
reserveInventory(cart, newCheckoutFlag)
sendReceipt(order, newCheckoutFlag)Now each function can interpret the same flag differently. Removing it later requires finding every interpretation.
Prefer resolving the rollout choice once and passing the selected behavior or meaningful result onward:
checkoutPolicy = oldCheckout
if flags.enabled("new-checkout"):
checkoutPolicy = newCheckout
return checkoutPolicy.placeOrder(cart)The exact shape depends on the codebase. The principle is that the flag should answer one release question, such as “which checkout implementation handles this order?” Lower-level functions should not know about rollout configuration unless they genuinely own part of that decision.
This keeps the temporary concern narrow and makes eventual deletion easier.
Decide the fallback before production decides it for you
A flag lookup can fail. A remote flag service may time out. Local configuration may be missing. A targeting rule may not have the attribute it expects.
The application therefore needs a defined fallback.
For a migration from an established implementation to a new one, a common choice is:
flag value available and enabled -> new behavior
flag value available and disabled -> old behavior
flag value unavailable -> old behaviorThat policy is reasonable when the old path remains valid and is the known baseline. It is not a universal rule. A safety control that disables a dangerous operation may need the opposite failure mode: uncertainty should keep the operation disabled.
The decision should follow the consequence of being wrong.
Ask: If the flag value cannot be determined, which behavior is acceptable to execute? Then encode and test that answer instead of relying on a library default that developers may not know about.
Keep targeting rules outside business meaning
Feature systems often support gradual rollout by percentage, user identity, tenant, geography, or other attributes. These rules are useful for controlling exposure, but they should not silently become business rules.
Consider this condition:
if flags.enabledFor("new-invoice-flow", customer):
...If the intent is temporary rollout, the flag decides who receives an implementation while the product behavior is being introduced.
That is different from a permanent rule such as “enterprise customers receive invoice approval.” A permanent domain rule belongs in application logic and should have a name that describes the business decision. Hiding it in a feature-management console makes the rule harder to discover, review, and test with the rest of the domain behavior.
A useful test is to ask what happens after rollout is complete. If the condition still represents a real product rule, model that rule explicitly. If the condition no longer has a purpose, remove the flag.
Test the decisions the flag creates
A feature flag adds at least two behaviors, so tests should make both intentional.
For the delivery estimate example:
test "uses old estimate when flag is off":
flags = fixedFlags("new-delivery-estimate" = false)
result = deliveryEstimate(order, flags)
assert result == oldEstimate(order)
test "uses new estimate when flag is on":
flags = fixedFlags("new-delivery-estimate" = true)
result = deliveryEstimate(order, flags)
assert result == newEstimate(order)These tests verify the application’s branching decision. They do not need a real remote flag service.
Separately, integration tests can verify the adapter that obtains flag values from the actual configuration mechanism. If targeting rules are important, test representative rules at the layer that owns them.
Avoid trying to test every combination of every flag across the entire system. That becomes impractical as flags accumulate. Instead, keep flags locally scoped, test each controlled decision in its meaningful states, and use broader tests for combinations that represent real workflows or known risks.
The difficulty of testing combinations is another reason to remove completed flags promptly.
Roll out in steps that answer specific questions
A gradual rollout is useful when each stage provides information that influences the next decision.
For example:
1. deploy with flag off
2. enable for the development team
3. enable for a small production cohort
4. inspect errors and relevant business outcomes
5. expand exposure
6. reach full exposure
7. remove the old path and the flagThe exact percentages and duration depend on the system, traffic, risk, and how quickly meaningful signals appear. There is no universal safe rollout percentage.
Before increasing exposure, decide what evidence matters. A low error rate may not be enough if the new behavior can silently produce incorrect results. A pricing change may need correctness checks or business reconciliation in addition to infrastructure metrics.
The flag gives you a control lever. Observability tells you whether moving that lever is justified.
Do not confuse a feature flag with a complete rollback plan
Turning a flag off can be a fast way to stop executing new code, but only if the change is actually reversible at that point.
Imagine the new path writes data in a format the old path cannot read. After some traffic uses the new path, switching the flag off may send later requests to code that does not understand the data already written.
The same issue appears with irreversible external actions, schema changes, messages sent to other systems, and migrations that destroy old information.
Before calling a flag a kill switch, identify the boundary of reversibility:
Can old code still read new data?
Can new side effects be safely ignored by the old path?
Can in-flight work cross from one mode to the other?
Does disabling the flag stop new exposure only, or undo anything already done?A flag controls future decisions. It does not automatically reverse past effects.
Give every flag an exit condition
The most common maintainability problem with feature flags is not creating them. It is failing to remove them.
Once the new behavior is fully accepted, code like this has stopped representing a useful runtime choice:
if flag("new-delivery-estimate"):
return newEstimate(order)
else:
return oldEstimate(order)If the decision is finished, simplify it:
return newEstimate(order)Then remove the old implementation when nothing else needs it, remove tests that exist only for the retired branch, and delete the flag from its configuration system.
Define the exit condition when the flag is introduced. It might be “remove after the new estimator has served all traffic for one release cycle” or “remove when the migration has completed and rollback no longer depends on the old reader.” The condition should describe an observable engineering state, not merely a vague future date.
Ownership also matters. A flag with no responsible team or person is easy to forget.
Recognize when a simpler mechanism is better
Not every optional behavior needs a feature flag.
If a setting represents a permanent user preference, model it as product configuration. If two algorithms are intentionally supported for different customers indefinitely, model that distinction as part of the domain or product design. If a change is tiny, easy to verify, and easy to roll back by redeploying, a runtime flag may add more complexity than it removes.
Feature flags are most valuable when temporary runtime control changes the risk or timing of a release decision.
That temporary nature is important. A permanent flag system embedded throughout business logic becomes an alternative programming environment whose rules may be harder to review than code.
Conclusion
A feature flag is a small conditional with a large operational effect. It lets deployed software contain a change before every intended user receives that change, which can make gradual exposure and rapid withdrawal possible.
Use that flexibility deliberately. Give each flag one clear release decision, define its fallback, keep targeting separate from permanent business rules, test the meaningful states, and understand whether disabling the flag is truly safe after side effects occur.
Most importantly, plan the deletion before rollout begins. A feature flag is easiest to maintain when everyone treats it as a temporary control point: introduce it for a specific decision, use it while that decision is active, and remove it when the decision is over.