Skip to content
gohacks.dev
APIs

Build a Feature Flag Service in Go From Scratch With Tests

Build a small feature flag service in Go: stable percentage rollouts by hashing, targeting rules, a kill switch, an admin API and an evaluation endpoint.

16 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services.

A feature flag is an if statement whose condition can be changed without a deploy. That tiny capability changes how teams ship: merge code that is switched off, turn it on for staff, then for five percent of users, then for everyone, and turn it off in ten seconds if the graphs go wrong. Commercial services sell this, and so do open-source projects (OpenFeature is the vendor-neutral client API). It is also a small enough problem that building the core yourself teaches what those products actually do.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This project builds a feature flag service in Go with the parts that matter: deterministic percentage rollouts, targeting rules, a kill switch, an admin API protected by a token and an evaluation API that applications call. It stays in memory to keep the focus on the logic. It ties to the notification service project as another small service built from scratch, and to the API security checklist for the admin endpoint. It ran on Go 1.25.6.

The model

A flag has a key, an enabled switch, a default rollout percentage and a list of rules. A rule says: if the user's attribute plan is one of enterprise, turn the flag on for percent of those users. Rules are checked in order and the first rule whose attribute matches decides; if none match, the default percentage applies.

flags.goGo
package main

import (
	"encoding/binary"
	"errors"
	"hash/fnv"
	"sort"
	"sync"
)

// Rule says: when every attribute matches, the flag is on for these users (or for a share of them).
type Rule struct {
	Attr    string   `json:"attr"`    // for example "plan" or "country"
	In      []string `json:"in"`      // match when the user's value is one of these
	Percent int      `json:"percent"` // 0..100 of matching users; 100 means all of them
}

type Flag struct {
	Key     string `json:"key"`
	Enabled bool   `json:"enabled"` // the kill switch: false turns the flag off for everyone, whatever the rules say
	Percent int    `json:"percent"` // default rollout for users no rule matches
	Rules   []Rule `json:"rules"`
	Version int    `json:"version"`
}

type User struct {
	ID    string
	Attrs map[string]string
}

type Decision struct {
	On     bool   `json:"on"`
	Reason string `json:"reason"`
}

var ErrInvalid = errors.New("invalid flag")

func (f Flag) Validate() error {
	if f.Key == "" || len(f.Key) > 64 || f.Percent < 0 || f.Percent > 100 {
		return ErrInvalid
	}
	for _, r := range f.Rules {
		if r.Attr == "" || len(r.In) == 0 || r.Percent < 0 || r.Percent > 100 {
			return ErrInvalid
		}
	}
	return nil
}

// bucket maps a user to a stable number from 0 to 99 for one flag. The flag key is part of the hash, so a user who
// is in the first 10% of one rollout is not automatically in the first 10% of every other.
func bucket(flagKey, userID string) int {
	h := fnv.New32a()
	h.Write([]byte(flagKey))
	h.Write([]byte{0})
	h.Write([]byte(userID))
	return int(binary.BigEndian.Uint32(h.Sum(nil)) % 100)
}

func Evaluate(f Flag, u User) Decision {
	if !f.Enabled {
		return Decision{false, "kill switch"}
	}
	b := bucket(f.Key, u.ID)
	for i, r := range f.Rules {
		v, ok := u.Attrs[r.Attr]
		if !ok || !contains(r.In, v) {
			continue
		}
		if b < r.Percent {
			return Decision{true, "rule " + itoa(i) + " (" + r.Attr + "=" + v + ")"}
		}
		return Decision{false, "rule " + itoa(i) + " matched but user is outside its rollout"}
	}
	if b < f.Percent {
		return Decision{true, "default rollout"}
	}
	return Decision{false, "default rollout"}
}

func contains(xs []string, s string) bool {
	for _, x := range xs {
		if x == s {
			return true
		}
	}
	return false
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for ; n > 0; n /= 10 {
		b = append([]byte{byte('0' + n%10)}, b...)
	}
	return string(b)
}

// Store keeps flags in memory behind a lock; a real service would persist them and broadcast changes.
type Store struct {
	mu    sync.RWMutex
	flags map[string]Flag
}

func NewStore() *Store { return &Store{flags: map[string]Flag{}} }

