Define and Validate Config for a Component¶
When building a new feature package for a GTB-based tool, you need to handle two concerns:
- Config defaults — what values should exist if the user doesn't provide them
- Config validation — catching typos, missing required fields, and invalid values at startup
GTB separates these responsibilities deliberately. Defaults live in embedded assets. Validation lives in struct tags. This guide shows how to wire both.
How It Fits Together¶
Embedded defaults User config file Environment variables
(assets/config.yaml, ↓ ↓
merged across bundles) → layered Store ← env backend
↓ (precedence = layer order)
└──────────────────────────↓
store.View() (pinned snapshot)
↓
Package calls ValidateStruct[T]
↓
✓ pass → use config ✗ fail → actionable error
Each package owns its slice of the config. No centralised schema is needed.
Quick Start: Scaffolding with the Generator¶
If you are creating a new command, the gtb generate command tool can scaffold the config validation boilerplate for you:
This creates a config.go file in your command package containing:
- A
Configstruct stub with exampleconfigstruct tags - A
ValidateConfigfunction wired to the schema validation engine
After scaffolding, you need to:
- Edit the
Configstruct inconfig.go— replace the TODO comments with your actual config fields and tags - Add your config defaults to
assets/config.yaml(seeded by the generator beside theassets/init/template) - Call
ValidateConfigfrom your command'sRunEor initialiser (see Step 4 below)
The generated config.go is yours to customise. Subsequent regenerate runs will never overwrite it — your changes are preserved. The rest of this guide explains each piece in detail.
Step 1: Define Config Defaults in Embedded Assets¶
Create an assets/config.yaml file in your package with sensible defaults.
This is the defaults document; the sibling assets/init/config.yaml is a
different thing — the human-facing template init writes into the user's
config file:
pkg/myfeature/
├── assets/
│ ├── config.yaml # defaults — always applied as the lowest layer
│ └── init/
│ └── config.yaml # init template — written to the user's file
├── config.go
├── feature.go
└── assets.go
pkg/myfeature/assets/config.yaml:
Embed the assets and register the bundle. A command package registers in its constructor (the generator emits this for you):
func NewCmdMyFeature(p *props.Props) *setup.Command {
p.Assets.Register("myfeature", &assets)
// ...
}
A non-command feature package announces its bundle through the setup
registry from init(); the root command applies the bundles of enabled
features during construction, so a disabled feature's defaults never leak into
the resolved configuration:
func init() {
setup.RegisterAssets(props.FeatureCmd("myfeature"), "myfeature", &assets)
setup.Register(props.FeatureCmd("myfeature"), /* initialisers, subcommands, flags */)
}
props.Assets merges assets/config.yaml across every registered bundle, and
the root bootstrap loads that merged document as the store's lowest layer.
These defaults always apply: a key omitted from the user's file resolves
to your default rather than a zero value, and users override them in their
config file, environment, or flags. Do not duplicate these values in struct
tags — the default tag is for documentation and hints only.
Step 2: Define the Config Struct with Validation Tags¶
Create a struct that describes the config keys your package consumes:
// pkg/myfeature/config.go
package myfeature
import "gitlab.com/phpboyscout/go/config"
// Config describes the configuration keys consumed by myfeature.
type Config struct {
APIKey string `config:"myfeature.api_key" validate:"required"`
Endpoint string `config:"myfeature.endpoint" validate:"required"`
LogLevel string `config:"myfeature.log_level" enum:"debug,info,warn,error" default:"info"`
Timeout string `config:"myfeature.timeout"`
}
Tag reference:
| Tag | Effect |
|---|---|
config:"myfeature.api_key" |
Maps to the dot-separated config key |
validate:"required" |
Fails if the key is absent or zero-valued |
enum:"debug,info,warn,error" |
Fails if the value is not in the allowed set |
default:"info" |
Appears in error hints — does not set the value |
config:"-" |
Skips the field entirely |
Step 3: Add a Validation Function¶
Expose a function that validates the config slice your package cares about. This
is exactly what the generator scaffolds when you pass --with-config-validation:
// ValidateConfig checks that all required myfeature config keys are present
// and that constrained values are within their allowed sets.
func ValidateConfig(cfg config.Reader) error {
return config.ValidateStruct[Config](cfg)
}
ValidateStruct[T] derives the schema from T's struct tags (caching it per
type), runs it against the resolved configuration, and returns a formatted
error if anything fails. It takes the config.Reader interface, so callers
pass props.Config.View() in production and the published MockReader in
tests — never a concrete store type.
If you need the ValidationResult itself — to inspect warnings, say — build the
schema with SchemaOf[T] and validate by hand:
func ValidateConfig(cfg config.Reader) error {
schema, err := config.SchemaOf[Config]()
if err != nil {
return err
}
result := cfg.Validate(schema)
if !result.Valid() {
return errors.New(result.Error())
}
// Optionally log warnings (e.g., unknown keys under myfeature.*)
for _, w := range result.Warnings {
// log warning
}
return nil
}
Step 4: Call Validation at the Right Time¶
Validate in your command's RunE or PersistentPreRunE, after config has been loaded:
func NewCmdMyFeature(p *props.Props) *setup.Command {
return setup.Wrap("myfeature", &cobra.Command{
Use: "myfeature",
Short: "Do something with myfeature",
RunE: func(cmd *cobra.Command, args []string) error {
if err := myfeature.ValidateConfig(p.Config); err != nil {
return err
}
// Config is valid — proceed
return run(cmd.Context(), p)
},
})
}
If validation fails, the user sees actionable output:
config validation failed:
myfeature.api_key: required field is missing (hint: add myfeature.api_key to your config file or set the MYFEATURE_API_KEY environment variable)
myfeature.log_level: value "verbose" is not allowed (hint: allowed values: debug, info, warn, error)
Step 5: Gate Hot-Reloads (Optional)¶
For long-running services, attach the schema to the container to prevent invalid config reloads from reaching observers:
schema, err := config.SchemaOf[Config]()
if err != nil {
return err
}
// Attach the schema at construction with WithSchema. The store then validates
// every reload against it and rejects an invalid one — observers keep the last
// valid configuration rather than being handed a broken reload.
store, err := config.NewStore(ctx,
config.WithSchema(schema),
config.WithFiles(fsys, paths...),
)
See React to Configuration Changes at Runtime for the full hot-reload pattern.
Step 6: Strict Mode (Optional)¶
By default, unknown keys produce warnings. If your package needs tighter control — for example, a user-facing config file where typos should be caught — enable strict mode by passing the option straight through:
In strict mode, myfeature.endpont (typo) would produce an error instead of a warning.
Testing¶
Build a store over an in-memory config file and pass a pinned View (a
config.Reader) to your validator:
func TestValidateConfig_Valid(t *testing.T) {
fs := afero.NewMemMapFs()
err := afero.WriteFile(fs, "/config.yaml", []byte(`
myfeature:
api_key: "secret"
endpoint: "https://api.example.com"
log_level: info
`), 0o600)
require.NoError(t, err)
store, err := config.NewStore(t.Context(),
config.WithFiles(configafero.Wrap(fs), "/config.yaml"))
require.NoError(t, err)
err = myfeature.ValidateConfig(store.View())
assert.NoError(t, err)
}
func TestValidateConfig_MissingRequired(t *testing.T) {
fs := afero.NewMemMapFs()
err := afero.WriteFile(fs, "/config.yaml", []byte(`
myfeature:
log_level: info
`), 0o600)
require.NoError(t, err)
store, err := config.NewStore(t.Context(),
config.WithFiles(configafero.Wrap(fs), "/config.yaml"))
require.NoError(t, err)
err = myfeature.ValidateConfig(store.View())
require.Error(t, err)
assert.Contains(t, err.Error(), "myfeature.api_key")
}
configafero.Wrap bridges an afero.Fs to the store's filesystem interface —
see Test Configuration for the reader-based variant.
What NOT to Do¶
Don't define defaults in struct tags AND in embedded assets. Pick one source of truth. Embedded assets are the correct place for defaults; the default tag is documentation only.
Don't create a single global schema for the whole config. Each package validates its own slice. A global schema would need to know which features are active and would couple packages together.
Don't reach for the concrete store to validate. ValidateStruct[T] and View.Validate both work through the config.Reader interface, so a package never needs the concrete *config.Store — a pinned view (or a mock) is enough.
Related Documentation¶
- Configuration component — the Store, views, layers, and schema validation reference
- Embed and Register Custom Assets — how to ship config defaults with your package
- React to Configuration Changes at Runtime — hot-reload and observer patterns
- Add an Initialiser — the full feature registration pattern including
IsConfiguredchecks