DEV Community

Josh Burgess
Josh Burgess

Posted on • Originally published at Medium on

Flipping Out: Feature Flags Without Frustration

Using Go Feature Flags with OpenFeature SDK in Kubernetes

Ever wonder why you get access to a shiny new feature in an app before your best friend does? Is it because you’re cooler? More influential? Smarter? Well… maybe. But more likely, the devs are running a feature flag experiment — and congrats, you’re in the test group.

This magical control over what users see, when they see it, and how it behaves is often called feature toggling , but let’s be real: “feature flags” just sounds cooler.

And now, I have a confession to make — and please don’t stop reading after this:

I think feature flags are cool. Like, sunglasses-emoji cool. 😎

But not everyone agrees. Some developers hear “feature flags” and immediately break into cold sweats. I get it. You’ve probably seen flags that were never cleaned up, led to duplicated logic, or straight-up broke production in the weirdest ways. Feature flag trauma is real. 💀

Fear not. Today, we’re diving into OpenFeature and Go Feature Flags (GoFF) — tools that’ll help you wield feature flags responsibly and maybe even have a little fun doing it.

I can tell you were starting to sweat with the size of this scroll bar. The bottom half of this post is an overkill of a tutorial where you will use both OpenFeature and GoFF with Mongo, Kafka, and Pinot to show how cool feature flags can be.

🚩What is a Feature Flag?

At its core, a feature flag is just a conditional switch in your app. Think:

if flag_is_on { show new thing } else { show old thing }

Sounds basic, right? But here’s the twist: flags can do way more than just flip features on or off. You can roll things out to users based on roles (hello QA team 👋), geography, app version, or even the phase of the moon (not recommended, but hey, you do you).

Usually, flags are evaluated by looking at request headers or context-specific data — things like user ID and account type.

🧰 Enter OpenFeature: The Great Unifier

There are tons of awesome feature flag providers out there — LaunchDarkly, PostHog, ConfigCat, GitLab, Vercel, the list goes on.

The problem? Each one comes with their own SDK, their own way of working, and their own learning curve. Switching providers can feel like changing banks — unnecessarily painful.

That’s where OpenFeature comes in. It’s like the universal remote for your feature flags. It gives you a consistent API, no matter what provider you’re using. Think of it as the Switzerland of feature flagging — neutral, helpful, and unexpectedly powerful.

Let’s look at how cleanly you can toggle between providers using OpenFeature.

import (
 "fmt"
 "context"

 "github.com/dhaus67/openfeature-posthog-go"
 "github.com/open-feature/go-sdk/openfeature"
 "github.com/posthog/posthog-go"
)

func main() {
 // Start by creating a PostHog client with your desired configuration.
 client, err := posthog.NewWithConifg("<your api key>", posthog.Config{})
 if err != nil {
  panic(err)
 }

 // Create the provider and register it.
 openfeature.SetProvider(openfeatureposthog.NewProvider(client))

 client := openfeature.NewClient("my-client")

 // The targeting key is required with this provider. It is used to evaluate with PostHog
 // whether for the specific user the feature is enabled or not.
 evalCtx := openfeature.NewEvaluationContext("<distinct-user-id>", map[string]interface{}{})

 secretFeature, err := client.BooleanValue(context.Background(), "secret", false, evalCtx)
 if err != nil {
  panic(err)
 }

 if secretFeature {
  fmt.Println("Secret feature is enabled")
 }
}
Enter fullscreen mode Exit fullscreen mode

Or if you wanted to switch to ConfigCat:

import (
 "fmt"
 "context"

  sdk "github.com/configcat/go-sdk/v9"
  configcat "github.com/open-feature/go-sdk-contrib/providers/configcat/pkg"
 "github.com/open-feature/go-sdk/openfeature"

)

func main() {
 // Start by creating a ConfigCat client with your desired configuration.
 provider := configcat.NewProvider(sdk.NewClient("..."))
 openfeature.SetProvider(provider)

 client := openfeature.NewClient("my-client")

 // The targeting key is required with this provider. It is used to evaluate with PostHog
 // whether for the specific user the feature is enabled or not.
 evalCtx := openfeature.NewEvaluationContext("<distinct-user-id>", map[string]interface{}{})

 secretFeature, err := client.BooleanValue(context.Background(), "secret", false, evalCtx)
 if err != nil {
  panic(err)
 }

 if secretFeature {
  fmt.Println("Secret feature is enabled")
 }
}
Enter fullscreen mode Exit fullscreen mode

Notice how nothing about your flag logic had to change? That’s the OpenFeature magic. Your business logic stays the same even if your boss decides to save money by switching providers mid-quarter. 😅

Oh — and you’re not locked into SaaS either. You can go DIY with YAML, env vars, or write your own provider if you’re feeling extra.

Why GoFF?

If you are evaluating building your own vs a hosted company, take a look at Go Feature Flags (GoFF). It began as an OSS project specific to Golang; however, they doubled down on OpenFeature and now can provide for most of the popular languages. 💪


https://gofeatureflag.org/docs/concepts/architecture

GoFF lets you use a relay proxy to evaluate flag states via YAML files stored pretty much anywhere:

  • Git
  • S3
  • Kubernetes ConfigMaps
  • MongoDB

And the cool bits don’t stop there. GoFF supports:

  • User bucketing (great for A/B testing or canary rollouts)
  • Progressive rollouts by time, percentage, or other logic
  • Metrics extraction so you can see what’s being used and make data-driven decisions instead of vibe-based ones

Basically, GoFF + OpenFeature gives you power and flexibility , without chaining you to a single vendor or requiring you to sell your soul for enterprise pricing.

💡Why Is This Data Useful?

“Data is the new oil.” — Clive Humby

“Without big data analytics, companies are blind and deaf, wandering out onto the Web like deer on a freeway.” — Geoffrey Moore

