Feature Flags: Trunk-Based Development Support
Trunk-based development promises faster integration, reduced merge conflicts, and continuous delivery. The core principle is simple: developers commit directly to the main branch (or merge...
Key Insights
- Feature flags decouple deployment from release, enabling trunk-based development teams to merge incomplete features to main without exposing them to users, eliminating long-lived feature branches and merge conflicts.
- Effective feature flag systems require thoughtful architecture—from simple configuration files for static toggles to service-based approaches for dynamic targeting, percentage rollouts, and A/B testing at scale.
- Feature flags accumulate as technical debt if not actively managed; establish naming conventions, expiration policies, and automated detection of stale flags to prevent your codebase from becoming a maze of conditional logic.
Introduction to Trunk-Based Development Challenges
Trunk-based development promises faster integration, reduced merge conflicts, and continuous delivery. The core principle is simple: developers commit directly to the main branch (or merge short-lived branches within a day). But this creates an immediate problem—what do you do with incomplete features that aren’t ready for users?
Traditional approaches fail here. Long-lived feature branches defeat the purpose of trunk-based development. Code freezes slow down the entire team. Commenting out code is fragile and unprofessional. The solution is feature flags: runtime controls that let you deploy code to production while keeping features hidden until they’re ready.
Feature flags transform deployment into a business decision rather than a technical one. Your incomplete feature lives in production, fully deployed, but invisible to users until you flip a switch. This is the foundation of continuous delivery at scale.
Feature Flag Fundamentals
A feature flag is a conditional statement that controls code execution at runtime. At its simplest, it’s a boolean check that determines which code path to execute.
func HandleCheckout(user User, cart Cart) error {
if featureFlags.IsEnabled("new-payment-flow") {
return processCheckoutV2(user, cart)
}
return processCheckoutV1(user, cart)
}
Feature flags fall into several categories, each serving different purposes:
Release toggles control incomplete features during development. These are temporary and should be removed once the feature is fully released. They’re your primary tool for trunk-based development.
Experiment toggles support A/B testing and multivariate experiments. These help you validate hypotheses with real user data before committing to a direction.
Ops toggles provide operational control, letting you disable expensive features during high load or gracefully degrade functionality when dependencies fail. Think of these as circuit breakers for features.
Permission toggles enable premium features or control access for specific user segments. These tend to be long-lived and become part of your product’s business logic.
The distinction matters because it affects lifecycle management. Release toggles should die quickly. Permission toggles might live forever.
Implementing a Feature Flag System
Start simple, but design for growth. A feature flag system needs three components: storage, evaluation, and a client interface.
For small teams or projects, configuration files work fine:
# features.yaml
features:
new-payment-flow:
enabled: false
description: "Redesigned checkout with one-click payment"
created: "2024-01-15"
expires: "2024-03-15"
experimental-search:
enabled: true
rollout_percentage: 25
description: "ML-powered search ranking"
premium-analytics:
enabled: true
required_tier: "enterprise"
The service interface should abstract the complexity:
type FeatureFlagService interface {
IsEnabled(flagName string, context EvaluationContext) bool
GetVariant(flagName string, context EvaluationContext) string
}
type EvaluationContext struct {
UserID string
Email string
AccountTier string
CustomAttrs map[string]interface{}
}
type ConfigFeatureFlags struct {
config map[string]FlagConfig
}
func (f *ConfigFeatureFlags) IsEnabled(flagName string, ctx EvaluationContext) bool {
flag, exists := f.config[flagName]
if !exists {
return false // Safe default
}
if !flag.Enabled {
return false
}
// Check percentage rollout
if flag.RolloutPercentage > 0 {
userHash := hashUserID(ctx.UserID)
if userHash > flag.RolloutPercentage {
return false
}
}
// Check tier requirements
if flag.RequiredTier != "" && ctx.AccountTier != flag.RequiredTier {
return false
}
return true
}
This pattern scales from configuration files to Redis to dedicated feature flag services like LaunchDarkly or Flagsmith without changing your application code.
Advanced Patterns for Trunk-Based Development
Trunk-based development thrives on progressive rollouts. You don’t flip a feature from 0% to 100% overnight. You start with internal users, expand to beta testers, then gradually increase the percentage.
type RolloutStrategy struct {
Stages []RolloutStage
}
type RolloutStage struct {
Percentage int
Duration time.Duration
Segments []string
}
func (s *RolloutStrategy) GetCurrentPercentage() int {
// Automatically progress through stages
elapsed := time.Since(s.startTime)
var cumulative time.Duration
for _, stage := range s.Stages {
cumulative += stage.Duration
if elapsed < cumulative {
return stage.Percentage
}
}
return 100 // Fully rolled out
}
User segmentation gives you surgical control:
func (f *ConfigFeatureFlags) IsEnabled(flagName string, ctx EvaluationContext) bool {
flag := f.config[flagName]
// Always enable for internal employees
if strings.HasSuffix(ctx.Email, "@yourcompany.com") {
return true
}
// Beta users get early access
if flag.BetaOnly && ctx.CustomAttrs["beta_user"] == true {
return true
}
// Percentage-based rollout for everyone else
if flag.RolloutPercentage > 0 {
userBucket := hashUserID(ctx.UserID) % 100
return userBucket < flag.RolloutPercentage
}
return flag.Enabled
}
func hashUserID(userID string) int {
h := fnv.New32a()
h.Write([]byte(userID))
return int(h.Sum32() % 100)
}
The hash function ensures consistency—the same user always gets the same result, preventing flickering experiences.
Managing Feature Flag Lifecycle
Feature flags are technical debt by design. Every flag doubles your code paths and increases complexity. The key is aggressive cleanup.
Establish naming conventions that encode metadata:
release_<feature>_<date> // release_new_checkout_2024_01
experiment_<name>_<date> // experiment_search_ranking_2024_01
ops_<system> // ops_disable_recommendations
perm_<tier>_<feature> // perm_enterprise_analytics
The prefix tells you the lifecycle. The date tells you when it’s stale.
Automate detection of removable flags:
import ast
import os
from datetime import datetime, timedelta
def find_stale_flags(codebase_path, flag_config):
"""Find feature flags that can be removed"""
stale_flags = []
for flag_name, config in flag_config.items():
# Check if flag is fully rolled out
if config.get('rollout_percentage') == 100:
days_at_100 = (datetime.now() - config['rollout_complete_date']).days
if days_at_100 > 14:
stale_flags.append({
'name': flag_name,
'reason': 'Fully rolled out for 14+ days',
'usage_count': count_flag_usage(codebase_path, flag_name)
})
# Check expiration dates
if 'expires' in config:
if datetime.now() > config['expires']:
stale_flags.append({
'name': flag_name,
'reason': 'Past expiration date',
'usage_count': count_flag_usage(codebase_path, flag_name)
})
return stale_flags
Set expiration dates when creating flags. Make flag removal part of your definition of done. Treat flag cleanup as seriously as you treat feature development.
Testing with Feature Flags
Feature flags create branching logic that must be tested. Don’t fall into the trap of only testing the enabled state.
func TestCheckoutFlow(t *testing.T) {
tests := []struct {
name string
flagState map[string]bool
expectedFlow string
}{
{
name: "new payment flow enabled",
flagState: map[string]bool{"new-payment-flow": true},
expectedFlow: "v2",
},
{
name: "new payment flow disabled",
flagState: map[string]bool{"new-payment-flow": false},
expectedFlow: "v1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flags := NewMockFeatureFlags(tt.flagState)
result := HandleCheckout(testUser, testCart, flags)
assert.Equal(t, tt.expectedFlow, result.Version)
})
}
}
Use dependency injection to make flags testable:
type CheckoutService struct {
flags FeatureFlagService
}
func NewCheckoutService(flags FeatureFlagService) *CheckoutService {
return &CheckoutService{flags: flags}
}
This lets you inject mock flag services in tests while using real implementations in production.
Production Considerations
Feature flag evaluation happens on every request. Performance matters. Cache evaluation results when possible:
type CachedFeatureFlags struct {
upstream FeatureFlagService
cache *sync.Map
ttl time.Duration
}
func (c *CachedFeatureFlags) IsEnabled(flagName string, ctx EvaluationContext) bool {
cacheKey := fmt.Sprintf("%s:%s", flagName, ctx.UserID)
if cached, ok := c.cache.Load(cacheKey); ok {
entry := cached.(cacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.value
}
}
value := c.upstream.IsEnabled(flagName, ctx)
c.cache.Store(cacheKey, cacheEntry{
value: value,
expiresAt: time.Now().Add(c.ttl),
})
return value
}
Implement circuit breakers for flag service failures:
func (f *RemoteFeatureFlags) IsEnabled(flagName string, ctx EvaluationContext) bool {
if f.circuitBreaker.IsOpen() {
// Service is down, use safe defaults
return f.getDefaultValue(flagName)
}
result, err := f.fetchFromService(flagName, ctx)
if err != nil {
f.circuitBreaker.RecordFailure()
return f.getDefaultValue(flagName)
}
f.circuitBreaker.RecordSuccess()
return result
}
Instrument flag evaluations for observability:
func (f *InstrumentedFeatureFlags) IsEnabled(flagName string, ctx EvaluationContext) bool {
start := time.Now()
result := f.upstream.IsEnabled(flagName, ctx)
metrics.RecordFeatureFlagEvaluation(flagName, result, time.Since(start))
if result {
log.Debug("feature flag enabled",
"flag", flagName,
"user", ctx.UserID)
}
return result
}
Track which flags are actually being used in production. This data informs cleanup decisions and helps identify flags that can be removed.
Feature flags are the infrastructure that makes trunk-based development practical. They let you merge fearlessly, deploy continuously, and release deliberately. Master them, and you’ll never go back to feature branches.