func (s *Store) Put(f Flag) (Flag, error) {
	if err := f.Validate(); err != nil {
		return Flag{}, err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	f.Version = s.flags[f.Key].Version + 1
	s.flags[f.Key] = f
	return f, nil
}

func (s *Store) Get(key string) (Flag, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	f, ok := s.flags[key]
	return f, ok
}

func (s *Store) Keys() []string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	keys := make([]string, 0, len(s.flags))
	for k := range s.flags {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

Three decisions in this file are the whole design.

The kill switch is separate from the rollout. Enabled is checked first. When it is false the flag is off for everybody, whatever the percentage and rules say. During an incident, the person on call should flip one boolean, not edit a percentage and a rule and risk getting one wrong.

Bucketing is a hash. bucket(flagKey, userID) hashes the key and the user id with FNV-1a and takes the result modulo 100, so every user lands in a fixed bucket from 0 to 99 for that flag. The flag is on for the user when the bucket is below the percentage. The result is deterministic: the same user always gets the same answer for a given configuration, with no state stored and no coordination between service instances.

The flag key is part of the hash. Without it, a user in bucket 4 would be in the first 10 percent of every flag, and a small group of unlucky users would receive every experiment. Mixing in the key spreads users independently across flags.

What the numbers show

The lab runs 10,000 users through a flag at 10, 25 and 50 percent.

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	// A rollout is stable: raising the percentage only adds users, it never removes anyone.
	const users = 10000
	count := func(f Flag, attrs map[string]string) (on int, set map[string]bool) {
		set = map[string]bool{}
		for i := 0; i < users; i++ {
			id := "user-" + strconv.Itoa(i)
			if Evaluate(f, User{id, attrs}).On {
				on++
				set[id] = true
			}
		}
		return
	}
	f := Flag{Key: "new-checkout", Enabled: true, Percent: 10}
	n10, s10 := count(f, nil)
	f.Percent = 25
	n25, s25 := count(f, nil)
	f.Percent = 50
	n50, s50 := count(f, nil)
	fmt.Printf("rollout 10%%: %d of %d users, 25%%: %d, 50%%: %d\n", n10, users, n25, n50)
	kept := 0
	for id := range s10 {
		if s25[id] && s50[id] {
			kept++
		}
	}
	fmt.Printf("users on at 10%% who stayed on at 25%% and 50%%: %d of %d\n", kept, len(s10))

	// Different flags bucket users differently.
	g := Flag{Key: "dark-mode", Enabled: true, Percent: 10}
	_, sg := count(g, nil)
	overlap := 0
	for id := range s10 {
		if sg[id] {
			overlap++
		}
	}
	fmt.Printf("users in both the new-checkout and dark-mode 10%% groups: %d (about 100 if independent)\n", overlap)

	// Targeting rules and the kill switch.
	r := Flag{Key: "beta-reports", Enabled: true, Percent: 0, Rules: []Rule{
		{Attr: "plan", In: []string{"enterprise"}, Percent: 100},
		{Attr: "country", In: []string{"NZ", "AU"}, Percent: 50},
	}}
	for _, u := range []User{
		{"u1", map[string]string{"plan": "enterprise"}},
		{"u2", map[string]string{"plan": "free"}},
		{"u3", map[string]string{"plan": "free", "country": "NZ"}},
		{"u4", map[string]string{"plan": "free", "country": "AU"}},
	} {
		fmt.Printf("%-3s %-34s -> %+v\n", u.ID, fmt.Sprint(u.Attrs), Evaluate(r, u))
	}
	r.Enabled = false
	fmt.Printf("kill switch on, enterprise user -> %+v\n", Evaluate(r, User{"u1", map[string]string{"plan": "enterprise"}}))
}
Text
rollout 10%: 1021 of 10000 users, 25%: 2492, 50%: 4989
users on at 10% who stayed on at 25% and 50%: 1021 of 1021
users in both the new-checkout and dark-mode 10% groups: 93 (about 100 if independent)
u1  map[plan:enterprise]               -> {On:true Reason:rule 0 (plan=enterprise)}
u2  map[plan:free]                     -> {On:false Reason:default rollout}
u3  map[country:NZ plan:free]          -> {On:true Reason:rule 1 (country=NZ)}
u4  map[country:AU plan:free]          -> {On:false Reason:rule 1 matched but user is outside its rollout}
kill switch on, enterprise user -> {On:false Reason:kill switch}

The first line shows the rollout is close to the target: 1,021 users at 10 percent, 2,492 at 25 and 4,989 at 50. A hash does not give exactly the requested share, and for large populations the error is small. For tiny populations (twenty users at 10 percent), you will not get exactly two, which is why percentage rollouts are meaningless without enough traffic.

The second line shows stability: of the 1,021 users enabled at 10 percent, all 1,021 were still enabled at 25 and at 50. That property is what makes gradual rollouts safe. A user does not see the feature appear, disappear and appear again as you ramp up. It follows directly from bucket < percent with a fixed bucket; a design that re-rolled a random number on each request would flicker.

The third line checks independence between flags. Two flags at 10 percent overlap on 93 users out of 10,000. If they were independent, you would expect about 100 (10 percent of 10 percent of 10,000). Without the key in the hash, the overlap would be all 1,021.

The last lines show the rules working. The beta-reports flag has a default rollout of zero. An enterprise user matches the first rule at 100 percent and gets the flag. A free user with no country matches nothing and falls to the default (off). A New Zealand user matches the second rule, whose rollout is 50 percent, and happens to fall in the on half; an Australian user matches the same rule and falls in the off half, with a reason that says so. The explanation in Decision.Reason is not decoration: when someone asks why they do not see a feature, the answer should be visible without reading code. Finally, flipping Enabled to false makes the enterprise user's decision kill switch.

The HTTP service

Two surfaces with different audiences should be separate routes with different protection.

server.goGo
package main

import (
	"encoding/json"
	"net/http"
)

func NewServer(s *Store, adminToken string) http.Handler {
	mux := http.NewServeMux()
	write := func(w http.ResponseWriter, status int, v any) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		json.NewEncoder(w).Encode(v)
	}
	admin := func(next http.HandlerFunc) http.HandlerFunc {
		return func(w http.ResponseWriter, r *http.Request) {
			if r.Header.Get("Authorization") != "Bearer "+adminToken {
				write(w, http.StatusUnauthorized, map[string]string{"error": "admin token required"})
				return
			}
			next(w, r)
		}
	}

	// Management API: protected, writes flags.
	mux.HandleFunc("PUT /admin/flags/{key}", admin(func(w http.ResponseWriter, r *http.Request) {
		var f Flag
		dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14))
		dec.DisallowUnknownFields()
		if dec.Decode(&f) != nil {
			write(w, http.StatusBadRequest, map[string]string{"error": "invalid body"})
			return
		}
		f.Key = r.PathValue("key")
		saved, err := s.Put(f)
		if err != nil {
			write(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
			return
		}
		write(w, http.StatusOK, saved)
	}))

	// Evaluation API: what applications call. The service decides; clients never see the rules.
	mux.HandleFunc("POST /v1/evaluate/{key}", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			UserID string            `json:"user_id"`
			Attrs  map[string]string `json:"attrs"`
		}
		if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<12)).Decode(&in) != nil || in.UserID == "" {
			write(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
			return
		}
		f, ok := s.Get(r.PathValue("key"))
		if !ok { // an unknown flag is off, so code can ship before the flag exists
			write(w, http.StatusOK, Decision{false, "unknown flag"})
			return
		}
		d := Evaluate(f, User{in.UserID, in.Attrs})
		w.Header().Set("X-Flag-Version", itoa(f.Version))
		write(w, http.StatusOK, d)
	})
	return mux
}

