Skip to content

Getting started

Ten minutes, from an empty main to a tool that gates one command on a feature and evaluates one dynamic flag with the manifest as its fallback.

1. A descriptor

A feature is anything with an identity, a kind and a default. The core asks four questions of it and nothing more, so it is an interface; carry whatever else you need on your own type.

package feat

import "gitlab.com/phpboyscout/go/features"

type descriptor struct {
    id      features.ID
    kind    features.Kind
    on      bool
    dynamic bool
}

func (d descriptor) FeatureID() features.ID     { return d.id }
func (d descriptor) FeatureKind() features.Kind { return d.kind }
func (d descriptor) DefaultOn() bool            { return d.on }
func (d descriptor) IsDynamic() bool            { return d.dynamic }

const (
    Reports     features.ID = "reports"
    BetaRanking features.ID = "beta-ranking"
)

func init() {
    r := features.Default()
    features.MustDeclare(r, descriptor{id: Reports, kind: "command", on: true})
    features.MustDeclare(r, descriptor{id: BetaRanking, kind: "experiment", dynamic: true})
}

MustDeclare panics on a duplicate or an incomplete descriptor. That is deliberate: this runs at init, where there is no caller to return an error to, and a silently dropped feature would surface much later somewhere confusing. It is the only panic in the package.

2. A contribution

A package that owns a feature can also contribute what the feature brings, under a Slot name of your choosing. The core knows a slot is a name and a value; you define the type.

const SlotCommand features.Slot = "command"

func init() {
    features.Default().Contribute(Reports, SlotCommand, func() string { return "reports" })
}

3. Resolve, once

When your tool builds itself, take a snapshot and resolve the set from the tool's own choices. The states come from wherever you keep them (a manifest, a config file, flags); Enable and Disable build them.

snapshot := features.Default().Snapshot()

set, err := features.Resolve(snapshot, features.Apply(nil,
    features.Disable(feat.Reports),
))
if err != nil {
    // Enabling an ID nothing declared is the one error: fix the wiring.
    log.Fatal(err)
}

The snapshot is immutable. A feature declared after it is not in it, and nothing panics; a fresh snapshot sees it. Disabling an ID nothing declared is not an error: it is kept on set.Ignored() so a diagnostic can report it.

4. Gate something static

if set.Enabled(feat.Reports) {
    for _, c := range set.Contributions(feat.Reports, feat.SlotCommand) {
        register(c.(func() string)())
    }
}

Contributions on a Set hands out only the contributions of enabled features, and ContributionsOf[T] does the type assertion for you and names the slot and feature when one does not fit.

5. Evaluate a dynamic flag

BetaRanking reported IsDynamic, so a Backend may override it at request time. With no backend yet, the Set itself is an Evaluator and answers statically:

dec, _ := set.Evaluate(ctx, feat.BetaRanking, features.EvalContext{})
// dec.Enabled == false, dec.Reason == features.ReasonStatic

When a backend arrives, wrap it in Dynamic. Nothing at the call site changes:

flags := features.Dynamic(set, backend, features.WithInitTimeout(2*time.Second))
_ = flags.Init(ctx)

dec, err := flags.Evaluate(ctx, feat.BetaRanking, features.EvalContext{TargetingKey: userID})

If the backend is not ready, errors, or does not know the flag, dec carries the static state with ReasonFallback and err says why. Reports is static-only, so the backend is never consulted for it, whatever it says.

That is the whole shape. The how-to guides cover each role in detail.