Day 79 — Feature flags

Updated

July 30, 2026

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.

Note

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 (false if 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 restart

Document 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.

  1. Define flags.Provider interface.
  2. Implement static + env (or file) provider.
  3. Gate one real path (feature or kill switch).
  4. Tests for on and off.
  5. FLAGS.md: key name, default, owner, removal criteria.
  6. Default is safe when unset (usually false for 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.