Remember when being an “ expert ” meant you could predict user behavior with confidence? Those were simpler times — back when you could rely on experience and intuition to guide product decisions, and stakeholders would trust your judgment completely.

Well, congratulations. The internet has democratized failure, and now your customers get to vote on whether your expertise was worth the pixels it’s printed on. 🥲

This is where Feature Flags become invaluable. They’re not just a deployment tool — they’re your experimentation infrastructure. 💡Feature flags allow you to test hypotheses with real users, measure actual behavior, and make decisions based on evidence rather than educated guesses.

Here’s why this matters: According to Microsoft, only about one-third of ideas actually improve the metrics they’re designed to impact. That means two-thirds of development effort could be better spent elsewhere. Feature flags help you identify winners before you’ve invested months of development time.

The beauty of feature flags lies in their flexibility. You can gradually roll out features to small user segments, measure impact, and either expand or rollback based on real performance data. This approach reduces risk dramatically — instead of betting the farm on a single launch, you’re making informed, incremental decisions.

Because nothing — and I mean nothing — hurts quite like spending three months crafting the perfect feature, complete with thoughtful animations and delightful micro-interactions, only to launch it and hear the deafening sound of… crickets. It’s like preparing an elaborate dinner party and watching everyone order pizza instead.

Shoutout

If you are looking for something to just easily start, PostHog has a growing suite of tools that let you focus on building a great product and leverage the power of gathering and processing data that can inform decisions. Using data let’s you find the mythical product market fit and excel your skills as a product focused engineer! I currently use them with my app Mudget.

Join Mudget today!

Mudget - Simplify Your Finances

Tutorial

This tutorial is going to be fun! 🐐

We are building a simple Go application programming interface (API) to handle List Create Read Update and Delete (L’CRUD) requests for feature flags in MongoDB. We will also pair this with a user interface (UI) written with Typescript in the Remix framework. Essentially two UI pages will be available, one is a user experience with feature flags — obviously with OpenFeature. The other is a feature flag admin page with flag key performance indicator (KPI) metrics which is collected and evaluated with the GoFF Relay Proxy. The metrics have to be exported somewhere so we are going overkill and sending them to Kafka , an event stream. The admins will want a pretty UI for the metrics, so let’s consume the stream and push to the Remix app with websockets!

You can reference my code here

Preparing

  • Kind (and kubetcl)
  • Helm
  • Golang
  • Node (pnpm or npm with it)
  • Docker

The Kind Cluster

First, we’ll use Kind — a tool for running local Kubernetes clusters inside Docker. Installing it with Go is easy:

go install sigs.k8s.io/kind@v0.26.0
Enter fullscreen mode Exit fullscreen mode

Once installed, create a kind config file to make port forwarding easy (may need to port forward instead if you are using WSL):

# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 1031
        hostPort: 1031
      - containerPort: 3001
        hostPort: 3001
Enter fullscreen mode Exit fullscreen mode

Then create a cluster like so:

kind create cluster --name feature-analytics --config=kind-config.yaml
Enter fullscreen mode Exit fullscreen mode

By default, this switches your kubectl context to the new Kind cluster. You can verify it’s running by checking all pods:

kubectl get pod -A
Enter fullscreen mode Exit fullscreen mode

📅Deploying the Supporting Services with Helm

We will be using MongoDB to store the configuration of the feature flags which let’s the Go Feature Flag Relay reference the current flag states when evaluating the flag for clients. We are using Kafka as the event stream for our flag analytic exports.

We will use this script to easily setup the helm charts and apply our values files:

#!/bin/bash
set -e

# Add Helm repos
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add go-feature-flag https://charts.gofeatureflag.org/
helm repo update

# Install Mongo
helm install mongo bitnami/mongodb -f values/mongo-values.yaml

# Install Kafka
helm install kafka bitnami/kafka -f values/kafka-values.yaml

# Install GoFF Relay
helm install relay-proxy go-feature-flag/relay-proxy -f values/relay-values.yaml
Enter fullscreen mode Exit fullscreen mode

Make sure to ensure this shell script is executable like so:

chmod +x deploy.sh
Enter fullscreen mode Exit fullscreen mode

Mongo values:

# values/mongo-values.yaml
# Authentication settings
auth:
  enabled: true
  rootPassword: password
  rootUser: root
  usernames:
    - example
  passwords:
    - flagged
  databases:
    - appConfig

# Persistent storage settings
persistence:
  enabled: true
  size: 4Gi
Enter fullscreen mode Exit fullscreen mode

Kafka values:

# values/kafka-values.yaml
replicaCount: 1
auth:
  enabled: false

zookeeper:
  enabled: true

listeners:
  client:
    protocol: PLAINTEXT

advertisedListeners:
  - name: CLIENT
    address: kafka.default.svc.cluster.local
    port: 9092

service:
  type: ClusterIP

extraEnvVars:
  - name: KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE
    value: "true"
Enter fullscreen mode Exit fullscreen mode

Relay values:

# values/relay-values.yaml
relayproxy:
  # -- GO Feature Flag relay proxy configuration as string (accept template).
  # -- uri could be referenced as a secret in a production level scenario
  config: | # This is a configuration example for the relay-proxy
    listen: 1031
    pollingInterval: 1000
    startWithRetrieverError: false
    logLevel: info
    retriever:
      kind: mongodb
      uri: mongodb://root:password@mongo-mongodb:27017/
      database: appConfig
      collection: featureFlags
    exporters:
      kind: kafka
      kafka:
        topic: "go-feature-flag-events"
        addresses:
          - "kafka.default.svc.cluster.local:9092"

service:
  type: NodePort # -- Change to ClusterIP if port forwarding
Enter fullscreen mode Exit fullscreen mode

And install them all with:

./deploy.sh
Enter fullscreen mode Exit fullscreen mode

Beware that this is not production ready values, you will need to secure these deployments if you like this solution. Our new services will now be available:

  • Kafka Broker (host): kafka.default.svc.cluster.local:9092

