Back to Journal
SaaS Engineering

Complete Guide to Feature Flag Architecture with Go

A comprehensive guide to implementing Feature Flag Architecture using Go, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 13 min read

Go's simplicity and performance make it an excellent foundation for feature flag systems that need to evaluate millions of flags per second with minimal overhead. This guide covers building a production-ready feature flag architecture in Go, from core evaluation logic to distributed sync and targeting rules.

Core Flag Evaluation Engine

The evaluation engine is the hot path — every API request evaluates multiple flags. It must be lock-free on the read path and allocation-minimal:

go
1package flags
2 
3import (
4 "crypto/sha256"
5 "encoding/binary"
6 "sync/atomic"
7)
8 
9type FlagConfig struct {
10 Key string `json:"key"`
11 Enabled bool `json:"enabled"`
12 Percentage float64 `json:"percentage,omitempty"`
13 Rules []Rule `json:"rules,omitempty"`
14 Variants []Variant `json:"variants,omitempty"`
15 DefaultVar string `json:"default_variant,omitempty"`
16}
17 
18type Rule struct {
19 Conditions []Condition `json:"conditions"`
20 Variant string `json:"variant"`
21 Priority int `json:"priority"`
22}
23 
24type Condition struct {
25 Attribute string `json:"attribute"`
26 Operator string `json:"operator"` // eq, neq, in, contains, gt, lt
27 Values []string `json:"values"`
28}
29 
30type Variant struct {
31 Key string `json:"key"`
32 Weight float64 `json:"weight"`
33}
34 
35type EvalContext struct {
36 UserID string
37 Email string
38 Plan string
39 Country string
40 Properties map[string]string
41}
42 
43type Evaluator struct {
44 config atomic.Pointer[map[string]FlagConfig]
45}
46 
47func NewEvaluator() *Evaluator {
48 empty := make(map[string]FlagConfig)
49 e := &Evaluator{}
50 e.config.Store(&empty)
51 return e
52}
53 
54func (e *Evaluator) Update(configs []FlagConfig) {
55 m := make(map[string]FlagConfig, len(configs))
56 for _, c := range configs {
57 m[c.Key] = c
58 }
59 e.config.Store(&m)
60}
61 
62func (e *Evaluator) IsEnabled(flagKey string, ctx EvalContext) bool {
63 result := e.Evaluate(flagKey, ctx)
64 return result.Enabled
65}
66 
67type EvalResult struct {
68 Enabled bool
69 Variant string
70 Reason string
71}
72 
73func (e *Evaluator) Evaluate(flagKey string, ctx EvalContext) EvalResult {
74 configs := *e.config.Load()
75 flag, ok := configs[flagKey]
76 if !ok {
77 return EvalResult{Enabled: false, Reason: "flag_not_found"}
78 }
79 if !flag.Enabled {
80 return EvalResult{Enabled: false, Reason: "flag_disabled"}
81 }
82 
83 // Evaluate targeting rules in priority order
84 for _, rule := range flag.Rules {
85 if matchesAllConditions(rule.Conditions, ctx) {
86 return EvalResult{Enabled: true, Variant: rule.Variant, Reason: "rule_match"}
87 }
88 }
89 
90 // Percentage rollout
91 if flag.Percentage > 0 && flag.Percentage < 100 {
92 bucket := hashBucket(flagKey, ctx.UserID)
93 if bucket < flag.Percentage {
94 variant := selectVariant(flag.Variants, flagKey, ctx.UserID)
95 return EvalResult{Enabled: true, Variant: variant, Reason: "percentage_rollout"}
96 }
97 return EvalResult{Enabled: false, Reason: "percentage_excluded"}
98 }
99 
100 return EvalResult{Enabled: true, Variant: flag.DefaultVar, Reason: "default_enabled"}
101}
102 
103func hashBucket(flagKey, userID string) float64 {
104 h := sha256.Sum256([]byte(flagKey + ":" + userID))
105 val := binary.BigEndian.Uint32(h[:4])
106 return float64(val) / float64(^uint32(0)) * 100
107}
108 

