← Back to list

Feature Toggles as a Deployment Strategy: Implementation Patterns

There’s a specific kind of dread I remember from my early career. It was a Friday afternoon, around 4 PM, and I was SSHing directly into a…

Erwin Hermanto · 2026-05-11 02:01 · 0 claps · 7.6 min read paywalled
#golang #software-development #feature-flags #strategy-deployment #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Feature Toggles as a Deployment Strategy: Implementation Patterns

There’s a specific kind of dread I remember from my early career. It was a Friday afternoon, around 4 PM, and I was SSHing directly into a production server to deploy a new feature for a client. No staging environment. No rollback plan. Just me, a terminal, and a prayer.

That was 2016. I was at a small company building government web systems, and “deployment” meant pulling from a Git repo on a live machine and hoping the .env file was already there. If something broke, we fixed it on the server in real time. It sounds insane now, but it was just the reality of how a lot of small teams operated back then.

What I didn’t have — what I didn’t even know I needed — was a way to separate deploying code from releasing features.

The Problem I Didn’t Know Had a Name

When I moved into product companies — first at Qasir building POS systems in Go and Laravel, then later at SiCepat and Bobobox — the deployments got more structured. We had Docker, CI/CD pipelines, and at least some concept of staging. But there was still this fundamental tension: you’d finish a feature, open a PR, it gets merged, it goes to production. Done.

Except… not really done. Because now if that feature had a bug, or if the business team wanted to “soft launch” it to 10% of users first, or if the feature was half-finished and you needed to keep shipping other things — you were stuck.

I remember the first time a PM at Qasir asked me, “Can we turn off the QRIS integration for merchants in this region temporarily without a new deployment?”

My honest answer was: no, not without a code change and a redeploy.

That was the moment I started taking feature toggles seriously.

What Feature Toggles Actually Are (And What They’re Not)

A feature toggle — sometimes called a feature flag — is just a conditional check in your code that controls whether a feature is active. At its simplest, it looks like this:

if config.FeatureEnabled("new_checkout_flow") {
    handler.NewCheckout(w, r)
} else {
    handler.LegacyCheckout(w, r)
}

Simple enough. But the architecture around that conditional is what separates a clean system from toggle hell. Because done badly, you end up with a codebase full of if statements that nobody dares remove, and the feature itself becomes permanently toggled on but never cleaned up.

There are four main categories of toggles, and understanding which one you’re using matters:

┌─────────────────────────────────────────────────────────────┐
│              Feature Toggle Classification                   │
├──────────────────┬──────────────┬──────────────┬────────────┤
│ Type             │ Lifespan     │ Decision     │ Owner      │
├──────────────────┼──────────────┼──────────────┼────────────┤
│ Release Toggle   │ Days/weeks   │ Dev team     │ Engineer   │
│ Experiment Toggle│ Weeks        │ A/B result   │ Product    │
│ Ops Toggle       │ Long-lived   │ Runtime ops  │ Ops/SRE    │
│ Permission Toggle│ Permanent    │ User tier    │ Business   │
└──────────────────┴──────────────┴──────────────┴────────────┘

When I was building the API Gateway at POSFIN, we had all four in play at the same time. Release toggles for new gRPC endpoints still under testing. An ops toggle for circuit-breaking a third-party payment service that was flaky. And permission toggles for gating premium features by account tier. Treating them all the same would have been a mess.

Building a Toggle System in Go

When I built the internal toggle infrastructure at POSFIN, I wanted something lightweight that didn’t need a third-party service to operate — at least not initially. Here’s a simplified version of the core pattern I used.

The Toggle Interface

package toggle

import "context"

type Evaluator interface {
    IsEnabled(ctx context.Context, key string) bool
    IsEnabledFor(ctx context.Context, key string, userID string) bool
}

Keeping it an interface from day one meant we could swap the backing store — config file, Redis, a dedicated service — without touching the call sites.

A Config-Backed Implementation

For early-stage use, a YAML config file is honestly fine. It’s simple, version-controlled, and ops-friendly.

package toggle

import (
    "context"
    "sync"

    "gopkg.in/yaml.v3"
    "os"
)

type ToggleConfig struct {
    Toggles map[string]ToggleDefinition `yaml:"toggles"`
}

type ToggleDefinition struct {
    Enabled     bool     `yaml:"enabled"`
    Allowlist   []string `yaml:"allowlist,omitempty"`
    Rollout     int      `yaml:"rollout,omitempty"` // percentage 0-100
}

type ConfigEvaluator struct {
    mu     sync.RWMutex
    config ToggleConfig
}

func NewConfigEvaluator(path string) (*ConfigEvaluator, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    var cfg ToggleConfig
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }

    return &ConfigEvaluator{config: cfg}, nil
}