Making a Simple(ish*) App and Deploying It

Now my goal is to show off the cool feature flagging abilities, but it will involve a bit of follow along or just checking out my GitHub project I linked above.

In an app/ directory, we will build out a Gin API with a few endpoints for our client to interact with. Let’s initialize our new app:

cd app
go mod init github.com/<username>/flipping-out
go install github.com/air-verse/air@latest
air init
Enter fullscreen mode Exit fullscreen mode

And write our server, ideally in a non tutorial environment, you will not be hardcoding as much as I am here like the ports, username, passwords.

This is the file structure I am following:

// Tree
├── cmd
│ └── server.go
├── Dockerfile
├── go.mod
├── go.sum
├── internal
│ ├── api
│ │ └── v1
│ │ ├── controllers.go
│ │ ├── routes.go
│ │ ├── websockets.go
│ │ └── welcomeController.go
│ ├── app
│ │ └── app.go
│ ├── kafka
│ │ └── consumer.go
│ ├── models
│ │ └── feature_flags.go
│ ├── repositories
│ │ └── repositories.go
│ └── services
│ ├── services.go
│ ├── websocket.go
│ └── welcome.go
├── main.go
└── manifests
    └── deploy.yml
Enter fullscreen mode Exit fullscreen mode

The main function just starts the server, which the main logic (establishing the clients, registering routes, and starting the Gin server) is located in the server.go file.

// main.go
package main

import (
 "github.com/joshbrgs/flipping-out/cmd"
)

func main() {
 cmd.StartServer()
}

// cmd/server.go
package cmd

import (
 "context"
 "log"
 "time"

 "github.com/gin-contrib/cors"
 "github.com/gin-gonic/gin"
 v1 "github.com/joshbrgs/flipping-out/internal/api/v1"
 "github.com/joshbrgs/flipping-out/internal/app"
 gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg"
 of "github.com/open-feature/go-sdk/openfeature"
 "go.mongodb.org/mongo-driver/v2/mongo"
 "go.mongodb.org/mongo-driver/v2/mongo/options"
)

func StartServer() {
 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 defer cancel()

 // Creation of the GO Feature Flag provider to our relay proxy in kind
 provider, err := gofeatureflag.NewProvider(
  gofeatureflag.ProviderOptions{
   Endpoint: "http://go-feature-flag-relay-proxy:1031",
  })
 if err != nil {
  log.Fatalf("Failed to connect to Relay: %v", err)
 }

 // Setting the provider to the OpenFeature SDK
 err = of.SetProviderAndWait(provider)
 if err != nil {
  log.Fatalf("Failed set provider: %v", err)
 }
 ofClt := of.NewClient("my-openfeature-gin-client")

 // Connect to MongoDB
 mongoClient, err := mongo.Connect(options.Client().ApplyURI("mongodb://root:password@mongo-mongodb:27017"))
 if err != nil {
  log.Fatalf("Failed to connect to MongoDB: %v", err)
 }
 defer func() {
  if err := mongoClient.Disconnect(ctx); err != nil {
   panic(err)
  }
 }()

 // bootstrap services
 container := app.NewContainer(mongoClient, ofClt)

 // start application
 r := gin.Default()

 // CORS configuration
 r.Use(cors.New(cors.Config{
  AllowOrigins: []string{"http://localhost:5173"},
  AllowMethods: []string{"GET", "POST", "PATCH", "OPTIONS"},
  AllowHeaders: []string{"Origin", "Content-Type"},
  ExposeHeaders: []string{"Content-Length"},
  AllowCredentials: true,
  MaxAge: 12 * time.Hour,
 }))

 v1.RegisterRoutes(r, container)
 log.Println("Server started at :3001")

 r.Run(":3001")
}
Enter fullscreen mode Exit fullscreen mode

This is something I was trying out, a container that contains the dependencies for the other layers of the application.

// internal/app/app.go
package app

import (
 "github.com/joshbrgs/flipping-out/internal/repositories"
 "github.com/joshbrgs/flipping-out/internal/services"
 of "github.com/open-feature/go-sdk/openfeature"
 "go.mongodb.org/mongo-driver/v2/mongo"
)

type Container struct {
 MongoClient *mongo.Client
 FeatureClient *of.Client

 FeatureRepo repositories.FeatureFlagRepository
 FeatureService services.FeatureService
 WelcomeService services.WelcomeService
 WebsocketHub *services.Hub
}

func NewContainer(mongoClient *mongo.Client, featureClient *of.Client) *Container {
 repo := repositories.NewFeatureFlagRepository(mongoClient, "appConfig", "featureFlags")
 service := services.NewFeatureService(repo)
 welcomeService := services.NewWelcomeService()
 websocketHub := services.NewHub()

 return &Container{
  MongoClient: mongoClient,
  FeatureClient: featureClient,
  FeatureRepo: repo,
  FeatureService: service,
  WelcomeService: welcomeService,
  WebsocketHub: websocketHub,
 }
}
Enter fullscreen mode Exit fullscreen mode

The routes handles registering v1 routes and handlers that are part of the controller, isolating the HTTP logic and feature flag evaluations.

// internal/api/v1/routes.go
package v1

import (
 "github.com/gin-gonic/gin"
 "github.com/joshbrgs/flipping-out/internal/app"
)

func RegisterRoutes(r *gin.Engine, c *app.Container) {
 api := r.Group("/v1")

 registerFlagRoutes(api, c)
 registerUserRoutes(api, c)
 registerWebsockets(api, c)
}

func registerFlagRoutes(r *gin.RouterGroup, c *app.Container) {
 api := r.Group("/flags")

 flagController := NewFlagController(c.FeatureService, c.FeatureClient)

 api.GET("", flagController.getFlagsHandler)
 api.GET("/:id", flagController.getFlagHandler)
 api.POST("", flagController.createFlagHandler)
 api.PATCH("/:id", flagController.updateFlagHandler)
 api.DELETE("/:id", flagController.deleteFlagHandler)
}

