Designing Configuration for Open-Source Go Libraries
When building an open-source Go library, one of the most common questions is:
How should configuration work?
Should the library read environment variables? Should it parse flags or files? Or should it leave everything to the application developer?
The short answer: keep your library clean and framework-agnostic. Let applications decide how to load configuration, and keep your library focused on consuming it.
This post outlines best practices, patterns, and pitfalls when designing configuration for Go libraries.
Core Principles
-
Library ≠ Application
- Don’t read environment variables or flags implicitly.
- Don’t log or call
os.Exitin your library. Return errors instead.
-
Zero-Value Usable + Explicit Options
- Provide sane defaults so
New()works without any configuration. - Let users override with a small
Configstruct or functional options.
- Provide sane defaults so
-
Separation of Concerns
- The application decides how to load config (env, YAML, Viper, Koanf, etc.).
- The library just consumes it.
-
Deterministic Precedence
- Defaults < Config struct < Functional options.
- Document this clearly.
-
Validation Up Front
- Validate configuration in
New(). - Return helpful errors with missing or invalid fields.
- Validate configuration in
-
Context-Aware
- Accept
context.Contextin operations. - Avoid globals.
- Accept
-
Minimal, Stable API
- Add new config fields with defaults to avoid breaking changes.
- Deprecate fields gradually.
Example: Core Library Configuration
Here’s a simple, dependency-free approach:
package foo
import (
"context"
"time"
)
type Config struct {
BaseURL string
APIKey string
Timeout time.Duration
MaxRetries int
Logger Logger // optional
}
func (c *Config) fillDefaults() {
if c.Timeout == 0 {
c.Timeout = 10 * time.Second
}
if c.MaxRetries == 0 {
c.MaxRetries = 3
}
}
func (c *Config) validate() error {
if c.APIKey == "" {
return fmt.Errorf("missing APIKey")
}
return nil
}
type Option func(*Config)
func WithAPIKey(k string) Option { return func(c *Config) { c.APIKey = k } }
func WithTimeout(d time.Duration) Option{ return func(c *Config) { c.Timeout = d } }
type Client struct {
cfg Config
}
func New(opts ...Option) (*Client, error) {
var cfg Config
for _, opt := range opts {
opt(&cfg)
}
cfg.fillDefaults()
if err := cfg.validate(); err != nil {
return nil, err
}
return &Client{cfg: cfg}, nil
}
func (c *Client) Do(ctx context.Context, in any) (any, error) {
// implementation here
return nil, nil
}
This design makes Client usable with just:
client, _ := foo.New(foo.WithAPIKey("secret"))
Optional Environment Variable Adapters
Instead of baking env logic into the library, provide it as an optional helper package:
foo/
configenv/
package configenv
import (
"os"
"strconv"
"time"
"github.com/you/foo"
)
const prefix = "FOO_"
func Load() (foo.Config, error) {
var c foo.Config
c.APIKey = os.Getenv(prefix + "API_KEY")
c.BaseURL = os.Getenv(prefix + "BASE_URL")
if v := os.Getenv(prefix + "TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.Timeout = d
}
}
if v := os.Getenv(prefix + "MAX_RETRIES"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
c.MaxRetries = n
}
}
return c, nil
}
This way, the application chooses:
- Load config from env with
configenv.Load() - Or use Koanf/Viper/flags/YAML/etc.
The library core stays clean.
What to Avoid
- Implicit
os.GetenvinNew(). - Global state and singletons.
- Panics for user errors.
- Heavy config dependencies in the core package.
- Hidden precedence or defaults.
Documentation Checklist
When publishing your library:
- ✅ Quick start with defaults (
New()+ options) - ✅ Full
Configtable with defaults and required fields - ✅ Env var adapter usage, with a table of variables
- ✅ Example integrations with Koanf/Viper/flags (as snippets)
- ✅ Security notes (secrets, timeouts, retries)
- ✅ Versioning and breaking-change policy
TL;DR
- Keep the core library pure:
Config+ functional options. - Don’t read env vars or flags implicitly.
- Offer opt-in adapters (env, Viper, Koanf) in separate packages.
- Document defaults, precedence, and validation rules.
By following these patterns, you make your Go library predictable, flexible, and integration-friendly—all without forcing choices on downstream applications.
You might also like
Designing Configuration for Open-Source Go Libraries
Best practices for balancing configuration, environment variables, and functional options when building open-source Go libraries.
Modern Python Package Structure with pyproject.toml, uv, Scripts, and FastAPI
A concise guide to building a clean, modern Python project using pyproject.toml, uv, scripts, and FastAPI.
Managing Multiple GPG Keys and YubiKey Setup
A practical guide to managing multiple GPG private keys — exporting, importing, backing up, and securely storing them on a YubiKey for signing, encryption, and SSH authentication.