feature flags
why
a flag defers a decision; without an owner or deadline it becomes an unowned permanent branch. the invariant: the codebase stays forward-only — every flag is a temporary contract with recorded owner, purpose, and expiry, evaluated at one boundary, deleted with all its branches when the expiry fires. without that, flags accumulate into undead scope — live production behavior nobody can safely change or remove.
rule
- a feature flag is a durable contract decision, not a runtime
ifscatter. - it has a typed name, declared owner, purpose, and expiry condition.
- expiry fires (rollout complete or experiment answered) → delete the flag and every guarded branch in one forward-only change.
- a permanent flag is undead scope: branches diverge, nobody remembers which side is correct, maintenance becomes a puzzle, the next refactor is blocked by dead code with no owner.
flag lifecycle
| phase | obligation |
|---|---|
| create | typed name in one registry; purpose, owner, expiry condition recorded. |
| serve | one evaluation boundary reads the flag and passes a typed verdict into context/domain call. |
| clean up | expiry condition met → delete flag + all guarded branches in the same change. |
flag state is durable config, evaluated at one boundary — same pattern as authentication: establish once, pass typed verdicts down, never read the flag store deep in domain code.
decision table
| want to | use a flag? |
|---|---|
| ship dark, enable per environment/person | yes — release flag with named expiry. |
| A/B an interface variant | yes — experiment flag with the measured question and end date. |
| kill-switch a risky integration | yes — ops flag with a declared probe for when to flip. |
| hide an unfinished feature | no — finish it or remove it; a permanent flag is undead scope. |
| gradually migrate data | no — that is a durable migration, per schema migrations guidance. |
do not
- evaluate flags in components or domain services directly; one boundary passes the verdict.
- keep a flag past its expiry because "it might be useful".
- branch on flag state in more places than the registry declares.
- use flags to avoid making an architecture decision.
verification
- list the registry: every flag has owner, purpose, expiry; any past-expiry flag is a defect.
- evaluate one flag through its boundary; confirm the verdict reaches consumers typed, not re-read.
- delete one expired flag;
rgits name — zero live matches is the forward-only proof.
good and bad
bad
// somewhere in auth.ts (no registry, no owner)if (localStorage.getItem("use_beta_oauth")) {// OAuth v2 flow (newer, never completed rollout)} else {// OAuth v1 flow (old, should be dead but still running)}// somewhere in utils.ts (related code, no coordinated decision)const oauthVersion = window.env.ENABLE_OAUTH_V2 ? 2 : 1;// somewhere in components (another copy of the decision)if (Date.now() < Date.parse("2024-06-01")) {// use new OAuth} else {// fallback}
a 3-year-old experiment flag scattered across three files with different conditions: nobody knows which branch is live, production may run v1 or v2 depending on load time, and no refactor can remove either branch without risk. the tree rots.
good
// FeatureFlagRegistry.tsexport const FeatureFlagEnum = {OAuthV2Rollout: "oauth_v2_rollout",} satisfies Record<string, string>;type FeatureFlagName = (typeof FeatureFlagEnum)[keyof typeof FeatureFlagEnum];type FeatureFlagConfig = {owner: string;purpose: string;expiry: string;};// FeatureFlagRegistry.ts (configuration)export const FeatureFlags: Record<FeatureFlagName, FeatureFlagConfig> = {[FeatureFlagEnum.OAuthV2Rollout]: {owner: "auth-team",purpose: "migrate OAuth to v2",expiry: "2026-06-01T00:00:00Z", // rollout complete or revert},};// auth.ts (one evaluation boundary)const oauthV2Enabled = readFeatureFlag(FeatureFlagEnum.OAuthV2Rollout);const oauth = oauthV2Enabled ? initOAuthV2() : initOAuthV1();// when 2026-06-01 arrives, delete both FeatureFlag.OAuthV2Rollout and the oauthV1 code entirely
one registry owns the decision, one boundary reads it, deletion date is recorded. the typed config makes a missing owner or expiry a compile error, not a review convention; the single boundary means deleting the flag on its expiry provably removes the last live reference to the old flow — dead the day it expires, not years later during a rewrite.