Using atomic.Pointer allows lock-free reads on the evaluation path — readers never block, even during config updates. The Update method creates a new map and atomically swaps the pointer, providing safe concurrent access without mutexes.

Condition Matching

go
1func matchesAllConditions(conditions []Condition, ctx EvalContext) bool {
2 for _, c := range conditions {
3 if !matchCondition(c, ctx) {
4 return false
5 }
6 }
7 return true
8}
9 
10func matchCondition(c Condition, ctx EvalContext) bool {
11 value := getAttributeValue(c.Attribute, ctx)
12 
13 switch c.Operator {
14 case "eq":
15 return len(c.Values) > 0 && value == c.Values[0]
16 case "neq":
17 return len(c.Values) > 0 && value != c.Values[0]
18 case "in":
19 for _, v := range c.Values {
20 if value == v {
21 return true
22 }
23 }
24 return false
25 case "contains":
26 return len(c.Values) > 0 && strings.Contains(value, c.Values[0])
27 case "starts_with":
28 return len(c.Values) > 0 && strings.HasPrefix(value, c.Values[0])
29 default:
30 return false
31 }
32}
33 
34func getAttributeValue(attr string, ctx EvalContext) string {
35 switch attr {
36 case "user_id":
37 return ctx.UserID
38 case "email":
39 return ctx.Email
40 case "plan":
41 return ctx.Plan
42 case "country":
43 return ctx.Country
44 default:
45 return ctx.Properties[attr]
46 }
47}
48 

Configuration Sync

The sync layer polls a management API and updates the evaluator:

go
1type SyncClient struct {
2 evaluator *Evaluator
3 apiURL string
4 httpClient *http.Client
5 interval time.Duration
6 etag string
7 logger *slog.Logger
8}
9 
10func NewSyncClient(evaluator *Evaluator, apiURL string, interval time.Duration, logger *slog.Logger) *SyncClient {
11 return &SyncClient{
12 evaluator: evaluator,
13 apiURL: apiURL,
14 httpClient: &http.Client{Timeout: 5 * time.Second},
15 interval: interval,
16 logger: logger,
17 }
18}
19 
20func (s *SyncClient) Run(ctx context.Context) error {
21 // Initial sync
22 if err := s.sync(ctx); err != nil {
23 return fmt.Errorf("initial sync failed: %w", err)
24 }
25 
26 ticker := time.NewTicker(s.interval)
27 defer ticker.Stop()
28 
29 for {
30 select {
31 case <-ctx.Done():
32 return nil
33 case <-ticker.C:
34 if err := s.sync(ctx); err != nil {
35 s.logger.Error("sync failed", "error", err)
36 }
37 }
38 }
39}
40 
41func (s *SyncClient) sync(ctx context.Context) error {
42 req, err := http.NewRequestWithContext(ctx, "GET", s.apiURL+"/api/flags", nil)
43 if err != nil {
44 return err
45 }
46 if s.etag != "" {
47 req.Header.Set("If-None-Match", s.etag)
48 }
49 
50 resp, err := s.httpClient.Do(req)
51 if err != nil {
52 return err
53 }
54 defer resp.Body.Close()
55 
56 if resp.StatusCode == http.StatusNotModified {
57 return nil // No changes
58 }
59 
60 var configs []FlagConfig
61 if err := json.NewDecoder(resp.Body).Decode(&configs); err != nil {
62 return fmt.Errorf("decode flags: %w", err)
63 }
64 
65 s.evaluator.Update(configs)
66 s.etag = resp.Header.Get("ETag")
67 s.logger.Info("flags synced", "count", len(configs))
68 return nil
69}
70 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

HTTP Middleware Integration

Flag evaluation in HTTP middleware attaches results to the request context:

