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")
}
}
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")
}
}
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
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
Then create a cluster like so:
kind create cluster --name feature-analytics --config=kind-config.yaml
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
📅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
Make sure to ensure this shell script is executable like so:
chmod +x deploy.sh
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
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"
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
And install them all with:
./deploy.sh
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
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
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")
}
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,
}
}
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,
}
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)
}
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
}
}
}
}
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
}
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"`
}
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/
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
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
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/*"
]
},
//
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>
)
}
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>
);
}
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
};
}
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
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
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?
Top comments (0)