A lightweight, production-ready feature flag service built in Go. Supports percentage rollouts with consistent hashing, user targeting rules, flag dependencies, and audit logging.
graph TB
Client[Client] -->|HTTP| MW[Middleware Stack]
MW -->|Request ID, Logging, Metrics, Recovery| Router[Chi Router]
Router --> FH[Flag Handler]
Router --> HH[Health Handler]
Router --> PM[Prometheus /metrics]
FH --> FS[Flag Service]
FS --> EE[Evaluation Engine]
FS --> FC[Flag Cache]
FS --> FR[Flag Repository]
FC -.->|TTL-based invalidation| FR
EE -->|FlagGetter interface| FS
FR -->|database/sql| DB[(SQLite)]
style MW fill:#f9f,stroke:#333
style FS fill:#bbf,stroke:#333
style EE fill:#bfb,stroke:#333
style FC fill:#fbb,stroke:#333
style DB fill:#ff9,stroke:#333
| Layer | Package | Responsibility |
|---|---|---|
| Transport | internal/handler |
HTTP handlers, middleware, JSON encoding |
| Business | internal/service |
Orchestration, validation, audit logging |
| Evaluation | internal/evaluation |
Flag evaluation engine (targeting, rollout, dependencies) |
| Cache | internal/cache |
TTL-based in-memory cache with sync.RWMutex |
| Persistence | internal/repository |
SQLite via database/sql behind a FlagRepository interface |
| Domain | internal/domain |
Core types, request/response structs, validation |
| Config | internal/config |
YAML + env var configuration loading |
- Go 1.23+
- GCC (for SQLite via cgo)
make rundocker compose upThe server starts on :8080 by default.
curl -X POST http://localhost:8080/flags \
-H "Content-Type: application/json" \
-d '{
"key": "dark-mode",
"name": "Dark Mode",
"description": "Enable dark mode UI",
"enabled": true,
"rollout_percentage": 50,
"targeting_rules": [
{"attribute": "email", "operator": "contains", "value": "@monzo.com"}
]
}'curl http://localhost:8080/flagscurl http://localhost:8080/flags/dark-modecurl -X PUT http://localhost:8080/flags/dark-mode \
-H "Content-Type: application/json" \
-d '{"enabled": false, "rollout_percentage": 0}'curl -X DELETE http://localhost:8080/flags/dark-modecurl -X POST http://localhost:8080/evaluate \
-H "Content-Type: application/json" \
-d '{
"flag_key": "dark-mode",
"context": {
"user_id": "user-123",
"attributes": {
"email": "[email protected]",
"country": "GB"
}
}
}'Response:
{
"key": "dark-mode",
"enabled": true,
"reason": "targeting rule matched: email contains @monzo.com"
}curl -X POST http://localhost:8080/evaluate/bulk \
-H "Content-Type: application/json" \
-d '{
"flag_keys": ["dark-mode", "new-checkout"],
"context": {"user_id": "user-123"}
}'curl http://localhost:8080/flags/dark-mode/historycurl http://localhost:8080/healthz # Liveness probe
curl http://localhost:8080/readyz # Readiness probe
curl http://localhost:8080/metrics # Prometheus metricsConfiguration is loaded from config.yaml with environment variable overrides:
| Env Var | Default | Description |
|---|---|---|
PORT |
8080 |
Server port |
DATABASE_DSN |
flags.db |
SQLite database path |
CACHE_ENABLED |
true |
Enable in-memory cache |
CACHE_TTL |
30s |
Cache TTL duration |
LOG_LEVEL |
info |
Log level (debug, info, warn, error) |
LOG_FORMAT |
json |
Log format (json, text) |
CONFIG_PATH |
config.yaml |
Path to YAML config file |
Rules support four operators:
| Operator | Description | Example |
|---|---|---|
eq |
Exact match | {"attribute": "country", "operator": "eq", "value": "GB"} |
neq |
Not equal | {"attribute": "user_id", "operator": "neq", "value": "blocked"} |
contains |
Substring match | {"attribute": "email", "operator": "contains", "value": "@monzo.com"} |
in |
Value in comma-separated list | {"attribute": "country", "operator": "in", "value": "GB,US,DE"} |
If any targeting rule matches, the flag is enabled for that user regardless of rollout percentage. If no rules match, the percentage rollout applies.
Percentage rollouts use CRC32 hashing of flagKey:userID to assign each user to a deterministic bucket in [0, 100). This means:
- The same user always sees the same flag state (no flicker)
- No external state needed to track assignments
- Increasing the rollout percentage from 10% to 20% adds users — it doesn't reshuffle existing ones
Storage is abstracted behind repository.FlagRepository. The current implementation uses SQLite with WAL mode for good read concurrency, but the interface makes it straightforward to swap in Postgres, MySQL, or DynamoDB without touching business logic.
The in-memory cache uses sync.RWMutex (not sync.Map) because the access pattern is heavily read-biased with known key types. Write-through invalidation on updates ensures consistency, and the configurable TTL provides a safety net. The cache stores copies of flag structs to prevent mutation of cached data.
The evaluation engine depends on a FlagGetter interface, not the repository directly. The service implements this interface, so evaluations (including recursive dependency resolution) go through the cache transparently. Circular dependencies are detected via a visited-set during recursion.
The server listens for SIGINT/SIGTERM and drains in-flight requests before stopping. This is essential for zero-downtime deployments in Kubernetes or similar orchestrators.
SQLite with WAL mode handles significant read throughput for a single-node service. For multi-node deployments, swap the repository implementation to Postgres — the interface is ready.
make test # Run tests with race detector
make test-verbose # Run tests with verbose output
make lint # Run golangci-lint
make coverage # Generate HTML coverage report
make fmt # Format code
make vet # Run go vet
make clean # Remove build artifacts