func registerUserRoutes(r *gin.RouterGroup, c *app.Container) {
 api := r.Group("/welcome")

 exampleController := NewWelcomeController(c.WelcomeService, c.FeatureClient)

 api.GET("", exampleController.getWelcomeHandler)
}

func registerWebsockets(r *gin.RouterGroup, c *app.Container) {
 api := r.Group("/ws")

 websocketController := NewWebsocketController(c.WebsocketHub)

 api.GET("", websocketController.HandleWebsocket)
}

// internal/api/v1/controllers.go
package v1

import (
 "net/http"

 "github.com/gin-gonic/gin"
 "github.com/joshbrgs/flipping-out/internal/models"
 "github.com/joshbrgs/flipping-out/internal/services"
 of "github.com/open-feature/go-sdk/openfeature"
)

type FlagController struct {
 service services.FeatureService
 flagClient *of.Client
}

func NewFlagController(service services.FeatureService, flagClient *of.Client) *FlagController {
 return &FlagController{service: service, flagClient: flagClient}
}

func (fc *FlagController) getFlagsHandler(c *gin.Context) {
 flags, err := fc.service.GetAllFlags(c.Request.Context())
 if err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch flags", "err_detailed": err.Error()})
  return
 }
 c.JSON(http.StatusOK, flags)
}

func (fc *FlagController) getFlagHandler(c *gin.Context) {
 id := c.Param("id")
 flag, err := fc.service.GetFlagByID(c.Request.Context(), id)
 if err != nil {
  c.JSON(http.StatusNotFound, gin.H{"error": "flag not found", "err_detailed": err.Error()})
  return
 }
 c.JSON(http.StatusOK, flag)
}

func (fc *FlagController) createFlagHandler(c *gin.Context) {
 var flag models.FeatureFlag
 if err := c.ShouldBindJSON(&flag); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
  return
 }
 if err := fc.service.CreateFlag(c.Request.Context(), flag); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create flag", "err_detailed": err.Error()})
  return
 }
 c.JSON(http.StatusCreated, flag)
}

func (fc *FlagController) updateFlagHandler(c *gin.Context) {
 id := c.Param("id")
 var update models.FeatureFlag
 if err := c.ShouldBindJSON(&update); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
  return
 }
 if err := fc.service.UpdateFlag(c.Request.Context(), id, update); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update flag", "err_detailed": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "flag updated"})
}

func (fc *FlagController) deleteFlagHandler(c *gin.Context) {
 id := c.Param("id")
 if err := fc.service.DeleteFlag(c.Request.Context(), id); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete flag", "err_detailed": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "flag deleted"})
}

// internal/api/v1/welcomeController.go
package v1

import (
 "net/http"

 "github.com/gin-gonic/gin"
 "github.com/joshbrgs/flipping-out/internal/services"
 of "github.com/open-feature/go-sdk/openfeature"
)

type WelcomeController struct {
 service services.WelcomeService
 flagClient *of.Client
}

func NewWelcomeController(service services.WelcomeService, flagClient *of.Client) *WelcomeController {
 return &WelcomeController{service: service, flagClient: flagClient}
}

// Example of using a featureflag decoupled from the buisness logic
func (fc *WelcomeController) getWelcomeHandler(c *gin.Context) {
 welcomeMessage, _ := fc.flagClient.BooleanValue(c, "welcome-message", false, of.EvaluationContext{})
 user := c.GetHeader("x-example-header")

 if welcomeMessage {
  msg := fc.service.HelloWorld()
  c.JSON(http.StatusOK, msg)
 } else {
  msg := fc.service.HelloWorldAgain(user)
  c.JSON(http.StatusOK, msg)
 }
}

// internal/api/v1/websockets.go
package v1

import (
 "log"
 "net/http"
 "time"

 "github.com/gin-gonic/gin"
 "github.com/gorilla/websocket"
 "github.com/joshbrgs/flipping-out/internal/kafka"
 "github.com/joshbrgs/flipping-out/internal/services"
)

type WebsocketController struct {
 hub *services.Hub
}

func NewWebsocketController(hub *services.Hub) *WebsocketController {
 go hub.Run()
 go kafka.StartConsumer(hub)
 return &WebsocketController{hub: hub}
}

func (wc *WebsocketController) HandleWebsocket(c *gin.Context) {
 conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
 if err != nil {
  log.Printf("WebSocket upgrade error: %v", err)
  return
 }

 // Set connection limits and timeouts
 conn.SetReadLimit(512)
 conn.SetReadDeadline(time.Now().Add(60 * time.Second))
 conn.SetPongHandler(func(string) error {
  conn.SetReadDeadline(time.Now().Add(60 * time.Second))
  return nil
 })

 // Register connection
 wc.hub.Register(conn)

 ticker := time.NewTicker(30 * time.Second)
 defer ticker.Stop()

 // Handle websocket messages in goroutine
 go func() {
  defer func() {
   wc.hub.Unregister(conn)
   conn.Close()
  }()

  for {
   messageType, message, err := conn.ReadMessage()
   if err != nil {
    if websocket.IsUnexpectedCloseError(err,
     websocket.CloseGoingAway,
     websocket.CloseAbnormalClosure,
     websocket.CloseNoStatusReceived) {
     log.Printf("WebSocket unexpected close: %v", err)
    }
    break
   }

   switch messageType {
   case websocket.TextMessage:
    log.Printf("Received text message: %s", message)
   case websocket.BinaryMessage:
    log.Printf("Received binary message of length: %d", len(message))
   case websocket.CloseMessage:
    log.Println("Received close message")
    return
   }
  }
 }()

 // Ping loop
 go func() {
  defer ticker.Stop()
  for {
   select {
   case <-ticker.C:
    conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
    if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
     log.Printf("Ping error: %v", err)
     return
    }
   }
  }
 }()
}