The admin API (PUT /admin/flags/{key}) writes flags and needs a bearer token. It decodes strictly, validates the percentages and rules, and returns the saved flag with a version that increases on every save. Versions give you an audit trail of changes and a way to detect stale writers: a more careful design takes an If-Match version and returns 409 on mismatch. Compare tokens in constant time in real code (subtle.ConstantTimeCompare); the lab compares strings for brevity.

The evaluation API (POST /v1/evaluate/{key}) is what applications call. The client sends a user id and attributes; the service returns on or off and a reason. The rules never leave the service, which matters for two reasons. Rules can mention things you do not want in a mobile app's binary (customer names, internal plans). And the logic lives in one place, so all clients agree even when they are written in different languages.

An unknown flag evaluates to off. This lets developers ship code that checks a flag before anyone has created it, and makes a typo in a key fail safe. Log unknown evaluations so the typo does not stay hidden.

Tests

Three tests cover the contract:

server_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func call(t *testing.T, h http.Handler, method, path, token, body string) (int, string) {
	t.Helper()
	req := httptest.NewRequest(method, path, strings.NewReader(body))
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec.Code, strings.TrimSpace(rec.Body.String())
}

func TestAdminAndEvaluate(t *testing.T) {
	h := NewServer(NewStore(), "s3cret")

	if code, _ := call(t, h, "PUT", "/admin/flags/new-ui", "", `{"enabled":true,"percent":100}`); code != 401 {
		t.Fatalf("no token = %d, want 401", code)
	}
	if code, _ := call(t, h, "PUT", "/admin/flags/new-ui", "s3cret", `{"enabled":true,"percent":150}`); code != 422 {
		t.Fatalf("bad percent = %d, want 422", code)
	}
	code, body := call(t, h, "PUT", "/admin/flags/new-ui", "s3cret", `{"enabled":true,"percent":100}`)
	if code != 200 || !strings.Contains(body, `"version":1`) {
		t.Fatalf("create = %d %s", code, body)
	}
	if _, body := call(t, h, "PUT", "/admin/flags/new-ui", "s3cret", `{"enabled":true,"percent":100}`); !strings.Contains(body, `"version":2`) {
		t.Fatalf("second save should be version 2: %s", body)
	}

	_, body = call(t, h, "POST", "/v1/evaluate/new-ui", "", `{"user_id":"u1"}`)
	if !strings.Contains(body, `"on":true`) {
		t.Fatalf("evaluate = %s", body)
	}
	_, body = call(t, h, "POST", "/v1/evaluate/never-defined", "", `{"user_id":"u1"}`)
	if !strings.Contains(body, `"on":false`) || !strings.Contains(body, "unknown flag") {
		t.Fatalf("unknown flag should be off: %s", body)
	}
	if code, _ := call(t, h, "POST", "/v1/evaluate/new-ui", "", `{}`); code != 400 {
		t.Fatalf("missing user = %d, want 400", code)
	}
}

