Skip to Content
Course content

228: Feature Flags in Go Services

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was on a team launching a new pricing engine for a B2B SaaS product. We spent three months building it, tested it in staging until we were blue in the face, and finally merged it into the main branch. The moment we deployed to production, we realized we'd missed a critical edge case for customers with grandfathered legacy contracts. The site started throwing 500 errors for our biggest spenders. We had to scramble to revert the commit, wait for the CI/CD pipeline to build and deploy the old version, and pray that no data had been corrupted in those ten minutes of chaos. It was a stressful morning, and it taught me a lesson I'll never forget: deploying code should not be the same thing as releasing a feature.

Feature flags—or toggles—allow you to decouple those two events. You deploy the code to production, but it stays dormant. You then "flip a switch" (via a config file, a database entry, or a third-party service) to enable the feature for specific users or a small percentage of traffic. If things go south, you flip the switch back. No reverts, no emergency pipelines, and no panicked Slack messages from the CEO.

Decoupling Logic with the Provider Pattern

In Go, you don't want to scatter if flagEnabled { ... } blocks all over your business logic. That makes your code unreadable and incredibly hard to test. Instead, I recommend treating your feature flags as a dependency. By defining a FeatureProvider interface, you can swap out a hard-coded local implementation for a dynamic one (like LaunchDarkly or an internal API) without changing your core service logic.

type FeatureProvider interface {
    IsEnabled(featureKey string, userID string) bool
}

type PaymentService struct {
    flags    FeatureProvider
    legacy   PaymentProcessor
    modern   PaymentProcessor
}

func (s *PaymentService) Process(userID string, amount float64) error {
    // Instead of a hardcoded boolean, we ask the provider
    if s.flags.IsEnabled("use-modern-gateway", userID) {
        return s.modern.Charge(amount)
    }
    return s.legacy.Charge(amount)
}

Notice how the PaymentService doesn't actually know how the flag is determined. It just knows that for a given user, the feature is either on or off. This makes your unit tests trivial: you can just pass a mock provider that returns true for one test and false for another.

Handling Dynamic State and Concurrency

When you start moving flags out of static config files and into a database or an external API, you run into a classic Go problem: concurrency. You can't just read a map from a remote server on every single request; you'll kill your performance and likely hammer your API limits. I usually implement a local cache with a background goroutine that refreshes the flag states every few minutes.

You'll want to use a sync.RWMutex to ensure that your service can read flags concurrently while the background updater is writing to the cache. If you're building something at massive scale, you might look into atomic.Value to store the entire configuration map, which allows for lock-free reads.

type LocalFlagProvider struct {
    mu    sync.RWMutex
    flags map[string]bool
}

func (p *LocalFlagProvider) IsEnabled(key string, _ string) bool {
    p.mu.RLock()
    defer p.mu.RUnlock()
    return p.flags[key]
}

func (p *LocalFlagProvider) updateFlags(newFlags map[string]bool) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.flags = newFlags
}

One warning: feature flags are technical debt by design. If you leave a flag in the code after a feature is 100% rolled out, you're just adding cognitive load for the next developer. I make it a rule to create a "cleanup ticket" in the backlog the moment the feature flag is created. If you don't, your codebase will eventually look like a jungle of dead if/else blocks.




📋 Practical Task

Exercise: Implementing a Canary Toggle for a User Profile Migration

You are migrating a user profile system from a legacy SQL table to a new Document store. You cannot switch everyone at once; you need to implement a "Canary" release where only users with an even-numbered UserID see the new profile system.

Requirements:

  • Create a FeatureProvider interface with an IsEnabled(featureKey, userID string) bool method.
  • Implement a CanaryProvider struct that satisfies this interface. The IsEnabled method should return true only if the featureKey is "use-new-profile-store" AND the userID (which is a numeric string) represents an even number.
  • Create a ProfileService that takes the FeatureProvider as a dependency. It should have a method GetProfile(userID string) that returns "Legacy Profile" or "Modern Profile" based on the flag.
  • Write a main function that tests this with at least two different UserIDs (one even, one odd) to verify the routing logic.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.