Concepts

Configuration

Typed configuration from environment variables: envconf, struct composition and the .env file.

In gpsystem, loading configuration is a single step: environment variables loaded into a typed Go struct, once, at process start. There is no config() helper and no config cache: the struct is the cache, and a mistyped key is not a runtime null but a compile error.

This is the 12-factor "config from the environment" principle: configuration is read at process start, from the environment, and the binary is identical in every environment (dev, staging, production); only the env differs.

The envconf package

import "github.com/gp-system/gpsystem/envconf"
func Load[T any]() (T, error)
func MustLoad[T any]() T                       // panics with the name of the bad/missing variable
func LoadPrefixed[T any](prefix string) (T, error)

The package builds on caarlos0/env v11: struct fields bind to variable names via env:"..." tags, envDefault supplies defaults, and env:"...,required" makes a variable mandatory. Before parsing, on a best-effort basis, godotenv loads the working directory's .env file if present: it never overrides an existing env variable, so .env only fills in what the environment did not provide. For local development you export nothing (generated projects ship a .env.example), and in production the real environment (systemd unit, K8s ConfigMap/Secret) wins.

MustLoad is the first line of main(): a missing required variable fails with the variable's name, at startup. No service ever boots half-configured only to reveal what's missing on the first request.

Struct composition

Kit packages export env-tagged config structs, and the application composes them into a single struct with envPrefix. The shop project's generated configuration:

internal/platform/config/config.go
package config

import (
    "github.com/gp-system/gpsystem/dbx"
    "github.com/gp-system/gpsystem/events/outbox"
    "github.com/gp-system/gpsystem/server"
    "github.com/gp-system/gpsystem/worker"
    // gpsystem:imports
)

type Config struct {
    Server server.Config
    DB     dbx.Config `envPrefix:"DB_"`
    Worker worker.Config
    Outbox outbox.Config `envPrefix:"OUTBOX_"`
    // gpsystem:config
}
cmd/shop/main.go
cfg := envconf.MustLoad[config.Config]()

The composition rule is simple: a package names its own fields, the embedder decides the prefix. dbx.Config's fields are HOST, PORT, USER, ...: under envPrefix:"DB_" they become DB_HOST, DB_PORT, DB_USER. Structs embedded without a prefix (server.Config, worker.Config) read standard top-level names.

The nesting goes deeper: server.Config itself contains telemetry.Config, which bundles the otelx + logx + sentryx configs (all unprefixed, because these are industry-standard names (OTEL_SERVICE_NAME, OTEL_DEV_MODE, LOG_LEVEL, SENTRY_DSN) that every OTel/Sentry-compatible tool expects as-is). worker.Config in turn prefixes its Redis and scheduler configs (REDIS_ADDR, SCHEDULER_TIMEZONE).

The shop's env surface therefore looks like this (excerpt):

.env
# server.Config: no prefix
LISTEN_ADDR=:3000
SHUTDOWN_TIMEOUT=10s

# telemetry (embedded in server.Config): standard names
OTEL_SERVICE_NAME=shop
OTEL_DEV_MODE=true
LOG_FORMAT=monolog
SENTRY_DSN=

# dbx.Config: under the DB_ prefix
DB_HOST=localhost
DB_USER=shop
DB_PASSWORD=secret
DB_NAME=shop

# worker.Config: WORKER_* and REDIS_*
WORKER_CONCURRENCY=10
REDIS_ADDR=localhost:6379

# outbox.Config: under the OUTBOX_ prefix
OUTBOX_POLL_INTERVAL=1s
The kit deliberately does not introduce its own env namespace. Where an industry-standard name exists (OTEL_*, SENTRY_*), it uses that; where none does, short prefixed names (DB_*, REDIS_*, WORKER_*). Your deployment looks like any other OTel-instrumented app's. The full per-package variable table: Configuration reference.

Your own fields

App-specific configuration goes into the same struct, below the // gpsystem:config anchor, so future generator additions (e.g. add worker retrofitting an older project) don't collide with it:

type Config struct {
    Server server.Config
    DB     dbx.Config `envPrefix:"DB_"`
    // ...
    JWT           auth.Config `envPrefix:"JWT_"`                       // JWT_SECRET, JWT_ACCESS_TOKEN_TTL, ...
    StripeKey     string      `env:"STRIPE_API_KEY,required"`
    OrderCapacity int         `env:"ORDER_CAPACITY" envDefault:"100"`
}

caarlos0/env parses all the usual types: time.Duration (10s, 168h), []string (comma-separated), map[string]int (critical:6,default:3), bool, numbers: you can see all of them live on server.Config's and worker.Config's fields.

LoadPrefixed[dbx.Config]("ANALYTICS_DB_") loads a second config with its own variable set, so one process can talk to a separate analytics database without extending the main Config struct.

Configuration as an explicit dependency

In gpsystem, configuration materializes once in main() and travels onward as an explicit parameter (cfg.Server to fiberx.Run, cfg.DB to pg.MustNewPool, and to the modules via the Dependencies struct). A service never touches the environment at runtime. What it receives is the boot-time snapshot, freely constructible in tests. A mistyped field is a compile error, and a missing required env variable is an error at boot, with the variable's name, never a silent null at runtime.

Copyright © 2026