Go configuration best practices illustration

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

  1. Library ≠ Application

    • Don’t read environment variables or flags implicitly.
    • Don’t log or call os.Exit in your library. Return errors instead.
  2. Zero-Value Usable + Explicit Options

    • Provide sane defaults so New() works without any configuration.
    • Let users override with a small Config struct or functional options.
  3. Separation of Concerns

    • The application decides how to load config (env, YAML, Viper, Koanf, etc.).
    • The library just consumes it.
  4. Deterministic Precedence

    • Defaults < Config struct < Functional options.
    • Document this clearly.
  5. Validation Up Front

    • Validate configuration in New().
    • Return helpful errors with missing or invalid fields.
  6. Context-Aware

    • Accept context.Context in operations.
    • Avoid globals.
  7. 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.Getenv in New().
  • 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 Config table 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.