Backend

Feature Flags & A/B Testing

Ship code before it's finished, roll it out to 5% of users, and kill it from the admin panel if it misbehaves — no redeploy. Grit's flags package is an in-memory engine that answers flags.IsEnabled(c, "new_dashboard") in sub-microseconds and never touches the database on the hot path. The same engine assigns sticky A/B variants and logs every exposure for rollout-health analytics.

How it fits together

A single Engine loads every flag into memory at boot and refreshes it in the background every 30 seconds. Your code calls the engine; the engine reads its in-memory map. Admin writes refresh the cache immediately and broadcast a flag.updated realtime event so connected clients can refetch.

Your codeFlag EngineStore & clientsreadload / 30sinvalidatebroadcastengine.Enabled()your checkEnginein-memory mapflags tablesource of truthAdmin writerefresh nowRealtimeflag.updated
Read path (memory)Admin writeRealtime push
Reads hit an in-memory map; admin writes refresh instantly and broadcast to clients
internal/flags
DB (feature_flags)
│ Find() at boot + every 30s
flags.Engine ──────── in-memory map[name]*FeatureFlag
│ IsEnabled(c, "x") (read-locked, zero DB hits)
│ Variant(c, "x")
evaluate(userID, name)
├─ master Enabled switch → "disabled" if off
├─ date window → EnabledFrom / EnabledUntil
├─ blocklist → always deny
├─ allowlist (if set) → restrict to listed users
├─ bucket = SHA-256(userID:name) % 100 ← sticky
├─ A/B: variants[bucket % len]
└─ boolean: bucket < RolloutPercentage
└─ trackExposure(...) → FlagExposure (async, fire-and-forget)
Admin write → RefreshAndBroadcast() → hub.Broadcast("flag.updated")

Checking a flag in Go

The engine is built once in routes.Setup (flags.New(db, hub)) and handed to whatever needs it. From a handler you have the *gin.Context, so use the context-aware helpers — they read user_id from the context (set by the auth middleware) for you.

internal/handlers/dashboard.go
func (h *DashboardHandler) Show(c *gin.Context) {
if h.Flags.IsEnabled(c, "new_dashboard") {
h.renderNewDashboard(c)
return
}
h.renderLegacyDashboard(c)
}
// A/B flag — Variant returns one of the configured strings.
func (h *CheckoutHandler) Start(c *gin.Context) {
switch h.Flags.Variant(c, "checkout_redesign") {
case "variant_a":
h.startVariantA(c)
case "variant_b":
h.startVariantB(c)
default: // "control" or "disabled"
h.startControl(c)
}
}

Outside a request — a cron job or worker that already knows the user id — use the explicit forms:

internal/jobs/digest.go
if engine.IsEnabledForUser(userID, "weekly_digest") {
sendDigest(userID)
}
variant := engine.VariantForUser(userID, "pricing_experiment")

Fail closed. An unknown flag name returns false from IsEnabled (and "" from Variant). A flag that exists but whose rules deny the user returns "disabled". Deleting a flag or a typo therefore hides the feature rather than crashing the request.

The FeatureFlag model

A flag is a row in feature_flags. The master Enabled boolean short-circuits everything — flip it off and no rule matters. Everything nuanced (percentage, allow/block lists, date windows, A/B variants) lives in the Rules JSON column, decoded into a typed FlagRules struct.

