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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.