func TestEvaluationIsDeterministic(t *testing.T) {
	f := Flag{Key: "x", Enabled: true, Percent: 37}
	for i := 0; i < 200; i++ {
		u := User{ID: "user-" + itoa(i)}
		if Evaluate(f, u) != Evaluate(f, u) {
			t.Fatalf("user %s flipped between calls", u.ID)
		}
	}
}

func TestKillSwitchBeatsRules(t *testing.T) {
	f := Flag{Key: "x", Enabled: false, Percent: 100, Rules: []Rule{{Attr: "plan", In: []string{"enterprise"}, Percent: 100}}}
	if d := Evaluate(f, User{"u", map[string]string{"plan": "enterprise"}}); d.On || d.Reason != "kill switch" {
		t.Fatalf("got %+v", d)
	}
}
Shell
go test -race -count=1 -v . | grep -E '^(--- |ok|FAIL)' | sed -E 's/ \([0-9.]+s\)//'
Text
--- PASS: TestAdminAndEvaluate
--- PASS: TestEvaluationIsDeterministic
--- PASS: TestKillSwitchBeatsRules
ok  	example.test/lab	1.297s

The admin test walks the API: no token gives 401, a percentage of 150 gives 422, a valid save gives version 1 and a repeated save gives version 2. The evaluation checks cover an enabled flag, an unknown flag and a missing user id. The determinism test evaluates 200 users twice each and fails if any answer changes, which is the property the whole design depends on. The kill switch test confirms the precedence over a matching rule.

Using flags from application code

The call site matters as much as the service. A client wrapper should be small, safe by default and easy to fake in tests. Something like this works:

Go
type Flags interface {
	Enabled(ctx context.Context, key string, u User) bool
}

type httpFlags struct {
	base   string
	client *http.Client
	cache  sync.Map // key+user -> cached decision, with an expiry
}

func (f *httpFlags) Enabled(ctx context.Context, key string, u User) bool {
	ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
	defer cancel()
	d, err := f.evaluate(ctx, key, u)
	if err != nil {
		return false // the compiled-in default: new behaviour stays off when the flag service is unreachable
	}
	return d.On
}

