Feature flags
core/featureflag is GoFastr's feature-flag package. It stays
in-process and covers the cases that come up in real projects: global
kill switches, percentage rollouts, per-user overrides for beta
testers, per-tenant enablement for design partners, and
per-environment restriction so a staging-only flag never reaches
production.
The package is named featureflag to avoid colliding with the standard
library's flag package; users who want a shorter local name can alias
on import.
For anything richer (multi-arm experiments, scheduled ramps, dependent
flags) wrap the supplied Evaluator rather than extending this
package.
Wiring
import flag "github.com/DonaldMurillo/gofastr/core/featureflag" app := framework.NewApp(framework.WithDB(db)) // Default backing store is in-memory; call SetFlagStore for redis/db. store := flag.NewMemoryStore() _ = store.Set(flag.Flag{Key: "new-checkout", Enabled: true, Rollout: 25}) app.SetFlagStore(store)
The first call to app.Flags() (or app.IsEnabled, or
app.SetFlagStore) lazily wires the evaluator and also installs it as
featureflag.Default() so package-level helpers work from anywhere.
The default middleware also puts the App's evaluator on every request
context (featureflag.WithEvaluator), and featureflag.Bool reads the
context's evaluator before Default(). With two Apps in one process,
flag.Bool(r.Context(), ...) in each App's handlers reads that App's
store, and an App that has not wired flags yet reads every flag as off.
Default() serves only contexts that never passed through a request,
such as a queue job or a cron run, and there the last App wired wins:
pass app.Flags() to that code instead. An app built with
WithoutDefaultMiddleware adds app.FlagContextMiddleware() to its
own chain.
Using a flag
Inside a handler:
ctx := flag.WithContext(r.Context(), flag.EvalContext{ UserID: sess.UserID, TenantID: tenant.ID, Env: "production", }) if app.IsEnabled(ctx, "new-checkout") { return newCheckout(w, r) } return oldCheckout(w, r)
Or, equivalently, the package-level form:
if flag.Bool(ctx, "new-checkout") { ... }Both consult the same evaluator.
Flag rules
type Flag struct { Key string Enabled bool // global kill switch Rollout int // 0..100; stable hash of (key, subject) Users []string // allow list: "tenant:user", or a bare id with no tenant Tenants []string // allow list of tenant ids Envs []string // restrict to deployment environments (empty = any) }
Evaluation order:
Enabledfalse → always off.Envsnon-empty ANDEvalContext.Envnot in the list → off.- A
Usersentry names this tenant's user (below) → on. - The tenant matches
Tenants(non-empty) → on. Rolloutpercentage of the stable hash → on or off.
The tenant
The tenant a flag is evaluated for is EvalContext.TenantID. When that
is empty, it is the tenant the framework resolved for the request: the
core/handler tenant slot that tenant.ResolveMiddleware and
tenant.TenantMiddleware fill (handler.GetTenantID). An explicit
TenantID wins. A context with neither has no tenant: a single-tenant
app.
User entries
User ids are per tenant in a multi-tenant app, so a Users entry names
one tenant's user:
| Entry | Matches |
|---|---|
acme:42 | user 42 of tenant acme only |
42 | user 42 when the context has no tenant |
:google:42 | user google:42 when the context has no tenant |
The entry splits at its first :. The part before it is the tenant
id with % and : percent-escaped (tenant a:b is written a%3Ab);
the part after it is the user id as is, : included. A bare 42 never
matches inside a tenant, and a bare id that holds a : must be written
with an empty tenant part, or it names a tenant. Build entries with
flag.UserEntry(tenantID, userID) and read them with
flag.ParseUserEntry(entry); both stores refuse a flag whose entry is
empty, has no user id after the : (acme:), or has a malformed
escape (%zz:42), and an entry a custom store hands over malformed
never matches.
The Tenants list names tenants and is unchanged.
Rollout
The hash is FNV-1a over key + 0x00 + subject. The subject is the user
as a Users entry spells it (flag.UserEntry(tenant, user), so
acme:42 inside a tenant and the bare 42 without one), otherwise the
tenant id, otherwise empty. Keying on the pair puts user 42 of two
tenants in independent buckets; a single-tenant app's subject is the
bare user id, as before. An
empty subject is forced off at any partial rollout (1–99): anonymous
traffic hashes to a single constant bucket per flag key, so a "50%
rollout" would be 100% or 0% of anonymous traffic depending on the
key. Apps that want anonymous traffic in a rollout derive a stable
pseudo-subject (session, request signature) and put it in
EvalContext.UserID; for anonymous-sensitive flags, use allow-list
gating.
The Envs filter is the outermost rule: even a user that appears
in the Users allow list does not see the flag if their request's
Env is not in Envs. This keeps a staging-only feature from leaking
into production for testers whose user-id sits in both environments'
identity provider.
Stores
featureflag.Store is the read interface, featureflag.MutableStore
adds writes. The bundled MemoryStore implements both, exposes All()
for admin listings, and is safe for concurrent use.
Two stores are bundled:
NewMemoryStore(): in-process map.NewSQLStore(db, opts...): SQL-backed (sqlite + postgres), createsfeature_flagson first use. PassWithSQLTable(...)to override the table name, orWithSQLDialect("postgres"|"sqlite")to pin the dialect when the runtime probe is unreliable. Also implementsMutableStoreandAll(ctx)for admin tooling.
The SQL store registers its table with App.ExportData and
App.EraseUserData. Erasing a user removes their id from every flag's
users list and keeps the flag. With a tenant (WithEraseTenant, or
the request's resolved tenant) it removes the bare entries and that
tenant's <tenant>:<id> only, since another tenant's beta:42 is a
different user. With no tenant it removes every tenant's entry. An entry
equal to the whole erased id (a pre-upgrade google:42) always goes.
The tenants list names tenants, not users, and is left alone. See data export.
For Redis or other backends, wrap featureflag.Store. Only Get is
required; add MutableStore for admin-edit support.
Common mistakes
- Don't read flags inside hot loops without caching the bool. The evaluator is fast but every call hits the store. Hoist the decision.
- Don't rely on flags for security gating. Flags are for rolling
out features, not for access control. Use
framework/accessfor permissions. The rollout hash is FNV-1a and is not adversary-resistant; an attacker who can choose their subject id can grind into a 1% cohort with ~100 tries. - Don't share an EvalContext across requests. It's per-call; build it from the request's authenticated subject.
- Don't bake user ids into the flag definition long-term. The allow lists are great for beta testers; they're not how you maintain a permanent VIP segment.
- Don't treat a missing key as "off" for a kill switch. A typo or
accidental delete returns
false, so forif !IsEnabled(...) { dangerous() }you'd silently enable the dangerous path. Use the explicitFlags().BoolDefault(ctx, key, defaultIfMissing)form to set the safe default per call site.