Day 79 — Feature flags
Day 79 — Feature flags
Stage VIII · ~3h
Goal: Implement one feature flag gate (boolean or percentage) with a clean evaluation API, safe defaults, and operational notes—without building a full flag SaaS.
Deploy ≠ release. Today: one flag, well-structured, with a removal plan. Flag debt is real—treat flags as temporary code paths.
Why this day exists
Feature flags let you:
- Ship dark code and enable later
- Kill-switch a bad path quickly
- Gradual rollout (10% → 50% → 100%)
Abuse creates untested combinations and permanent #ifdef culture. The skill is a small evaluation surface that application code can depend on without knowing LaunchDarkly vs env vars.
Theory 1 — Flag types
| Kind | Example |
|---|---|
| Release toggle | New checkout flow on/off |
| Ops toggle | Disable expensive export job |
| Experiment | 5% see algorithm B |
| Permission | Entitlement (often not a “flag service”) |
Lifecycle: create → enable gradually → remove flag and dead code.
ship off → canary % → 100% → delete old path + flag
If a flag has been 100% for a sprint, schedule removal.
Theory 2 — Evaluation API
Keep evaluation boring and injectable:
package flags
import "context"
type Provider interface {
Enabled(ctx context.Context, key string, subject Subject) bool
}
type Subject struct {
UserID string
// Attrs map[string]string // stretch
}
type Static map[string]bool
func (s Static) Enabled(_ context.Context, key string, _ Subject) bool {
return s[key]
}Env-based provider (good enough for many services):
type Env struct{}
func (Env) Enabled(_ context.Context, key string, _ Subject) bool {
v := strings.ToLower(os.Getenv("FLAG_" + strings.ToUpper(key)))
return v == "1" || v == "true" || v == "on"
}Percentage rollout (stable bucketing):
func EnabledPercent(key, userID string, percent int) bool {
if percent <= 0 {
return false
}
if percent >= 100 {
return true
}
sum := sha256.Sum256([]byte(key + ":" + userID))
// use first 2 bytes → 0..65535
n := int(sum[0])<<8 | int(sum[1])
return n%100 < percent
}Stability matters: same user should not flip every request. Include flag key in the hash so different flags do not correlate perfectly.
Composed provider
type PercentEnv struct {
Percent map[string]int // key → 0..100
}
func (p PercentEnv) Enabled(_ context.Context, key string, sub Subject) bool {
pct, ok := p.Percent[key]
if !ok {
// fall back to boolean env
return Env{}.Enabled(context.Background(), key, sub)
}
return EnabledPercent(key, sub.UserID, pct)
}Theory 3 — Where to branch
Prefer branching in application layer, not deep in SQL drivers:
if s.flags.Enabled(ctx, "new_search", flags.Subject{UserID: userID}) {
return s.searchV2(ctx, q)
}
return s.searchV1(ctx, q)HTTP adapter can pass subject from auth middleware.
Defaults
- Fail closed for risky features (
falseif provider errors).
- Fail open only for non-critical cosmetics—document choice.
func (s *Service) Enabled(ctx context.Context, key string, sub flags.Subject) bool {
// if remote provider added later:
// on timeout → return false for release toggles
return s.flags.Enabled(ctx, key, sub)
}Theory 4 — Config integration (Day 77)
type Config struct {
// ...
FlagNewSearch bool
FlagNewSearchPercent int
}Or a JSON file:
{
"flags": {
"new_search": { "enabled": true, "percent": 10 }
}
}Do not require a SaaS vendor for the lab. Awareness of LaunchDarkly/Unleash/OpenFeature is enough:
| Concept | Note |
|---|---|
| OpenFeature | Vendor-neutral API trend |
| Remote config | Needs caching + timeouts |
| Audit log | Who flipped what |
Wire at composition root
fp := flags.Static{
"new_search": cfg.FlagNewSearch,
}
// or flags.Env{} for ops kill switches without redeploy of binaries
// (still needs process restart unless you poll a file)
svc := app.New(deps, fp)Theory 5 — Testing matrix
Flags double paths—test both:
func TestSearchFlag(t *testing.T) {
svc := NewService(deps, flags.Static{"new_search": true})
// assert v2 behavior
got, err := svc.Search(context.Background(), "q")
if err != nil || !got.FromV2 {
t.Fatalf("v2: %#v %v", got, err)
}
svc2 := NewService(deps, flags.Static{"new_search": false})
got2, err := svc2.Search(context.Background(), "q")
if err != nil || got2.FromV2 {
t.Fatalf("v1: %#v %v", got2, err)
}
}Percentage stability test
func TestPercentStable(t *testing.T) {
a := EnabledPercent("exp", "user-1", 30)
b := EnabledPercent("exp", "user-1", 30)
if a != b {
t.Fatal("unstable")
}
}Theory 6 — Ops and observability
Expose evaluation for operators carefully:
// internal admin only — auth required
mux.HandleFunc("GET /internal/flags", func(w http.ResponseWriter, r *http.Request) {
// list configured keys + defaults — not every subject evaluation
})Metrics (stretch):
flag_evaluation_total{key="new_search",result="true"} 120
flag_evaluation_total{key="new_search",result="false"} 880
Avoid per-user labels.
Kill switch runbook
# env-driven: restart with flag off
FLAG_REPORTS=false systemctl restart myservice
# or kubernetes set env + rollout restartDocument whether a restart is required. File-based flags can be re-read on interval—only if you implement it; do not pretend env vars hot-reload.
Worked example — kill switch for expensive endpoint
mux.HandleFunc("GET /v1/report", func(w http.ResponseWriter, r *http.Request) {
if !fp.Enabled(r.Context(), "reports", subjectFrom(r)) {
// 404 hides existence; 503 signals temporary — pick one and document
http.Error(w, "reports disabled", http.StatusServiceUnavailable)
return
}
writeReport(w, r)
})FLAGS.md template
# Flags
| Key | Default | Owner | Type | Removal criteria |
|-----|---------|-------|------|------------------|
| reports | false | you | ops kill | N/A (permanent ops) or date |
| new_search | false | you | release | After 100% for 14 days + delete v1 |Lab
Suggested workspace: your service.
- Define
flags.Providerinterface.
- Implement static + env (or file) provider.
- Gate one real path (feature or kill switch).
- Tests for on and off.
FLAGS.md: key name, default, owner, removal criteria.
- Default is safe when unset (usually
falsefor new features).
Stretch
- Percentage rollout with stable hashing.
- Expose active flags on an internal admin endpoint (auth required).
- Metric
flag_evaluation_total{key,result}.
- File provider with mtime reload every 30s (document races).
Common gotchas
| Gotcha | Fix |
|---|---|
| Flag forever | Removal date in FLAGS.md |
| UserID empty → random | Explicit anonymous bucketing |
| Flag check in hot loop without cache | Local snapshot; remote with TTL |
| Testing only “on” | Matrix both sides |
| 404 vs 403 vs 503 confusion | Document semantics for clients |
| Branching inside SQL string builders | Branch in app layer |
| Logging PII in flag evaluation | Log key + bool only |
| Nested flags (A requires B) | Avoid; simplify combinations |
Checkpoint
- Provider interface + implementation
- One gated path in production code
- On/off tests
- Default safe when unset
- Removal plan written
- Ops note if restart required
Commit
git add .
git commit -m "day79: feature flag gate"Write three personal gotchas before continuing.
Tomorrow
Day 80 — Load test: generate traffic, read metrics/profiles, and find a bottleneck with numbers.