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 if scatter.
  • 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

phaseobligation
createtyped name in one registry; purpose, owner, expiry condition recorded.
serveone evaluation boundary reads the flag and passes a typed verdict into context/domain call.
clean upexpiry 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 touse a flag?
ship dark, enable per environment/personyes — release flag with named expiry.
A/B an interface variantyes — experiment flag with the measured question and end date.
kill-switch a risky integrationyes — ops flag with a declared probe for when to flip.
hide an unfinished featureno — finish it or remove it; a permanent flag is undead scope.
gradually migrate datano — 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; rg its 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.ts
export 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.

search pages

go to any page