func (e *ConfigEvaluator) IsEnabled(ctx context.Context, key string) bool {
    e.mu.RLock()
    defer e.mu.RUnlock()

    def, ok := e.config.Toggles[key]
    if !ok {
        return false
    }
    return def.Enabled
}

The corresponding toggles.yaml looks like this:

toggles:
  new_checkout_flow:
    enabled: true

  experimental_recommendation_engine:
    enabled: false

  grpc_gateway_v2:
    enabled: true
    rollout: 20  # only 20% of traffic

Rollout-Based Evaluation

Percentage rollouts are where it gets interesting. The trick is to make the evaluation deterministic for a given user — so they don’t flip between experiences on every request.

import (
    "crypto/sha256"
    "encoding/binary"
    "fmt"
)

func (e *ConfigEvaluator) IsEnabledFor(ctx context.Context, key string, userID string) bool {
    e.mu.RLock()
    defer e.mu.RUnlock()

    def, ok := e.config.Toggles[key]
    if !ok {
        return false
    }

    if !def.Enabled {
        return false
    }

    // Allowlist check
    for _, id := range def.Allowlist {
        if id == userID {
            return true
        }
    }

    // Percentage rollout — deterministic per user+key
    if def.Rollout > 0 && def.Rollout < 100 {
        hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", key, userID)))
        bucket := binary.BigEndian.Uint64(hash[:8]) % 100
        return int(bucket) < def.Rollout
    }

    return def.Enabled
}

This means user "user-123" always ends up in the same bucket for toggle "new_checkout_flow". Stable, reproducible, no session state needed.

Wiring It Into Your HTTP Handlers

At Bobobox, when we were building the Property Management System, I found it cleanest to inject the evaluator at the handler level rather than deep inside business logic. It keeps the business layer clean and makes testing trivial.

type CheckoutHandler struct {
    toggles toggle.Evaluator
    svc     CheckoutService
}

func (h *CheckoutHandler) Handle(w http.ResponseWriter, r *http.Request) {
    userID := auth.UserIDFromContext(r.Context())

    if h.toggles.IsEnabledFor(r.Context(), "new_checkout_flow", userID) {
        h.svc.ProcessV2(w, r)
        return
    }

    h.svc.ProcessV1(w, r)
}

Clean. The handler doesn’t care how the toggle is evaluated. It just asks the question and moves on.

The Part Nobody Talks About: Removal Automation

Here’s the thing that almost nobody talks about in feature toggle tutorials: toggles accumulate. I’ve seen codebases where 60% of the if statements are dead toggle checks for features that shipped two years ago. It becomes genuinely risky to remove them because nobody's sure what's still load-bearing.

The discipline I brought in at eDOT when I became Squad Leader was to treat toggle removal as a first-class engineering task — not an afterthought.

The first step is just tagging your toggles with metadata:

type ToggleDefinition struct {
    Enabled       bool      `yaml:"enabled"`
    Rollout       int       `yaml:"rollout,omitempty"`
    Allowlist     []string  `yaml:"allowlist,omitempty"`
    ExpiresAt     string    `yaml:"expires_at,omitempty"` // ISO 8601
    Owner         string    `yaml:"owner"`
    Description   string    `yaml:"description"`
}
toggles:
  new_checkout_flow:
    enabled: true
    expires_at: "2025-03-01"
    owner: "platform-team"
    description: "New checkout UX for mobile users, replacing v1 flow"

Then a simple script run in CI can flag expired toggles:

package main

import (
    "fmt"
    "os"
    "time"

    "gopkg.in/yaml.v3"
)

type ToggleConfig struct {
    Toggles map[string]ToggleDef `yaml:"toggles"`
}

type ToggleDef struct {
    Enabled     bool   `yaml:"enabled"`
    ExpiresAt   string `yaml:"expires_at,omitempty"`
    Owner       string `yaml:"owner"`
    Description string `yaml:"description"`
}

func main() {
    data, _ := os.ReadFile("toggles.yaml")
    var cfg ToggleConfig
    yaml.Unmarshal(data, &cfg)

    now := time.Now()
    expired := 0

    for name, def := range cfg.Toggles {
        if def.ExpiresAt == "" {
            continue
        }
        exp, err := time.Parse("2006-01-02", def.ExpiresAt)
        if err != nil {
            continue
        }
        if now.After(exp) {
            fmt.Printf("⚠️  EXPIRED TOGGLE: %s (owner: %s)\n", name, def.Owner)
            expired++
        }
    }

    if expired > 0 {
        fmt.Printf("\n%d expired toggle(s) found. Please clean up before merging.\n", expired)
        os.Exit(1)
    }

    fmt.Println("✅ All toggles are within their expiry dates.")
}

