Recipes
Three patterns cover almost everything a real project needs beyond the basic Load/MustLoad call on the Overview page: composing a root config out of several modules' configs, loading the same shape more than once under different prefixes, and testing code that reads configuration.
Composing a root Config from module configs
Every gp-system module that needs configuration exports its own Config struct, tagged with its own field names, with no opinion on what (if anything) prefixes them. A generated gpsystem project composes those structs into one application-wide Config by nesting them as fields and choosing a prefix per field with envPrefix:
package config
import (
"github.com/gp-system/dbx"
"github.com/gp-system/events/outbox"
"github.com/gp-system/framework/server"
"github.com/gp-system/framework/worker"
)
type Config struct {
Server server.Config
DB dbx.Config `envPrefix:"DB_"`
Worker worker.Config
Outbox outbox.Config `envPrefix:"OUTBOX_"`
}
cfg := envconf.MustLoad[config.Config]()
One envconf.Load[AppConfig]() (or MustLoad) call at the top of main() is all a process needs: every module's settings arrive fully typed in one struct, with no further parsing anywhere else in the codebase. The rule is: a module names its own fields, the embedder decides the prefix. dbx.Config's own tags are HOST, PORT, USER, ...; nested under envPrefix:"DB_" they become DB_HOST, DB_PORT, DB_USER. A struct embedded without a prefix (like server.Config above) reads its variables under their own, unprefixed names. Nesting can go arbitrarily deep: server.Config itself embeds a telemetry.Config, unprefixed, because its variables (OTEL_SERVICE_NAME, SENTRY_DSN, LOG_LEVEL) are already industry-standard names that every OTel/Sentry-aware tool expects as-is. See Configuration for the full prefix table gpsystem's own modules settle into.
Prefixed multi-instance loads
envPrefix on a nested field only helps when the struct is embedded once. When a process genuinely needs two independent instances of the same config shape (two Valkey connections: one for caching, one for the queue), LoadPrefixed loads the same type twice, under two different top-level prefixes, with no struct nesting required:
type ValkeyConfig struct {
Addr string `env:"ADDR,required"`
Password string `env:"PASSWORD"`
DB int `env:"DB" envDefault:"0"`
}
cache, err := envconf.LoadPrefixed[ValkeyConfig]("CACHE_VALKEY_") // CACHE_VALKEY_ADDR, CACHE_VALKEY_PASSWORD, CACHE_VALKEY_DB
queue, err := envconf.LoadPrefixed[ValkeyConfig]("QUEUE_VALKEY_") // QUEUE_VALKEY_ADDR, QUEUE_VALKEY_PASSWORD, QUEUE_VALKEY_DB
CACHE_VALKEY_ADDR=localhost:6379
QUEUE_VALKEY_ADDR=localhost:6380
QUEUE_VALKEY_DB=1
Each call is independent: a required field missing under one prefix fails only that call, not the other. Reach for LoadPrefixed specifically when the two instances are genuinely separate connections with independently varying settings; when they'd always share the same values, a single unprefixed Config field is simpler.
Testing with t.Setenv
Because Load/MustLoad read the real process environment, tests that exercise configuration-dependent code set it directly with t.Setenv, which Go automatically restores after the test (and fails if the test is parallel, which is the right failure: environment variables are process-global state):
func TestLoad_defaults(t *testing.T) {
t.Setenv("DB_HOST", "localhost")
t.Setenv("DB_USER", "test")
cfg := envconf.MustLoad[config.Config]()
if cfg.DB.Host != "localhost" {
t.Errorf("DB.Host = %q, want %q", cfg.DB.Host, "localhost")
}
}
func TestLoad_missingRequired(t *testing.T) {
// DB_HOST intentionally not set
_, err := envconf.Load[config.Config]()
if err == nil {
t.Fatal("expected an error for missing required DB_HOST")
}
}
No .env file is involved here: t.Setenv sets real process environment variables for the duration of the test, exactly what Load reads first (before falling back to any .env value), so the test is deterministic regardless of whether a .env file happens to exist in the working directory.
With the framework
A generated gpsystem project's internal/platform/config package is exactly the composition pattern above, generated once and extended by add surface/add db/add worker as modules are added. See Configuration for the concept-level view (why configuration is an explicit, boot-time-only dependency in gpsystem) and Configuration reference for the full, generated-project-by-generated-project variable table.
Related pages
- envconf: Overview:
Load/MustLoad/LoadPrefixed, tags,.envloading. - Configuration: the composition principle and the env-prefix conventions across gp-system modules.
- Configuration reference: every env variable, module by module.