internal/models/feature_flag.go
type FeatureFlag struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"` // "new_dashboard"
Description string `gorm:"type:text" json:"description"`
Enabled bool `gorm:"not null;default:false" json:"enabled"` // master switch
Rules datatypes.JSON `gorm:"type:jsonb" json:"rules"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `gorm:"not null;default:1" json:"version"` // bumped on every update
}
type FlagRules struct {
RolloutPercentage int `json:"rollout_percentage,omitempty"` // 0..100
AllowlistUserIDs []string `json:"allowlist_user_ids,omitempty"` // ONLY these users
BlocklistUserIDs []string `json:"blocklist_user_ids,omitempty"` // always deny
EnabledFrom *time.Time `json:"enabled_from,omitempty"` // date window start
EnabledUntil *time.Time `json:"enabled_until,omitempty"` // date window end
Variants []string `json:"variants,omitempty"` // set = A/B mode
}

IDs are UUID strings assigned in a BeforeCreate hook. Version is auto-incremented in BeforeUpdate so you can tell whether a client's view of a flag is stale. Decode rules with flag.ParsedRules(); encode with flag.SetRules(r).

Rules & evaluation order

evaluate() runs the checks in a fixed order, and the order matters — a blocklisted user never reaches the percentage roll:

#CheckOutcome when it matches
1Master Enabled is falsedisabled (short-circuit)
2Now < EnabledFrom or > EnabledUntildisabled
3User in BlocklistUserIDsdisabled (always wins)
4AllowlistUserIDs set & user not in itdisabled
5Variants set (A/B mode)variants[bucket % len]
6bucket < RolloutPercentage (or allowlisted)enabled, else disabled

Sticky bucketing

Both the percentage roll and A/B assignment bucket a user by SHA-256(userID + ":" + flagName) % 100. Same user, same flag → same bucket, every time — so a user in the 25% rollout stays in it across sessions and never sees the feature flicker. SHA-256 (not Go's runtime hash) keeps the bucket stable across process restarts and Go versions.

Anonymous users. With an empty user_id the bucket is drawn from crypto/rand — effectively random per request, and not sticky. For a sticky anonymous flag (marketing experiment on logged-out traffic) pass a stable id via IsEnabledForUser — a session id or device id. Anonymous checks are also skipped in the exposure log.

Managing flags (admin API)

CRUD lives under /api/admin/flags (admin role required). Every write calls Engine.RefreshAndBroadcast(), so the in-memory cache updates instantly — you don't wait for the 30s tick.

Method & pathDoes
GET /api/admin/flagsList (paginated, searchable by name/description)
POST /api/admin/flagsCreate a flag (name must be unique)
PUT /api/admin/flags/:idUpdate (name is immutable)
DELETE /api/admin/flags/:idDelete; cache drops it on next check
GET /api/admin/flags/:id/exposuresPer-variant unique-user counts

The create/update body takes rules as a structured object — the handler encodes it to JSON for you:

POST /api/admin/flags
{
"name": "new_dashboard",
"description": "Redesigned analytics home",
"enabled": true,
"rules": {
"rollout_percentage": 25,
"blocklist_user_ids": ["…"],
"enabled_from": "2026-08-01T00:00:00Z"
}
}

The exposure log

Every non-anonymous flag check writes a FlagExposure row — which user got which variant, and when. The insert is fire-and-forget: it runs in a goroutine with a 5s timeout so exposure tracking never blocks (or fails) a flag check.

internal/models/feature_flag.go
type FlagExposure struct {
ID string `gorm:"primarykey;size:36" json:"id"`
FlagID string `gorm:"size:36;index;not null" json:"flag_id"`
FlagName string `gorm:"size:100;index" json:"flag_name"` // denormalized for join-free analytics
UserID string `gorm:"size:36;index" json:"user_id"`
Variant string `gorm:"size:50" json:"variant"` // "enabled" / "disabled" / "variant_a" / …
CreatedAt time.Time `gorm:"index" json:"created_at"`
}

The /exposures endpoint aggregates it into rollout health — COUNT(DISTINCT user_id) grouped by variant, so you see how many real users landed in each arm:

GET /api/admin/flags/:id/exposures
{
"data": [
{ "variant": "variant_a", "count": 4231 },
{ "variant": "variant_b", "count": 4189 }
]
}

Go deeper

Want to build a real rollout end to end — a percentage ramp, an A/B experiment, and a kill switch wired to the admin UI? The course walks the whole flow with a live app.

Course: Feature Flags & A/B Testing →