Three habits are in that sketch. The timeout is tiny: a flag lookup that takes 400 ms is worse than the feature being off. The failure path returns the safe default instead of an error, so a flag service outage does not become an application outage. And the interface is defined by the code that uses it, so a test can pass fakeFlags{"new-ui": true} and exercise both branches without a server.

Keep the flag key in a constant next to the code that reads it, and read each flag once per request, near the top of the handler, and pass the answer down. A flag read in five places during one request can disagree with itself if the rollout changes mid-request, and the user sees half of a feature.

Planning a rollout

The mechanics make rollouts possible; the plan makes them safe. A typical ramp for a risky change:

  1. Staff only. A rule on an email_domain attribute at 100 percent, with the default at 0.
  2. One percent of users for a day, watching error rates and latency compared with the other 99 percent. This is where the stable bucketing matters, since it gives you a consistent comparison group.
  3. Ten percent, then 25, then 50, pausing at each step long enough for the metrics that matter to show a difference. Choose the metrics before the ramp, not after.
  4. One hundred percent, and leave the flag in place for a short period as the kill switch.
  5. Remove the flag and the old code path.

Decide in advance what would make you stop, in numbers: an error rate above X, p99 above Y. Wire those to alerts so the decision to roll back is made by the graph, not by whoever is watching. And tag logs and traces with the flag state, so you can ask "do errors differ between users with the flag on and off?" with a query instead of a guess (tracing makes this easy by adding the decision as a span attribute).

Targeting and privacy

Attributes are the part of flag systems that gets people in trouble. Everything you send in attrs is data about a user, passing through another service and possibly stored in its logs. Send the minimum: a plan name, a country code, a cohort label. Do not send emails or names when a hashed id or a segment would do. Keep user ids opaque. And do not log attribute maps in the service by default.

Rules based on attributes can also create fairness and legal issues if they are used to withhold features from groups of people. Keep targeting for operational reasons (staff, beta testers, plans, regions with data laws), and review rules the way you review code.

Testing code that depends on flags

Each flag creates two code paths, so test both. For a flag that guards new behaviour, write the test for the old behaviour first and keep it passing while the flag exists; add a test for the new behaviour with the fake returning true. When the rollout finishes and the flag is removed, delete the old test along with the old code path.

In the service itself, add tests for the properties the design depends on: determinism, stability when the percentage rises, independence across flags, kill switch precedence and rule ordering. The lab's tests cover the first and fourth; the stability and independence checks are in the run output, and turning them into assertions is a ten-line change. A property test over thousands of generated ids, which checks that the set enabled at P1 is a subset of the set enabled at P2 whenever P1 is below P2, is the most valuable test in the whole package.

What a production version needs

Persistence and change propagation. Store flags in a database and let each service instance cache them, refreshing on a short interval or on a notification. Server-sent events work well for pushing changes to instances, with polling as the fallback. Evaluating from an in-memory copy keeps the call local and fast, which is the key to being safe to put on the request path.

Local evaluation in clients. For very hot paths, ship an SDK that downloads the flag definitions and evaluates locally, so the service is not called per request. That requires being comfortable with rules visible to the client, and signing or authenticating the definitions.

Failure behaviour. Decide what an application does when it cannot reach the flag service. The common answer is to use the last known values, and then a default compiled into the code. Never make the flag service a hard dependency of a request, and set short timeouts (timeouts and retries).

Audit log. Record who changed which flag, when, and from what to what (audit trails). Flags are production changes.

Flag hygiene. Flags rot. Give each one an owner, a type (release, experiment, operational switch, permission) and an expected removal date, and delete the flag and the dead branch when the rollout is complete. A codebase with two hundred forgotten flags is a combinatorial testing problem.

Experiments. Percentage rollouts can drive A/B tests, but a test needs more: consistent assignment (this hashing provides it), exposure logging when a user actually sees a variant and statistics. Do not draw conclusions from flag evaluations alone.

Vendor-neutral clients. The OpenFeature specification defines a client API for evaluating flags so application code does not depend on one vendor. If you build a service, expose it behind that API where you can, or at least keep the evaluation call behind an interface in your own code. The specification is at openfeature.dev, and the hashing used here comes from the standard library's hash/fnv package.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.