Lightweight, thread-safe feature flags library for Go applications with support for gradual rollouts, allowlists, and denylists.
- Dynamic Configuration - Hot-reload flags from HTTP endpoints or local files
- Gradual Rollouts - Enable features for a percentage of users using deterministic hashing
- User Targeting - Allowlist/Denylist specific users regardless of rollout percentage
- Thread-Safe - Safe for concurrent use across thousands of goroutines
- Minimal Dependencies - Core uses only Go standard library (log/slog, sync, hash/crc32)
- Extensible - Implement the Provider interface to fetch configs from any source
- Installation
- Quick Start
- Configuration Format
- Examples
- API Reference
- Evaluation Logic
- Rollout Percentage Implementation
- Error Handling
- Graceful Shutdown
- Thread Safety
- Custom Provider Example
- License
go get -u github.com/bordviz/featureflagsRequirements: Go 1.21+ (uses log/slog)
package main
import (
"time"
"log/slog"
"github.com/bordviz/featureflags"
"github.com/bordviz/featureflags/provider"
)
func main() {
// Create a provider (HTTP or File)
prov := provider.NewHTTPProvider("https://config.example.com/flags.json")
// Initialize the client
client := featureflags.NewClient(prov,
featureflags.WithInterval(30*time.Second),
featureflags.WithLogger(slog.Default()),
)
defer client.Close() // Always close to stop the background updater
// Check if a feature is enabled for a user
if client.IsEnabled("new-checkout-flow", "user_12345") {
renderNewCheckout()
} else {
renderLegacyCheckout()
}
}The library supports JSON and YAML configuration files (for FileProvider). HTTPProvider expects JSON.
{
"version": "1.0.0",
"flags": {
"new-checkout-flow": {
"key": "new-checkout-flow",
"enabled": true,
"rule": {
"rollout_percentage": 25,
"allowlist": ["user_admin", "user_beta_001"],
"denylist": ["user_blocked_999"]
}
},
"dark-mode": {
"key": "dark-mode",
"enabled": false,
"rule": {
"rollout_percentage": 0,
"allowlist": [],
"denylist": []
}
}
}
}version: "1.0.0"
flags:
new-checkout-flow:
key: new-checkout-flow
enabled: true
rule:
rollout_percentage: 25
allowlist:
- user_admin
- user_beta_001
denylist:
- user_blocked_999
dark-mode:
key: dark-mode
enabled: false
rule:
rollout_percentage: 0
allowlist: []
denylist: []The examples/ directory contains working demonstrations of different use cases.
- examples/file-json - Loading flags from a local JSON file.
- examples/file-yaml - Loading flags from a local YAML file.
- examples/http-json - Fetching flags from a remote HTTP endpoint.
- examples/http-preprocess-base64 - Fetching and decoding Base64-encoded flags from HTTP.
The Client is the main entry point for evaluating feature flags. It manages a background goroutine that periodically fetches updated configurations from the provider.
func NewClient(provider Provider, opts ...ClientOption) *ClientCreates a new feature flags client. Starts a background updater goroutine immediately.
Default Options:
- Interval: 1 minute
- Logger: slog.New(slog.DiscardHandler)
Client Options:
| Option | Description | Default |
|---|---|---|
| WithInterval(time.Duration) | Config refresh interval | 1 minute |
| WithLogger(*slog.Logger) | Logger for background errors | DiscardHandler |
func (c *Client) IsEnabled(flagKey, userID string) boolReturns true if the flag is enabled for the specified user. This is an O(1) operation safe for high-traffic use.
func (c *Client) GetConfig() ConfigReturns a copy of the current configuration. Useful for debugging or bulk flag checks.
func (c *Client) Close()Stops the background configuration updater goroutine. Must be called when shutting down your application to prevent goroutine leaks.
type Provider interface {
Fetch(ctx context.Context) (*Config, error)
}Implement this interface to load configurations from any source. The provider is called periodically by the client's background updater.
Fetches JSON configuration from a remote HTTP endpoint.
// Basic usage
prov := provider.NewHTTPProvider("https://api.example.com/flags")
// With custom options
prov := provider.NewHTTPProvider(
"https://api.example.com/flags",
provider.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
provider.WithHeader("Authorization", "Bearer token123"),
provider.WithPreprocess(func(data []byte) ([]byte, error) {
// Decrypt, decompress, or transform data before parsing
return data, nil
}),
)Default Settings:
- HTTP Client Timeout: 10 seconds
- Default Header: Accept: application/json
Options:
| Option | Description |
|---|---|
| WithHTTPClient(*http.Client) | Set custom HTTP client |
| WithHeader(key, value string) | Add custom HTTP header |
| WithPreprocess(func([]byte) ([]byte, error)) | Transform response before parsing |
Loads configuration from a local file. Supports JSON and YAML formats.
// JSON file (default)
prov := provider.NewFileProvider("./flags.json")
// YAML file
prov := provider.NewFileProvider(
"./flags.yaml",
provider.WithFormat(provider.FileFormatYAML),
)Options:
| Option | Description | Default |
|---|---|---|
| WithFormat(FileFormat) | Set file format (JSON or YAML) | JSON |
type Config struct {
Flags map[string]Flag `json:"flags" yaml:"flags"`
Version string `json:"version" yaml:"version"`
}Main configuration structure containing all feature flags.
type Flag struct {
Key string `json:"key" yaml:"key"`
Enabled bool `json:"enabled" yaml:"enabled"`
Rule FlagRule `json:"rule" yaml:"rule"`
}Represents a single feature flag with its evaluation rules.
type FlagRule struct {
RolloutPercentage int `json:"rollout_percentage" yaml:"rollout_percentage"`
Allowlist []string `json:"allowlist" yaml:"allowlist"`
Denylist []string `json:"denylist" yaml:"denylist"`
}Defines conditions for enabling a flag for a specific user.
| Field | Description | Range |
|---|---|---|
| RolloutPercentage | Percentage of users for whom the flag is enabled | 0-100 |
| Allowlist | User IDs for whom the flag is always enabled | - |
| Denylist | User IDs for whom the flag is always disabled | - |
When IsEnabled(flagKey, userID) is called, the flag is evaluated in the following order:
- Flag Exists - If the flag key does not exist in the configuration, return false
- Global Enabled - If flag.Enabled is false, return false
- Allowlist Check - If userID is in the Allowlist, return true
- Denylist Check - If userID is in the Denylist, return false
- Rollout Percentage - If hash(flagKey:userID) % 100 < RolloutPercentage, return true
- Default - Return false
Note: Allowlist takes precedence over Denylist. If a user is in both lists, Allowlist wins.
The library uses deterministic hashing to ensure consistent user experience across application restarts and multiple instances.
// Internal implementation
input := fmt.Sprintf("%s:%s", flagKey, userID)
hash := crc32.ChecksumIEEE([]byte(input))
bucket := int(hash % 100)
enabled := bucket < rolloutPercentage- Consistency - Same user + same flag = same result across restarts and instances
- No External State - Works offline, does not require Redis or database
- Even Distribution - CRC32 ensures uniform distribution across buckets 0-99
- High Performance - O(1) operation, no network calls during evaluation
// User user_123 will always be in the same bucket for flag new-feature
bucket1 := hash.GetUserBucket("new-feature", "user_123") // e.g., 42
bucket2 := hash.GetUserBucket("new-feature", "user_123") // always 42
// Different flags give different buckets for the same user
bucket3 := hash.GetUserBucket("other-feature", "user_123") // e.g., 17If the provider returns an error during configuration fetch:
- The error is logged via the configured slog.Logger
- The client continues working with the last successful configuration
- New flag updates are not available until the next successful fetch
If a flag key does not exist in the configuration:
- IsEnabled returns false
- No error is returned
If the initial fetch fails and no configuration is loaded:
- All IsEnabled calls return false
- The background updater continues retrying at the configured interval
Always close the client to stop the background configuration updater goroutine.
client := featureflags.NewClient(provider)
defer client.Close()package main
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"github.com/bordviz/featureflags"
"github.com/bordviz/featureflags/provider"
)
func main() {
prov := provider.NewHTTPProvider("https://config.example.com/flags.json")
client := featureflags.NewClient(prov)
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
// Gracefully close the client
client.Close()
// Allow time for cleanup
time.Sleep(100 * time.Millisecond)
}The library is designed for concurrent use:
- Storage - Uses sync.RWMutex for thread-safe read/write operations
- IsEnabled - Safe to call from thousands of goroutines simultaneously
- Background Updater - Runs in a separate goroutine, does not block flag evaluation
- GetConfig returns a copy of the configuration to prevent race conditions
- Storage uses RLock for reads and Lock for writes
- All public methods are safe for concurrent use
Implement the Provider interface to load configurations from any source.
type EtcdProvider struct {
client *etcd.Client
key string
}
func (p *EtcdProvider) Fetch(ctx context.Context) (*featureflags.Config, error) {
resp, err := p.client.Get(ctx, p.key)
if err != nil {
return nil, err
}
var cfg featureflags.Config
if err := json.Unmarshal(resp.Kvs[0].Value, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// Usage
client := featureflags.NewClient(&EtcdProvider{client: etcdClient, key: "/flags"})Distributed under the MIT License. See LICENSE for more information.
Made with ❤️ for the Go community