Concepts

Configuration

Composing a project's configuration from module Config structs: envconf, env-prefix conventions 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 loading mechanism itself, envconf, is a standalone module (github.com/gp-system/envconf) with no gpsystem-specific knowledge: this page is about what a generated project does with it, composing a dozen modules' worth of settings into one struct.

Struct composition

Every standalone module exports its own env-tagged Config struct, naming its own fields without knowing what, if anything, will prefix them. A generated project composes those structs into a single root Config with envPrefix:

internal/platform/config/config.go
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"
    // 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 module 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 embeds telemetry.Config, which bundles the telemetry/otelx config, the standalone logx module's Config, and the telemetry/sentryx config (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 Valkey and scheduler configs (VALKEY_ADDR, SCHEDULER_TIMEZONE).

The env-prefix conventions

Across the modules a project actually wires in, the prefixes settle into a consistent set:

PrefixModuleExample variables
(none)framework's serverLISTEN_ADDR, SHUTDOWN_TIMEOUT
(none)framework's workerWORKER_CONCURRENCY, WORKER_QUEUES
(none)framework's realtimeREALTIME_LISTEN_ADDR, REALTIME_CHANNEL_PREFIX
DB_dbxDB_HOST, DB_USER, DB_PASSWORD
JWT_authJWT_SECRET, JWT_ACCESS_TOKEN_TTL
MAIL_mail/smtpMAIL_HOST, MAIL_FROM_ADDRESS
VALKEY_queueVALKEY_ADDR, VALKEY_PASSWORD
OUTBOX_events/outboxOUTBOX_POLL_INTERVAL, OUTBOX_BATCH_SIZE
SCHEDULER_events/schedulerSCHEDULER_TIMEZONE, SCHEDULER_LEASE_TTL
S3_minio-storage-driverS3_ENDPOINT, S3_BUCKET
(none, standard)telemetry (otelx/sentryx) + logxOTEL_*, LOG_*, SENTRY_*

The shop's env surface, an excerpt combining several of these:

.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 VALKEY_*
WORKER_CONCURRENCY=10
VALKEY_ADDR=localhost:6379

# outbox.Config: under the OUTBOX_ prefix
OUTBOX_POLL_INTERVAL=1s
No module, and no part of the framework, introduces its own env namespace. Where an industry-standard name exists (OTEL_*, SENTRY_*), it's used as-is; where none does, short prefixed names (DB_*, VALKEY_*, WORKER_*). Your deployment looks like any other OTel-instrumented app's. The full per-module variable table, generated project by generated project: Configuration reference.

.env file loading order

Before parsing, on a best-effort basis, envconf loads the working directory's .env file, if present, via godotenv: it never overrides an existing environment variable, so .env only fills in what the process environment did not already provide. That single rule is what makes the same Config struct behave correctly in every context:

  • Local development: nothing is exported in the shell; a generated project ships a .env.example you copy to .env, and every value comes from the file.
  • The dev docker-compose stack: mise run dev starts the compose services (Postgres, Valkey, Mailpit, RustFS) and the app container with the same .env; service hostnames in the file (DB_HOST=postgres, VALKEY_ADDR=valkey:6379) resolve to compose's internal DNS, not localhost. See Installation for the full stack.
  • Staging/production: the real environment (a systemd unit, a Kubernetes ConfigMap/Secret) sets the variables directly; there usually is no .env file at all, and even if one were present, the already-set variables would win regardless.

envconf.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. See envconf: Overview for Load/MustLoad/LoadPrefixed themselves, and envconf: Recipes for composition and testing patterns beyond the basic case above (multi-instance connections, t.Setenv in tests).

Configuration as an explicit dependency

In gpsystem, configuration materializes once in main() and travels onward as an explicit parameter (cfg.Server to server.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