go
1func FlagMiddleware(evaluator *Evaluator) func(http.Handler) http.Handler {
2 return func(next http.Handler) http.Handler {
3 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4 userID := r.Header.Get("X-User-ID")
5 plan := r.Header.Get("X-Plan")
6 
7 ctx := EvalContext{
8 UserID: userID,
9 Plan: plan,
10 Country: r.Header.Get("CF-IPCountry"),
11 }
12 
13 flags := &EvaluatedFlags{evaluator: evaluator, context: ctx}
14 r = r.WithContext(context.WithValue(r.Context(), flagsKey, flags))
15 next.ServeHTTP(w, r)
16 })
17 }
18}
19 
20type EvaluatedFlags struct {
21 evaluator *Evaluator
22 context EvalContext
23 cache map[string]EvalResult
24}
25 
26func (f *EvaluatedFlags) IsEnabled(flagKey string) bool {
27 if f.cache == nil {
28 f.cache = make(map[string]EvalResult)
29 }
30 if result, ok := f.cache[flagKey]; ok {
31 return result.Enabled
32 }
33 result := f.evaluator.Evaluate(flagKey, f.context)
34 f.cache[flagKey] = result
35 return result.Enabled
36}
37 
38func GetFlags(ctx context.Context) *EvaluatedFlags {
39 return ctx.Value(flagsKey).(*EvaluatedFlags)
40}
41 

The EvaluatedFlags struct caches results within a request — evaluating the same flag twice returns the cached result without recomputation.

Metrics and Observability

go
1var (
2 flagEvaluations = promauto.NewCounterVec(prometheus.CounterOpts{
3 Name: "feature_flag_evaluations_total",
4 }, []string{"flag", "result", "reason"})
5 
6 flagEvalDuration = promauto.NewHistogram(prometheus.HistogramOpts{
7 Name: "feature_flag_evaluation_duration_seconds",
8 Buckets: prometheus.ExponentialBuckets(0.0000001, 10, 6),
9 })
10)
11 
12func (e *Evaluator) EvaluateWithMetrics(flagKey string, ctx EvalContext) EvalResult {
13 start := time.Now()
14 result := e.Evaluate(flagKey, ctx)
15 flagEvalDuration.Observe(time.Since(start).Seconds())
16 flagEvaluations.WithLabelValues(flagKey, fmt.Sprintf("%t", result.Enabled), result.Reason).Inc()
17 return result
18}
19 

Testing

go
1func TestPercentageRollout(t *testing.T) {
2 evaluator := NewEvaluator()
3 evaluator.Update([]FlagConfig{
4 {Key: "test-flag", Enabled: true, Percentage: 50},
5 })
6 
7 enabled := 0
8 total := 10000
9 for i := 0; i < total; i++ {
10 ctx := EvalContext{UserID: fmt.Sprintf("user-%d", i)}
11 if evaluator.IsEnabled("test-flag", ctx) {
12 enabled++
13 }
14 }
15 
16 ratio := float64(enabled) / float64(total) * 100
17 if ratio < 48 || ratio > 52 {
18 t.Errorf("expected ~50%% rollout, got %.1f%%", ratio)
19 }
20}
21 
22func TestStickyBucketing(t *testing.T) {
23 evaluator := NewEvaluator()
24 evaluator.Update([]FlagConfig{
25 {Key: "test-flag", Enabled: true, Percentage: 50},
26 })
27 
28 ctx := EvalContext{UserID: "user-123"}
29 first := evaluator.IsEnabled("test-flag", ctx)
30 
31 for i := 0; i < 100; i++ {
32 if evaluator.IsEnabled("test-flag", ctx) != first {
33 t.Fatal("sticky bucketing violated")
34 }
35 }
36}
37 

Conclusion

Go's combination of performance, simplicity, and low resource overhead makes it the ideal language for feature flag evaluation — the component that sits in every request's critical path. The atomic pointer pattern enables lock-free reads, the standard library's crypto/sha256 provides deterministic bucketing, and Go's compilation to a single binary simplifies deployment.

The architecture separates evaluation (Go library embedded in your service) from management (separate service or third-party platform). This separation lets you optimize the hot path without compromising on the admin experience. Build the evaluator as a reusable Go module that any service can import, and sync flag configurations from whatever management tool your team prefers.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026