Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ linters:
list-mode: strict
allow:
- $gostd
# Portable runtime flag API — the feature-flag-first delivery
# convention (see AGENTS.md ## Maintenance). Swap the provider for
# flagd / a managed backend in a real service; call sites are stdlib.
- github.com/open-feature/go-sdk
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ broadly compatible as possible. Nothing else hard-codes the version — `copilot
`go-version-file: go.mod` and the README points at `go.mod` — so keep it that
way (no second copy to drift).

**Feature-flag-first delivery.** Generated services land every new feature
**behind a flag, default-off**, and flip it on only after validation — the
portfolio-wide convention (devantler-tech/monorepo#2059). The scaffold wires the
portable **OpenFeature Go SDK** in `pkg/featureflag`: `NewProvider` (in-memory,
so the example evaluates with no backend — swap for **flagd** or a managed
backend in a real service), `NewClient`, and `Enabled` (default-off on any error
or missing flag). Guard the new path behind `Enabled`, keep the old path as the
default, and **cover both states** with a table-driven test. **Lifecycle is
mandatory:** short-lived *release* flags are **removed after rollout** (flag debt
is the #1 failure mode); long-lived *ops/permission* flags are the exception.
Delete `pkg/featureflag` (like `pkg/example`) when you add your own. The SDK is
the one allowance in `.golangci.yml`'s `depguard` strict allow-list; keep the
rest of the non-test scaffold stdlib-only.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Services vs CLIs — reach for the SDK only when you need it.** The OpenFeature
SDK is for **services** that need runtime evaluation (targeting, gradual
rollout, a remote backend). A generated **CLI** should stay dependency-free:
gate an experimental command/flag behind cobra's `Hidden: true` (off `--help`)
plus an `--experimental` opt-in (or a config gate), and only add the SDK where
richer evaluation is genuinely needed. Same default-off, remove-after-rollout
lifecycle either way.

**Validate before any PR (locally):** `golangci-lint fmt` (if configured), `go build ./... && go test ./...`, `golangci-lint run` — local checks for fast feedback (the ruleset-injected `validate-go-project` workflow re-runs build/test/lint/coverage on the PR — see *Validation* above; don't duplicate it into `ci.yaml`). Workflows → `actionlint`.

**Task menu** (light; ≤1 high-value item per run):
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A minimal, batteries-included Go template for new projects. Skip the boilerplate
## ✨ What's included

- **Idiomatic scaffold** — a no-op `main.go` plus the conventional `cmd/`, `internal/`, and `pkg/` layout, ready for your first package. A minimal [`pkg/example`](pkg/example) package with a table-driven test shows the house testing pattern — replace it with your own.
- **Feature-flag-first** — [`pkg/featureflag`](pkg/featureflag) wires the portable [OpenFeature](https://openfeature.dev/) Go SDK so a **service** can land every new feature behind a flag, default-off, and flip it on only after validation (swap the in-memory provider for [flagd](https://flagd.dev/) or a managed backend). A **CLI** can stay dependency-free instead — gate experimental commands behind cobra `Hidden` + an `--experimental` opt-in, reaching for the SDK only when you need richer evaluation. Delete it when you add your own.
- **Linting & formatting** — [`golangci-lint`](https://golangci-lint.run/) v2 (formatters + `default: all` linters) in CI, with [MegaLinter](https://megalinter.io/) covering everything else. A [pre-commit](https://pre-commit.com/) hook runs `golangci-lint` formatting (and `mockery` mock generation) locally on commit.
- **CI/CD** — a required-checks workflow on pull requests and the merge queue, plus a [GoReleaser](https://goreleaser.com/) release pipeline (`cd.yaml`) triggered on `v*` tags.
- **Coverage** — `go test` coverage reported via [GitHub Code Quality](https://docs.github.com/code-security/code-quality).
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
module github.com/devantler-tech/go-template

go 1.25.10

require github.com/open-feature/go-sdk v1.17.2

require (
github.com/go-logr/logr v1.4.3 // indirect
go.uber.org/mock v0.6.0 // indirect
)
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/open-feature/go-sdk v1.17.2 h1:pTdeNks/hgnPrlqdgtFwltnIron1oOxqg4FmLlirJlY=
github.com/open-feature/go-sdk v1.17.2/go.mod h1:kTMCquVtck18XdSCI6rBoNFEBLvkOy4Tphu2pV8bq34=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
89 changes: 89 additions & 0 deletions pkg/featureflag/featureflag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Package featureflag is a minimal, replaceable feature-flag scaffold.
//
// It wires the portable OpenFeature Go SDK (github.com/open-feature/go-sdk) as
// the runtime flag API so every new feature can be landed behind a flag,
// default-off, and flipped on only after validation — the feature-flag-first
// delivery convention (see AGENTS.md ## Maintenance).
//
// The template ships the in-memory provider (openfeature/memprovider) so the
// example flag evaluates and its tests pass with no running backend. In a real
// service, swap the provider for flagd
// (github.com/open-feature/go-sdk-contrib/providers/flagd) to read FeatureFlag
// custom resources from the GitOps platform, or a managed backend — call sites
// (Enabled below) are unchanged because they only touch the OpenFeature client.
//
// Lifecycle: short-lived *release* flags are removed after rollout (flag debt
// is the #1 failure mode); long-lived *ops/permission* flags are the exception.
// Delete this package and its example flag when you add your own first feature.
package featureflag

import (
"context"
"fmt"

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

// ExampleFlag is a sample boolean flag key, evaluated default-off.
//
// It gates ExampleFeature below purely to demonstrate the both-states pattern;
// replace it with your own feature's key.
const ExampleFlag = "example-feature"

// NewProvider builds an OpenFeature provider that serves the given boolean flag
// states in-process — the template's default so the example evaluates and its
// tests pass with no running backend.
//
// In a real service, replace this with the flagd provider (or a managed
// backend); call sites are unaffected because they only touch the client.
func NewProvider(flags map[string]bool) memprovider.InMemoryProvider {
memFlags := make(map[string]memprovider.InMemoryFlag, len(flags))

for key, on := range flags {
variant := "off"
if on {
variant = "on"
}

memFlags[key] = memprovider.InMemoryFlag{
State: memprovider.Enabled,
DefaultVariant: variant,
Variants: map[string]any{"on": true, "off": false},
}
}

return memprovider.NewInMemoryProvider(memFlags)
}

// NewClient registers provider under domain and returns a client bound to it.
//
// A real service calls this once at startup (domain = the service name); the
// returned client is safe to share across goroutines.
func NewClient(domain string, provider openfeature.FeatureProvider) (*openfeature.Client, error) {
err := openfeature.SetNamedProviderAndWait(domain, provider)
if err != nil {
return nil, fmt.Errorf("register feature-flag provider for %q: %w", domain, err)
}

return openfeature.NewClient(domain), nil
}

// Enabled reports whether flag is on for client, defaulting to OFF on a missing
// flag or any evaluation error — the default-off rule every new feature follows
// so a flag failure can never silently turn a feature on.
func Enabled(ctx context.Context, client *openfeature.Client, flag string) bool {
return client.Boolean(ctx, flag, false, openfeature.EvaluationContext{})
}

// ExampleFeature returns the feature's output when ExampleFlag is enabled and a
// safe default otherwise — the shape a real feature takes: guard the new path
// behind Enabled, keep the old path as the default. Delete this when you add
// your own first flagged feature.
func ExampleFeature(ctx context.Context, client *openfeature.Client) string {
if Enabled(ctx, client, ExampleFlag) {
return "new feature output"
}

return "default output"
}
52 changes: 52 additions & 0 deletions pkg/featureflag/featureflag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package featureflag_test

import (
"context"
"testing"

"github.com/devantler-tech/go-template/pkg/featureflag"
)

func TestExampleFeature(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
flags map[string]bool
want string
}{
{
name: "flag on serves the new path",
flags: map[string]bool{featureflag.ExampleFlag: true},
want: "new feature output",
},
{
name: "flag off serves the default path",
flags: map[string]bool{featureflag.ExampleFlag: false},
want: "default output",
},
{
name: "flag absent defaults off",
flags: map[string]bool{},
want: "default output",
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

provider := featureflag.NewProvider(testCase.flags)

client, err := featureflag.NewClient(testCase.name, provider)
if err != nil {
t.Fatalf("NewClient(%q) returned error: %v", testCase.name, err)
}

got := featureflag.ExampleFeature(context.Background(), client)
if got != testCase.want {
t.Errorf("ExampleFeature() = %q, want %q", got, testCase.want)
}
})
}
}