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…
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