var upgrader = websocket.Upgrader{
 CheckOrigin: func(r *http.Request) bool {
  return true
 },
 ReadBufferSize: 1024,
 WriteBufferSize: 1024,
 HandshakeTimeout: 45 * time.Second,
}
Enter fullscreen mode Exit fullscreen mode

Service Layer contains all the business logic and communication to other services, this services in particular does not contain much of that logic since we only have this one service.

// internal/services/services.go
package services

import (
 "context"
 "fmt"
 "log"

 "github.com/joshbrgs/flipping-out/internal/models"
 "github.com/joshbrgs/flipping-out/internal/repositories"
 "go.mongodb.org/mongo-driver/v2/bson"
)

type FeatureService interface {
 IsFeatureEnabled(ctx context.Context, name string) (bool, error)
 GetAllFlags(ctx context.Context) ([]models.FeatureFlag, error)
 GetFlagByID(ctx context.Context, id string) (*models.FeatureFlag, error)
 CreateFlag(ctx context.Context, flag models.FeatureFlag) error
 UpdateFlag(ctx context.Context, id string, update models.FeatureFlag) error
 DeleteFlag(ctx context.Context, id string) error
}

type featureService struct {
 repo repositories.FeatureFlagRepository
}

func NewFeatureService(repo repositories.FeatureFlagRepository) FeatureService {
 return &featureService{repo: repo}
}

func (s *featureService) IsFeatureEnabled(ctx context.Context, name string) (bool, error) {
 flags, err := s.repo.GetAll(ctx)
 if err != nil {
  return false, err
 }

 for _, flag := range flags {
  if flag.Flag == name {
   return flag.Variations.DefaultVar, nil
  }
 }

 return false, nil
}

func (s *featureService) GetAllFlags(ctx context.Context) ([]models.FeatureFlag, error) {
 return s.repo.GetAll(ctx)
}

func (s *featureService) GetFlagByID(ctx context.Context, id string) (*models.FeatureFlag, error) {
 idd, err := bson.ObjectIDFromHex(id)
 if err != nil {
  return nil, fmt.Errorf("Failed to convert ID. Err: %w", err)
 }
 return s.repo.GetByID(ctx, idd)
}

func (s *featureService) CreateFlag(ctx context.Context, flag models.FeatureFlag) error {
 return s.repo.Create(ctx, flag)
}

func (s *featureService) UpdateFlag(ctx context.Context, id string, update models.FeatureFlag) error {
 idd, err := bson.ObjectIDFromHex(id)
 if err != nil {
  return fmt.Errorf("Failed to convert ID. Err: %w", err)
 }
 model, err := s.GetFlagByID(ctx, id)
 if err != nil {
  return fmt.Errorf("Failed to get flag by ID. Err: %w", err)
 }

 log.Println(update)

 updateData := models.FeatureFlag{
  Flag: model.Flag,
  Variations: model.Variations,
  DefaultRule: update.DefaultRule,
 }

 log.Println(updateData)

 return s.repo.Update(ctx, idd, updateData)
}

func (s *featureService) DeleteFlag(ctx context.Context, id string) error {
 idd, err := bson.ObjectIDFromHex(id)
 if err != nil {
  return fmt.Errorf("Failed to convert ID. Err: %w", err)
 }
 return s.repo.Delete(ctx, idd)
}

// internal/services/welcome.go
package services

import "fmt"

type WelcomeService interface {
 HelloWorld() string
 HelloWorldAgain(user string) string
}

type welcomeService struct {
}

func NewWelcomeService() WelcomeService {
 return &welcomeService{}
}

func (s *welcomeService) HelloWorld() string {
 return "HelloWorld"
}

func (s *welcomeService) HelloWorldAgain(user string) string {
 return fmt.Sprintf("Hello there %s", user)
}

// internal/services/websocket.go
package services

import (
 "log"
 "sync"
 "time"

 "github.com/gorilla/websocket"
)

type FeatureEvent struct {
 Value bool `json:"value"`
}

type Hub struct {
 clients map[*websocket.Conn]bool
 register chan *websocket.Conn
 unregister chan *websocket.Conn
 broadcast chan interface{}
 mu sync.Mutex
 Done chan struct{}
 LastBroadcast time.Time
 BroadcastMu sync.Mutex
}

func NewHub() *Hub {
 return &Hub{
  clients: make(map[*websocket.Conn]bool),
  register: make(chan *websocket.Conn),
  unregister: make(chan *websocket.Conn),
  broadcast: make(chan interface{}),
  Done: make(chan struct{}),
 }
}

func (h *Hub) Run() {
 for {
  select {
  case conn := <-h.register:
   h.mu.Lock()
   h.clients[conn] = true
   h.mu.Unlock()
   log.Printf("Client connected. Total clients: %d", len(h.clients))

  case conn := <-h.unregister:
   h.mu.Lock()
   if _, ok := h.clients[conn]; ok {
    delete(h.clients, conn)
    conn.Close()
   }
   h.mu.Unlock()
   log.Printf("Client disconnected. Total clients: %d", len(h.clients))

  case message := <-h.broadcast:
   h.mu.Lock()
   clientCount := len(h.clients)
   if clientCount == 0 {
    h.mu.Unlock()
    continue
   }

   for conn := range h.clients {
    conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
    if err := conn.WriteJSON(message); err != nil {
     log.Printf("WebSocket write error: %v", err)
     conn.Close()
     delete(h.clients, conn)
    }
   }
   h.mu.Unlock()

  case <-h.Done:
   return
  }
 }
}

func (h *Hub) Broadcast(message interface{}) {
 select {
 case h.broadcast <- message:
 case <-time.After(5 * time.Second):
  log.Println("Broadcast timeout - hub may be blocked")
 }
}

func (h *Hub) Register(conn *websocket.Conn) {
 h.register <- conn
}

