Feature Flags: Separate Deployment From Release
Deployment puts code in an environment. Release makes behavior available to users. Treating those as one event forces every rollout decision into a binary deploy-or-rollback choice. A feature flag inserts a controlled decision point so teams can deploy dormant code, expose it gradually, compare behavior, and disable it without rebuilding.
That power has a cost. Flags create runtime branches, distributed configuration, and temporary states that tests and operators must understand. Safe flag systems are not collections of booleans; they are a governed release control plane.
A checkout migration scenario
Suppose a retailer is replacing tax calculation during checkout. The change touches pricing, invoices, refunds, and customer display. A flag can first enable the new calculator for internal test accounts, then a deterministic percentage of low-risk traffic, then selected regions. The old calculator remains available while reconciliation compares outputs.
flowchart LR
D[Deploy both paths] --> I[Internal cohort]
I --> C{Checks pass?}
C -- no --> O[Disable new path]
C -- yes --> P[1 percent cohort]
P --> R{Business and technical signals}
R -- outside bounds --> O
R -- healthy --> W[Widen exposure]
W --> F[Make new path default]
F --> X[Remove flag and old path]The diagram includes the frequently omitted final step: cleanup. A rollout is incomplete while both implementations remain permanent.
Model a flag as a decision
A useful evaluation receives a key, typed default, context, and provider state, then returns a typed value plus reason and variant. OpenFeature standardizes an API and evaluation concepts so application code is less coupled to one vendor.
import { OpenFeature } from '@openfeature/server-sdk'
const client = OpenFeature.getClient('checkout') const useNewTax = await client.getBooleanValue( 'checkout-new-tax', false, { targetingKey: customer.id, region: customer.region, accountType: customer.accountType, } )
const quote = useNewTax ? await newTaxCalculator.quote(cart) : await legacyTaxCalculator.quote(cart)
This TypeScript example supplies a safe false default. The targeting key should be stable so one customer does not oscillate between variants. Context should contain only attributes approved for release targeting; avoid sending names, email addresses, or secrets merely because a provider accepts arbitrary values.
Wrap evaluation near the behavior decision, not throughout low-level functions. A single branch at the tax adapter boundary is easier to reason about than dozens of checks. Do not ask the flag provider repeatedly during one transaction; evaluate once and carry the chosen variant so a configuration update cannot split a checkout between implementations.
Flag types and lifecycles
| Type | Purpose | Typical completion |
|---|---|---|
| Release | Gradually expose a new path | new path wins; flag removed |
| Experiment | Compare defined variants | analysis ends; winning behavior implemented |
| Operational | Shed load or disable an optional capability | retained with drills and ownership |
| Permission | Enforce an entitlement | managed as long-lived policy, not casual release debt |
Do not confuse release targeting with authorization. A flag can hide a user interface, but the server must still enforce permissions. Experiment assignment also needs analysis integrity; changing cohorts midstream can invalidate interpretation.
Every flag needs an owner, description, creation date, expected removal condition, safe default, allowed contexts, and risk class. Time is not the only cleanup trigger. “Remove after all regions use the new calculator and one full reconciliation period passes” is more actionable than an arbitrary date.
Deterministic percentage rollout
Percentage assignment should hash a stable subject and flag key into buckets:
import hashlib
def enabled(flag_key: str, subject: str, percentage: float) -> bool: raw = f"{flag_key}:{subject}".encode("utf-8") bucket = int.from_bytes(hashlib.sha256(raw).digest()[:8], "big") % 10_000 return bucket < round(percentage * 100)
This Python function uses 10,000 buckets, so 1.25 percent maps to 125 buckets. Including the flag key prevents unrelated flags from creating identical cohorts. This demonstrates deterministic assignment, not a complete evaluation engine: production systems also need configuration versions, context rules, telemetry, and provider failure handling.
If users collaborate in an account, account-level assignment may be safer than individual assignment. Choose the unit that prevents inconsistent shared state. For a database migration, route writes by entity rather than request so one record never alternates formats.
Failures and defaults
Evaluation can fail because the provider is down, configuration is malformed, context is missing, or a type mismatches. The default must be valid and safe. During a rollout, the old path is often safest; after migration, leaving the old path as default can resurrect retired behavior during an outage. Update default semantics as lifecycle stages change, then remove the flag.
Cache configuration locally when the SDK supports it and understand staleness. A kill switch requiring immediate propagation has different needs from an experiment assignment. Record evaluation reason and configuration version without logging sensitive context.
Some operations must not change variant midway. Capture an evaluation snapshot:
{ "flagKey": "checkout-new-tax", "variant": "new", "configVersion": "cfg-184", "subject": "account-opaque-id", "evaluatedAt": "request-start" }
Persist this snapshot with a long-running order if later steps need the same decision. It provides audit evidence and makes retries consistent.
Testing walkthrough: both paths and the control plane
First unit-test behavior with an in-memory flag evaluator. Test old and new paths, not the vendor SDK:
test.each([ [false, legacyTaxCalculator], [true, newTaxCalculator], ])('uses selected calculator', async (flag, expected) => { flags.boolean.mockResolvedValue(flag) await checkout.quote(cart) expect(expected.quote).toHaveBeenCalledTimes(1) })
This parameterized test proves branch selection. Add assertions that the nonselected calculator was not called and that one request evaluates once.
Next contract-test flag keys, types, defaults, and required context against the real provider in a nonproduction project. Integration-test provider timeout, stale cache, malformed values, and configuration changes during a transaction. End-to-end tests should cover representative targeting rules but should not attempt every percentage bucket.
Before widening exposure, test the rollback path. For the tax scenario, disable the flag while traffic is active, confirm new requests use the old adapter, and decide what happens to checkouts already using the new variant. Rollback may be unsafe after a one-way schema change; feature flags do not reverse data migrations automatically.
Release plan as executable policy
rollout: flag: checkout-new-tax stages:
- audience: internal
- percentage: 1
- percentage: 10
- percentage: 50
- percentage: 100
advance_when: min_observation_window: defined-by-owner error_rate_delta: within-approved-bound reconciliation_delta: within-approved-bound stop_when:
- invoice_mismatch
- latency_budget_breach
owner: checkout-team
This YAML-like configuration expresses sequence and guard categories without inventing numeric outcomes. Real thresholds must come from baselines and business tolerance. Advancement should require enough observation to cover the workload’s cycles, not simply a few quiet minutes.
Observability by variant
Attach flag key, variant, evaluation reason, and configuration version to traces and metrics with controlled cardinality. Compare request errors, latency, conversion funnel steps, reconciliation mismatches, and support contacts by variant. A global average can look stable while the new cohort fails.
Do not use raw user IDs as metric labels. Keep assignment details in sampled, access-controlled traces or an audit store. Alert on provider failures, unknown flags, type mismatches, missing targeting keys, unexpected assignment distribution, and evaluation latency.
A release timeline might read:
10:00 deploy version containing both calculators 10:08 internal variant evaluations begin 10:20 reconciliation signal outside approved bound 10:21 flag disabled; no deployment rollback 10:24 trace links mismatch to rounding rule
This is a fictional diagnostic example, not a reported outcome. It illustrates why configuration changes and application traces need a shared correlation timeline.
Debt and failure patterns
Nested flags create combinatorial states. Avoid making one temporary flag depend on another. Limit concurrent release flags in a capability and visualize mutually exclusive variants. Static analysis can find keys no longer referenced; dashboards can identify flags fixed at one variant.
Common failures are using flags as authorization, choosing an unstable targeting key, evaluating repeatedly during one operation, shipping one-way migrations behind a reversible-looking switch, omitting a safe default, and keeping the old path forever. Another is changing experiment allocation while interpreting results without accounting for exposure.
Cleanup walkthrough
After 100 percent exposure and the agreed observation period, search code, configuration, dashboards, runbooks, and tests for the key. Remove the old branch and obsolete migrations, simplify tests to the surviving behavior, deploy, and only then archive provider configuration. Reversing that order can make old application versions fail during a rolling deployment. Confirm no supported client or background job still evaluates the key.
Record the final decision and measurements used to complete the rollout. Cleanup is a behavior change and deserves review; deleting the wrong default can reintroduce the retired path.
Archive the decision record with the surviving implementation.
Multi-model routing is a practical place to apply controlled assignments. Testing modern systems helps balance branch tests with broader confidence, and Prime Axiom resources covers implementation support.
Conclusion: release is a lifecycle
Feature flags make deployment calmer only when the release process becomes more disciplined. Use stable targeting, typed evaluation, explicit defaults, variant-aware telemetry, rehearsed rollback, and named cleanup conditions. Treat every flag as temporary complexity unless it is deliberately operated as a permanent control. The goal is not more flags; it is smaller, observable, reversible release decisions.



