Feature Flag Architectures
Design feature flag systems that enable safe, controlled feature rollouts. Covers flag types, targeting, evaluation, lifecycle management, technical debt, and the patterns that make feature flags a superpower instead of a liability.
Feature flags decouple deployment from release. You deploy code that is hidden behind a flag, then enable it for specific users, percentages, or segments. This means you can deploy to production at any time without exposing unfinished features, gradually roll out to catch issues early, and instantly disable features without redeploying.
Flag Types
Release Flags:
Purpose: Hide unfinished features
Lifetime: Days to weeks
Example: new_checkout_flow = false (enable when ready)
Experiment Flags:
Purpose: A/B testing
Lifetime: Weeks to months
Example: checkout_variant = "A" | "B" | "C"
Ops Flags:
Purpose: Operational control (kill switches)
Lifetime: Permanent
Example: enable_external_payment_processor = true
Permission Flags:
Purpose: Entitlement / access control
Lifetime: Permanent
Example: premium_analytics = user.plan == "enterprise"
Evaluation Architecture
class FeatureFlagService:
def __init__(self, config_store):
self.store = config_store
self.cache = {}
def is_enabled(self, flag_name: str, context: dict) -> bool:
flag = self.store.get(flag_name)
if not flag:
return False
# Kill switch: globally on/off
if flag.state == "ON":
return True
if flag.state == "OFF":
return False
# Targeting rules (evaluated in order)
for rule in flag.targeting_rules:
if self._matches(rule.conditions, context):
return self._evaluate_rollout(rule.rollout, context)
# Default rule
return self._evaluate_rollout(flag.default_rollout, context)
def _evaluate_rollout(self, rollout, context):
if rollout.type == "percentage":
# Consistent hashing: same user always gets same result
hash_val = hash(f"{context['user_id']}:{rollout.flag_name}")
return (hash_val % 100) < rollout.percentage
return rollout.value
# Usage
flags = FeatureFlagService(config_store)
if flags.is_enabled("new_checkout", {"user_id": "123", "plan": "pro"}):
render_new_checkout()
else:
render_old_checkout()
Targeting Rules
flag:
name: "new_checkout"
state: "TARGETED" # Evaluate targeting rules
targeting_rules:
# Rule 1: Internal team gets it first
- conditions:
- attribute: "email"
operator: "ends_with"
value: "@company.com"
rollout:
type: "boolean"
value: true
# Rule 2: 10% of premium users
- conditions:
- attribute: "plan"
operator: "in"
value: ["premium", "enterprise"]
rollout:
type: "percentage"
value: 10
# Rule 3: Specific beta users
- conditions:
- attribute: "user_id"
operator: "in"
value: ["user_123", "user_456"]
rollout:
type: "boolean"
value: true
default_rollout:
type: "boolean"
value: false
Flag Lifecycle
1. CREATE
Developer creates flag with default OFF
Code deployed behind flag
2. DEVELOP
Flag ON for developers and internal team
Testing in production environment
3. ROLLOUT
Gradual: 1% → 5% → 25% → 50% → 100%
Monitor metrics at each step
4. RELEASE
Flag set to ON for everyone
Old code path is now dead code
5. CLEANUP ← Most teams skip this!
Remove flag from code
Remove old code path
Remove flag configuration
CRITICAL: Set cleanup deadline at creation time
"This flag must be removed by March 15"
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Never cleaning up flags | Technical debt, code complexity | Mandatory expiration dates |
| Nested flag checks | Combinatorial explosion | Flat flags, no nesting |
| Flags in data layer | Hard to reason about data flow | Flags at service/UI layer only |
| No monitoring per flag | Can’t attribute issues to flag changes | Metric correlation with flag state |
| Hardcoded flag evaluations | Cannot change without deploy | External flag config service |
Feature flags are debt with an expiration date. Every flag you create must have a plan for removal. The flag system that has 500 stale flags is worse than no flag system at all.