func (h *Hub) Unregister(conn *websocket.Conn) {
 h.unregister <- conn
}

func (h *Hub) Stop() {
 close(h.Done)
}
Enter fullscreen mode Exit fullscreen mode

For supporting our services, we have a client for consuming the kafka stream too!

// internal/kafka/consumer.go
package kafka

import (
 "context"
 "encoding/json"
 "log"
 "time"

 "github.com/joshbrgs/flipping-out/internal/services"
 "github.com/segmentio/kafka-go"
)

type FeatureEvent struct {
 Value bool `json:"value"`
}

func StartConsumer(h *services.Hub) {
 ctx := context.Background()

 reader := kafka.NewReader(kafka.ReaderConfig{
  Brokers: []string{"kafka.default.svc.cluster.local:9092"},
  Topic: "go-feature-flag-events",
  GroupID: "feature-analytics",
  MinBytes: 10e3,
  MaxBytes: 10e6,
  MaxWait: 1 * time.Second,
 })
 defer reader.Close()

 log.Println("Starting Kafka consumer...")

 for {
  select {
  case <-h.Done:
   log.Println("Stopping Kafka consumer...")
   return
  default:
   ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)

   m, err := reader.ReadMessage(ctxWithTimeout)
   cancel()

   if err != nil {
    log.Printf("Kafka read error: %v", err)
    time.Sleep(time.Second)
    continue
   }

   var event FeatureEvent
   if err := json.Unmarshal(m.Value, &event); err != nil {
    log.Printf("JSON unmarshal error: %v", err)
    continue
   }

   // Rate limit broadcasts to prevent spam
   h.BroadcastMu.Lock()
   if time.Since(h.LastBroadcast) > 100*time.Millisecond {
    h.LastBroadcast = time.Now()
    h.BroadcastMu.Unlock()

    log.Printf("Broadcasting event: %+v", event)
    h.Broadcast(event)
   } else {
    h.BroadcastMu.Unlock()
    // Skip this event due to rate limiting
   }
  }
 }
}
Enter fullscreen mode Exit fullscreen mode

The repository is the data access layer. Database queries all happen with this layer so we can separate the logic from other layers, keeping this testable and evolvable.

// internal/repositories/repositories.go
package repositories

import (
 "context"
 "log"

 "github.com/joshbrgs/flipping-out/internal/models"
 "go.mongodb.org/mongo-driver/v2/bson"
 "go.mongodb.org/mongo-driver/v2/mongo"
)

type FeatureFlagRepository interface {
 GetAll(ctx context.Context) ([]models.FeatureFlag, error)
 GetByID(ctx context.Context, id bson.ObjectID) (*models.FeatureFlag, error)
 Create(ctx context.Context, flag models.FeatureFlag) error
 Update(ctx context.Context, id bson.ObjectID, update models.FeatureFlag) error
 Delete(ctx context.Context, id bson.ObjectID) error
}

type featureFlagRepository struct {
 collection *mongo.Collection
}

func NewFeatureFlagRepository(client *mongo.Client, dbName, collectionName string) FeatureFlagRepository {
 return &featureFlagRepository{
  collection: client.Database(dbName).Collection(collectionName),
 }
}

func (r *featureFlagRepository) GetAll(ctx context.Context) ([]models.FeatureFlag, error) {
 cursor, err := r.collection.Find(ctx, bson.M{})
 if err != nil {
  return nil, err
 }
 defer cursor.Close(ctx)

 var flags []models.FeatureFlag

 for cursor.Next(ctx) {
  var flag models.FeatureFlag

  err := cursor.Decode(&flag)
  if err != nil {
   // Log the raw BSON document to find the broken one
   var raw bson.M
   if rawErr := cursor.Decode(&raw); rawErr == nil {
    log.Printf("Failed to decode into FeatureFlag, raw: %+v", raw)
   }
   return nil, err
  }

  flags = append(flags, flag)

 }
 return flags, nil
}

func (r *featureFlagRepository) GetByID(ctx context.Context, id bson.ObjectID) (*models.FeatureFlag, error) {
 var flag models.FeatureFlag
 err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&flag)
 return &flag, err
}

func (r *featureFlagRepository) Create(ctx context.Context, flag models.FeatureFlag) error {
 _, err := r.collection.InsertOne(ctx, flag)
 return err
}

func (r *featureFlagRepository) Update(ctx context.Context, id bson.ObjectID, update models.FeatureFlag) error {
 updateData := bson.M{
  "flag": update.Flag,
  "variations": update.Variations,
  "defaultRule": update.DefaultRule,
 }

 log.Println(updateData)

 _, err := r.collection.UpdateByID(ctx, id, bson.M{"$set": updateData})
 return err
}

func (r *featureFlagRepository) Delete(ctx context.Context, id bson.ObjectID) error {
 _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
 return err
}
Enter fullscreen mode Exit fullscreen mode

Last, but certainly not least is our models! Structuring data is fun right?

// internal/models/feature_flags.go
package models

import (
 "go.mongodb.org/mongo-driver/v2/bson"
)

type FeatureFlag struct {
 ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
 Flag string `bson:"flag" json:"flag"`
 Variations VariationSet `bson:"variations" json:"variations"`
 DefaultRule FeatureFlagRuleSet `bson:"defaultRule" json:"defaultRule"`
}

type VariationSet struct {
 DefaultVar bool `bson:"default_var" json:"default_var"`
 FalseVar bool `bson:"false_var" json:"false_var"`
 TrueVar bool `bson:"true_var" json:"true_var"`
}

type FeatureFlagRuleSet struct {
 Percentage map[string]int `bson:"percentage" json:"percentage"`
}
Enter fullscreen mode Exit fullscreen mode

We will write a simple Dockerfile to keep the backend portable and allow us to deploy it to the local cluster:

// Dockerfile
FROM golang:1.24 AS builder

