Every engineering team reaches the same breaking point: the deploy-to-release coupling. You want to ship code to production, but you can’t activate it for all users at once. Maybe it’s a UI overhaul that needs a staged rollout, a payment integration that requires compliance sign-off, or a performance optimization that should reach 5% of traffic before going global. Without a mechanism to decouple deployment from release, teams resort to long-lived feature branches, midnight deploys, or configuration file hacks.
Feature flags solve this by separating the act of deploying code from the act of turning it on. The pattern is straightforward: wrap new behavior in a conditional check against a flag key, deploy the code with the flag off, then toggle it independently at runtime. But building a flagging system from scratch opens a rabbit hole of edge cases — caching, targeting rules, percentage rollouts, audit logging, and the inevitable vendor lock-in when you pick a commercial platform.
OpenFeature, now a CNCF incubating project, tackles this by defining a vendor-neutral specification for feature flagging. You write code against a standard API, and swap backends (Flagd, GO Feature Flag, LaunchDarkly, CloudBees, your own) without touching application logic. Let’s walk through what the Go SDK offers and how to put it to work.
The Core Abstractions
OpenFeature has three central concepts. A provider is the bridge between your application and the actual flag evaluation backend — it could be an in-process JSON file, a gRPC daemon, or a SaaS API. An evaluation context carries the targeting data (user ID, region, device type) that the provider uses to decide which variant to return. Hooks are lifecycle callbacks that run before and after flag evaluation, letting you inject logging, telemetry, or custom logic.
The separation matters because it means your application code never knows where flag values come from. In development, you might use a no-op provider that returns defaults. In staging, a local file provider. In production, a managed service. The SDK calls remain identical across all three.
Getting Started with the Go SDK
The OpenFeature Go SDK is at v1.17.2, targeting spec version 0.7.0. Installation is a standard module import:
go get github.com/open-feature/go-sdk/openfeature
The entry point is the openfeature package, where you register a provider and create clients. Here’s a minimal setup:
package main
import (
"context"
"fmt"
"github.com/open-feature/go-sdk/openfeature"
)
func main() {
// Register a provider and block until ready.
// NoopProvider always returns defaults — useful in tests.
openfeature.SetProviderAndWait(openfeature.NoopProvider{})
client := openfeature.NewDefaultClient("billing-service")
// Evaluate a boolean flag with a safe default.
enabled := client.Boolean(
context.Background(),
"invoicing-v2",
false, // default if evaluation fails
openfeature.EvaluationContext{},
)
if enabled {
fmt.Println("Running invoicing v2")
}
}
Notice the pattern: every evaluation takes a flag key, a default value, and an evaluation context. The default is what gets returned if the provider is unavailable, the flag doesn’t exist, or an error occurs. This makes feature flags safe by default — if the flag system goes down, your application falls back to known behavior.
Targeting with Evaluation Context
Static on/off flags are the starting point, but the real power comes from targeted evaluation. The evaluation context carries attributes that the provider uses to match targeting rules: “enable this flag for users in the EU” or “roll out to 10% of traffic on mobile.”
func getCheckoutVariant(ctx context.Context, userID, plan, region string) string {
client := openfeature.NewDefaultClient("checkout")
evalCtx := openfeature.NewEvaluationContext(
userID,
map[string]any{
"plan": plan,
"region": region,
},
)
return client.String(
ctx,
"checkout-flow",
"legacy", // default variant
evalCtx,
)
}
Evaluation context can be set at three levels. Global context applies to all evaluations in the process. Client context applies to all evaluations on a specific client. Invocation context (shown above) applies to a single evaluation. The SDK merges them at evaluation time, with more specific contexts overriding broader ones.
// Global: applies to every client in the process
openfeature.SetEvaluationContext(
openfeature.NewTargetlessEvaluationContext(map[string]any{
"service": "api-gateway",
"version": "2.1.0",
}),
)
// Client: applies to all evaluations on this client
client := openfeature.NewDefaultClient("payments")
client.SetEvaluationContext(
openfeature.NewTargetlessEvaluationContext(map[string]any{
"environment": "production",
}),
)
Hooks: Observability Without Coupling
Hooks are the SDK’s extensibility mechanism. They fire at defined points — before evaluation, after success, after error, and finally — letting you wire in telemetry without entangling your business logic with a specific monitoring vendor. A hook that records evaluation latency in OpenTelemetry spans looks like this:
type TracingHook struct{}
func (t TracingHook) Before(
ctx context.Context,
hCtx openfeature.HookContext,
details openfeature.HookHints,
) (*openfeature.EvaluationContext, error) {
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.String("feature_flag.key", hCtx.Flag()),
)
return nil, nil
}
func (t TracingHook) Finally(
ctx context.Context,
hCtx openfeature.HookContext,
details openfeature.HookHints,
) error {
// Record the resolved value in the span
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.String("feature_flag.variant", hCtx.DefaultValue().(string)),
)
return nil
}
// Register globally — runs on every evaluation
openfeature.AddHooks(TracingHook{})
Hooks can be registered globally, per-client, or per-invocation. The HookContext passed to each callback exposes the flag key, the flag type, the default value, and the evaluation context, giving you everything needed for observability without reaching into the provider.
Practical Patterns
A few patterns emerge from real-world usage. First, always provide sensible defaults. If a flag evaluation returns the default, your application should behave correctly — the flag is an enhancement, not a dependency. Second, use domains for multi-tenant flagging. OpenFeature supports named clients (domains), letting different services or logical components bind to different providers:
// Register different providers for different domains
openfeature.SetNamedProvider(
"billing",
flagd.NewProvider(flagd.WithPort(8013)),
)
openfeature.SetNamedProvider(
"experiments",
growthbook.NewProvider(),
)
// Each client talks to its own provider
billingClient := openfeature.NewClient("billing")
expClient := openfeature.NewClient("experiments")
Third, pair flag evaluation with tracking. The SDK’s tracking API associates user actions with flag evaluations, closing the loop between “we turned on a flag” and “users who saw it converted at a higher rate.”
client := openfeature.NewDefaultClient("checkout")
// Evaluate which variant to show
variant := client.String(ctx, "checkout-flow", "legacy", evalCtx)
// ... user interacts with the checkout ...
// Track the outcome alongside the evaluation context
client.Track(
ctx,
"purchase-completed",
evalCtx,
openfeature.NewTrackingEventDetails(89.99).Add("currencyCode", "USD"),
)
Wrapping Up
Feature flags are infrastructure, not a nice-to-have. The question isn’t whether you need them — it’s whether you build your own coupling or adopt a standard that survives vendor changes. OpenFeature’s value proposition is portability: write your flag logic once, and the provider becomes a deployment detail. The Go SDK gives you typed evaluation, layered context, lifecycle hooks, and tracking in a package that integrates cleanly with existing HTTP servers and gRPC services.
Start with the NoopProvider in tests, move to an in-process provider in development, and graduate to a managed backend when you need targeting rules and analytics. The migration between these stages is a provider swap, not a code rewrite — and that’s the entire point.