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 assets (defaults) User config file Environment variables
โ โ โ
Props.Assets.Open() โ Viper merge hierarchy โ AutomaticEnv
โ
Container (merged config)
โ
Package calls Validate(schema)
โ
โ 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/init/config.yaml(created by--assets) - 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/init/config.yaml file in your package with sensible defaults:
pkg/myfeature/
โโโ assets/
โ โโโ init/
โ โโโ config.yaml
โโโ config.go
โโโ feature.go
โโโ assets.go
pkg/myfeature/assets/init/config.yaml:
Embed and register the assets:
Register during initialisation so the merge hierarchy picks up your defaults:
func init() {
setup.Register(props.FeatureCmd("myfeature"),
[]setup.InitialiserProvider{
func(p *props.Props) setup.Initialiser {
p.Assets.Mount(assets, "pkg/myfeature")
return &Initialiser{}
},
},
// ...
)
}
These defaults are now the baseline. Users override them in their config file or via environment variables. 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-tool-base/pkg/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.Containable) error {
return config.ValidateStruct[Config](cfg)
}
ValidateStruct[T] derives the schema from T's struct tags (caching it per
type), runs it against the container, and returns a formatted error if anything
fails. It takes the Containable interface, so callers never have to reach for
the concrete *config.Container.
If you need the ValidationResult itself โ to inspect warnings, say โ build the
schema with SchemaOf[T] and validate by hand:
func ValidateConfig(cfg config.Containable) 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
}
container.SetSchema(schema)
// Now if the config file changes and validation fails,
// observers are NOT notified and the previous valid config stays in effect.
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¶
Test validation using in-memory config containers:
func TestValidateConfig_Valid(t *testing.T) {
l := logger.NewNoop()
fs := afero.NewMemMapFs()
err := afero.WriteFile(fs, "/config.yaml", []byte(`
myfeature:
api_key: "secret"
endpoint: "https://api.example.com"
log_level: info
`), 0o644)
require.NoError(t, err)
c, err := config.LoadFilesContainer(fs, config.WithLogger(l), config.WithConfigFiles("/config.yaml"))
require.NoError(t, err)
err = myfeature.ValidateConfig(c)
assert.NoError(t, err)
}
func TestValidateConfig_MissingRequired(t *testing.T) {
l := logger.NewNoop()
fs := afero.NewMemMapFs()
err := afero.WriteFile(fs, "/config.yaml", []byte(`
myfeature:
log_level: info
`), 0o644)
require.NoError(t, err)
c, err := config.LoadFilesContainer(fs, config.WithLogger(l), config.WithConfigFiles("/config.yaml"))
require.NoError(t, err)
err = myfeature.ValidateConfig(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "myfeature.api_key")
}
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 add Validate to the Containable interface. It lives on *Container deliberately. Tests that mock config use Containable; validation runs against the real container in production.
Related Documentation¶
- Configuration component โ
Containable,Container, factory functions, 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