WORKDIR /app

COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o app .

FROM alpine:latest

WORKDIR /root/

COPY --from=builder /app/app .

EXPOSE 3001

ENTRYPOINT ["./app"]

// .dockerignore
.air.toml
tmp/
Enter fullscreen mode Exit fullscreen mode

This will be the Kubernetes yamls that will be used to deploy this app:

# manifests/deploy.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flipping-out-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: flipping-out-api
  template:
    metadata:
      labels:
        app: flipping-out-api
    spec:
      containers:
        - name: api
          image: flipping-out-api:1
          imagePullPolicy: Never # Because we'll load it locally into kind
          ports:
            - containerPort: 3001
---
apiVersion: v1
kind: Service
metadata:
  name: flipping-out-api
spec:
  selector:
    app: flipping-out-api
  ports:
    - protocol: TCP
      port: 3001
      targetPort: 3001
  type: ClusterIP
Enter fullscreen mode Exit fullscreen mode

And now let’s build the docker image locally and load it into our local cluster with a deployment!

docker build -t flipping-out-api:1 .
kind load docker-image flipping-out-api:1 --name feature-analytics
kubectl apply -f manifests/deploy.yml
# Then port forward this too
Enter fullscreen mode Exit fullscreen mode

UI

Now of course we will want to show off the beautiful abilities of flags via client side too! Go back to the root directory and create a directory next to app/ called client/ . This will contain our client side application, and I am choosing Remix, a powerful framework built on the React library. You will need to port forward the relay proxy for our app to use it.

Get a Remix project going with Shadcn (because we are creative like that):

pnpm create remix@latest .

pnpm dlx shadcn@latest init

pnpm add recharts

pnpm dlx shadcn@latest add card button label switch input alert chart

// Add into tsconfig.json in paths
    "paths": {
      "~/*": [
        "./app/*"
      ],
      "@/*": [
        "./app/*"
      ]
    },

//
Enter fullscreen mode Exit fullscreen mode

Let’s create our user experience page:

// app/routes/user.tsx
import { useFlag } from "@openfeature/react-sdk";
import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@/components/ui/alert"
import { useState, useEffect, useCallback } from "react"
import FeatureChart from "@/components/FeatureChart";
import { useWebSocket } from "../lib/socket.ts";

type RawEvent = {
  value: boolean;
};

type ChartData = {
  timestamp: number;
  percentTrue: number;
};