We put this in the CI pipeline. If a toggle is past its expiry and nobody’s acted on it, the build fails. It sounds harsh, but it turned toggle hygiene from a “someday” task into something the team actually did.

The lifecycle looks like this:

[Feature Planned]
        │
        ▼
  [Toggle Created]  ── expires_at set, owner assigned
        │
        ▼
  [Toggle Enabled]  ── gradual rollout: 5% → 20% → 100%
        │
        ▼
  [Fully Rolled Out]
        │
        ▼
  [Toggle Removed]  ── code cleaned up, YAML entry deleted
        │
        ▼
  [CI Verification] ── expired toggles block merge

Testing With Toggles

This is the other thing that gets messy fast. If you’re not careful, you end up with a combinatorial explosion of test cases — one for each toggle state. The approach I settled on was to test the toggle logic separately from the business logic.

// Test the business logic in isolation — no toggle awareness needed
func TestCheckoutServiceV2(t *testing.T) {
    svc := NewCheckoutService(mockPaymentGateway)
    result, err := svc.ProcessV2(testOrder)
    assert.NoError(t, err)
    assert.Equal(t, "completed", result.Status)
}

// Test the handler routing — mock the evaluator
func TestCheckoutHandlerRouting(t *testing.T) {
    tests := []struct {
        name          string
        toggleEnabled bool
        expectV2      bool
    }{
        {"toggle on routes to v2", true, true},
        {"toggle off routes to v1", false, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockEval := &MockEvaluator{enabled: tt.toggleEnabled}
            handler := &CheckoutHandler{toggles: mockEval, svc: mockCheckoutSvc}

            req := httptest.NewRequest("POST", "/checkout", nil)
            rr := httptest.NewRecorder()
            handler.Handle(rr, req)

            if tt.expectV2 {
                assert.True(t, mockCheckoutSvc.V2Called)
            } else {
                assert.True(t, mockCheckoutSvc.V1Called)
            }
        })
    }
}

The MockEvaluator is dead simple:

type MockEvaluator struct {
    enabled bool
}

func (m *MockEvaluator) IsEnabled(_ context.Context, _ string) bool {
    return m.enabled
}

func (m *MockEvaluator) IsEnabledFor(_ context.Context, _ string, _ string) bool {
    return m.enabled
}

By separating the toggle routing test from the actual business logic test, you keep both focused. The business logic test doesn’t care about flags. The routing test doesn’t care about payment logic.

Where This Fits in the Bigger Picture

Looking back at that Friday afternoon in 2016 — SSHing into a live server, praying nothing broke — the distance to where I am now isn’t just a better toolchain. It’s a different philosophy about what “deploying” means.

Back then, deploying was releasing. They were the same event. Everything that happened downstream — user impact, bug discovery, rollback — was uncontrollable once you hit git pull on that server.

Today, with containerized deployments on Kubernetes, CI/CD pipelines, and a proper toggle system, deploying is just moving code into production. Whether any user sees it is a separate, controlled decision. You can deploy on a Tuesday and flip the toggle on a Thursday after QA signs off. You can roll back a feature in 10 seconds without a redeployment. You can let your most trusted users test it while everyone else sees the old flow.

That separation — deploy vs. release — is honestly one of the biggest shifts in how I think about shipping software.

Without toggles:           With toggles:

  Code merged                Code merged
       │                          │
       ▼                          ▼
  Deployed to prod          Deployed to prod
       │                          │
       ▼                          ▼
  Users see it              Toggle stays off
       │                          │
  (no going back              QA validates
   without redeploy)             │
                                 ▼
                           Toggle → 10% rollout
                                 │
                                 ▼
                           Monitor metrics
                                 │
                                 ▼
                           Toggle → 100%
                                 │
                                 ▼
                           Clean up toggle

It’s not magic. It adds operational overhead — you need toggle hygiene discipline, expiry policies, and test coverage for both paths. But compared to the anxiety of a risky Friday deploy? That overhead feels like nothing.

If you’re still treating deployments and releases as the same thing, feature toggles are probably the highest-leverage practice you can introduce this quarter. Start simple — a YAML file and a clean interface is more than enough to begin. Build the discipline around removal and expiry from day one. Then let the complexity grow only as your needs demand it.

The goal isn’t a fancy toggle platform. The goal is control over when your users actually see your work.


메타데이터
post_id
dfc9b539b1e8
slug
feature-toggles-as-a-deployment-strategy-implementation-patterns-dfc9b539b1e8
url
https://medium.com/@erwindev/feature-toggles-as-a-deployment-strategy-implementation-patterns-dfc9b539b1e8
canonical_url
https://medium.com/@erwindev/feature-toggles-as-a-deployment-strategy-implementation-patterns-dfc9b539b1e8
author_url
https://medium.com/@erwindev
status
ok
fetched_at
2026-06-09 14:34:10