export default function Page() {
  const { value: showNewMessage } = useFlag('show-banner', false);
  const [message, setMessage] = useState<string>("");
  const [events, setEvents] = useState<RawEvent[]>([]);
  const [chartData, setChartData] = useState<ChartData[]>([]);

  // Stable callback that won't cause websocket reconnections
  const handleMessage = useCallback((event: RawEvent) => {
    console.log("Received websocket event:", event);

    setEvents((prev) => {
      const updated = [...prev, event].slice(-100); // last 100 events
      const trueCount = updated.filter((e) => e.value).length;
      const percentTrue = Math.round((trueCount / updated.length) * 100);

      setChartData((prevChart) => [
        ...prevChart.slice(-50), // Keep last 50 chart points
        { timestamp: Date.now(), percentTrue },
      ]);

      return updated;
    });
  }, []); // Empty dependency array - callback never changes

  useWebSocket(handleMessage);

  useEffect(() => {
    fetch("http://localhost:3001/v1/welcome")
      .then((res) => res.json())
      .then((data) => setMessage(data))
      .catch((err) => {
        console.error("Failed to fetch welcome message:", err);
        setMessage("Failed to load welcome message");
      });
  }, []);

  return (
    <div className="m-5 py-6">
      <Alert className="p-5">
        <AlertTitle>FeatureFlag Banner</AlertTitle>
        {showNewMessage ? 
          <AlertDescription>Welcome to this OpenFeature-enabled React app!</AlertDescription> :
          <AlertDescription>Welcome to this React app.</AlertDescription>
        }
      </Alert>

      <Alert>
        <AlertTitle>User welcome message</AlertTitle>
        <AlertDescription>{message}</AlertDescription>
      </Alert>

      <div className="mt-6">
        <h1 className="text-xl font-semibold mb-4">Live Feature Flag Status</h1>
        <p className="text-sm text-gray-600 mb-4">
          Events processed: {events.length} | Latest: {events[events.length - 1]?.value ? 'True' : 'False'}
        </p>
        <FeatureChart data={chartData} />
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let’s build a way to control these feature flags:

// app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { useEffect, useState } from "react";
import { Form } from "@remix-run/react";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { FeatureFlag } from "~/types/feature-flags";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export default function FeatureFlagsPage() {
  const [flags, setFlags] = useState<FeatureFlag[]>([]);
  const [newFlagName, setNewFlagName] = useState("");

  useEffect(() => {
    fetch("http://localhost:3001/v1/flags")
      .then((res) => res.json())
      .then((data) => setFlags(data));
  }, []);

  const handleCreate = async (e: React.FormEvent) => {
    e.preventDefault();

    await fetch("http://localhost:3001/v1/flags", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        flag: newFlagName,
        variations: {
          default_var: false,
          false_var: false,
          true_var: true,
        },
        defaultRule: {
          percentage: {
            false_var: 100,
            true_var: 0,
          },
        },
      }),
    });

    setNewFlagName("");
    const res = await fetch("http://localhost:3001/v1/flags");
    setFlags(await res.json());
  };

  const handleToggle = async (flag: FeatureFlag) => {
    const newSplit = flag.defaultRule.percentage.true_var === 100
      ? { false_var: 100, true_var: 0 }
      : { false_var: 0, true_var: 100 };

    await fetch(`http://localhost:3001/v1/flags/${flag.id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ defaultRule: { percentage: newSplit } }),
    });

    const res = await fetch("http://localhost:3001/v1/flags");
    setFlags(await res.json());
  };

  return (
    <div className="max-w-4xl mx-auto mt-10 space-y-10">
      <Card>
        <CardHeader>
          <CardTitle>Create New Feature Flag</CardTitle>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleCreate} className="flex gap-4">
            <div className="grid gap-2 w-full">
              <Label htmlFor="name">Flag Name</Label>
              <Input
                id="name"
                value={newFlagName}
                onChange={(e) => setNewFlagName(e.target.value)}
                placeholder="e.g. new-admin-ui"
                required
              />
            </div>
            <div className="flex items-end">
              <Button type="submit">Create</Button>
            </div>
          </form>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>Feature Flags</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="space-y-4">
            {flags && flags.map((flag) => (
              <div key={flag.id} className="flex items-center justify-between border-b pb-2">
                <span className="text-sm font-medium">{flag.flag}</span>
                <Switch
                  checked={flag.defaultRule.percentage.true_var === 100}
                  onCheckedChange={() => handleToggle(flag)}
                />
              </div>
            ))}
          </div>
        </CardContent>
      </Card>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We need our supporting libraries for the feature flags and webhooks too

// app/lib/featureFlagProvider.tsx
import { OpenFeature, OpenFeatureProvider } from "@openfeature/react-sdk";
import { GoFeatureFlagWebProvider } from "@openfeature/go-feature-flag-web-provider";

const goFeatureFlagWebProvider = new GoFeatureFlagWebProvider({
  endpoint: "http://localhost:1031"
});

// Set the initial context for your evaluations
OpenFeature.setContext({
  targetingKey: "user-1",
  admin: false
});

// Instantiate and set our provider (be sure this only happens once)!
// Note: there's no need to await its initialization, the React SDK handles re-rendering and suspense for you!
OpenFeature.setProvider(goFeatureFlagWebProvider);

// Enclose your content in the configured provider
export function OFWrapper({ children }: { children: React.ReactNode }) {
  return (
    <OpenFeatureProvider>
      {children}
    </OpenFeatureProvider>
  );
}

// Be sure to remember to wrap this provider around your root of the app!

// app/lib/socket.ts
import { useEffect, useRef, useCallback } from "react";

export function useWebSocket(onMessage: (data: any) => void) {
  const socketRef = useRef<WebSocket | null>(null);
  const reconnectTimeoutRef = useRef<any>(null);
  const reconnectAttempts = useRef(0);
  const maxReconnectAttempts = 5;

  // Memoize the onMessage callback to prevent unnecessary reconnections
  const stableOnMessage = useCallback(onMessage, []);

  const connect = useCallback(() => {
    if (socketRef.current?.readyState === WebSocket.OPEN) {
      return; // Already connected
    }

    try {
      const socket = new WebSocket("ws://localhost:3001/v1/ws");

      socket.onopen = () => {
        console.log("WebSocket connected");
        reconnectAttempts.current = 0;
      };

      socket.onmessage = (event) => {
        try {
          const parsed = JSON.parse(event.data);
          stableOnMessage(parsed);
        } catch (error) {
          console.error("Failed to parse WebSocket message:", error);
        }
      };

      socket.onerror = (err) => {
        console.error("WebSocket error:", err);
      };

      socket.onclose = (event) => {
        console.log(`WebSocket closed: ${event.code} ${event.reason}`);
        socketRef.current = null;

        // Only reconnect if it wasn't a normal closure and we haven't exceeded max attempts
        if (event.code !== 1000 && reconnectAttempts.current < maxReconnectAttempts) {
          const delay = Math.pow(2, reconnectAttempts.current) * 1000; // Exponential backoff
          console.log(`Attempting to reconnect in ${delay}ms (attempt ${reconnectAttempts.current + 1})`);

          reconnectTimeoutRef.current = setTimeout(() => {
            reconnectAttempts.current++;
            connect();
          }, delay);
        }
      };

      socketRef.current = socket;
    } catch (error) {
      console.error("Failed to create WebSocket:", error);
    }
  }, [stableOnMessage]);

  useEffect(() => {
    connect();

    return () => {
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current);
      }
      if (socketRef.current) {
        socketRef.current.close(1000, "Component unmounting");
      }
    };
  }, [connect]);

  return {
    socket: socketRef.current,
    reconnect: connect
  };
}
Enter fullscreen mode Exit fullscreen mode

Awesome, now let’s run the cool new UI locally and see how it is interacting with our new server too!

 pnpm run dev
// Make sure to portforward the gin pod, and relay so it
// is exposed to your local host network
Enter fullscreen mode Exit fullscreen mode

You can create a new flag show-banner which will actively change the banner, no reload needed!

Give me the Data

To check the Kafka topic and messages we can use Kafka’s tooling like so:

kubectl exec -it kafka-controller-0 -- bash

# Lists the topics available
kafka-topics.sh --bootstrap-server localhost:9092 --list

# Describe topics
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic go-feature-flag-events

# List the messages
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic go-feature-flag-events \
--from-beginning
Enter fullscreen mode Exit fullscreen mode

If you have visited the UI and waited until the collector sends a batch of messages to the Relay you should see some messages coming through! These messages are the same ones being consumed by the go consumer and pushed to our Remix app via websockets!

In a more production based application, you would want to track these events in a time series database and display the results that way. You can also track custom events with these feature flags which is crucial to experimentation and evaluating your product.

As my own test, comment if you like the tutorial portions of the blogs, and if you like the format of showing pretty much all files in the tutorial or just highlight the important ones?

Conclusion

Feature flags don’t have to be the stuff of engineering horror stories. With tools like OpenFeature and GoFF, you can keep your codebase clean, your feature rollouts chill, and your dev team sane(ish).

So go ahead — flip some flags. Experiment. Test. Iterate.

Just promise me you’ll clean them up afterwards, right?

References

https://ai.stanford.edu/~ronnyk/ExPThinkWeek2009Public.pdf

Top comments (0)