Configuration Without IOptions<T>

In ASP.NET Core, configuration is a whole subsystem. IConfiguration, IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>, multiple providers, hot reload, dependency injection integration… It’s sophisticated. Maybe too sophisticated.

Go doesn’t have a standard configuration library. The community uses environment variables, simple file parsing, or third-party libraries. It’s less powerful and often exactly what you need.

The Simplest Approach: Environment Variables

The Go standard library makes environment variables easy:

port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}

dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
    log.Fatal("DATABASE_URL required")
}

That’s it. No configuration provider chain. No dependency injection. Just read the environment.

For typed values:

timeout, err := strconv.Atoi(os.Getenv("TIMEOUT_SECONDS"))
if err != nil {
    timeout = 30  // default
}

debug := os.Getenv("DEBUG") == "true"

Struct-Based Configuration

Most projects define a config struct:

type Config struct {
    Port        string
    DatabaseURL string
    Debug       bool
    Timeout     time.Duration
}

func LoadConfig() (*Config, error) {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        return nil, errors.New("DATABASE_URL required")
    }
    
    debug := os.Getenv("DEBUG") == "true"
    
    timeout := 30 * time.Second
    if t := os.Getenv("TIMEOUT"); t != "" {
        d, err := time.ParseDuration(t)
        if err != nil {
            return nil, fmt.Errorf("invalid TIMEOUT: %w", err)
        }
        timeout = d
    }
    
    return &Config{
        Port:        port,
        DatabaseURL: dbURL,
        Debug:       debug,
        Timeout:     timeout,
    }, nil
}

Then in main:

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        log.Fatalf("config error: %v", err)
    }
    
    server := NewServer(cfg)
    server.Run()
}

No interfaces. No DI. Pass the config struct to things that need it.

envconfig: Less Boilerplate

The envconfig package reduces repetition:

import "github.com/kelseyhightower/envconfig"

type Config struct {
    Port        string        `envconfig:"PORT" default:"8080"`
    DatabaseURL string        `envconfig:"DATABASE_URL" required:"true"`
    Debug       bool          `envconfig:"DEBUG" default:"false"`
    Timeout     time.Duration `envconfig:"TIMEOUT" default:"30s"`
}

func LoadConfig() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

Struct tags define environment variable names, defaults, and requirements. Much cleaner.

Viper: The Kitchen Sink

If you need file-based config, multiple formats, or hot reload, Viper is the standard choice:

import "github.com/spf13/viper"

func LoadConfig() (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(".")
    viper.AddConfigPath("/etc/myapp/")
    
    // Environment variables override file
    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYAPP")
    
    // Defaults
    viper.SetDefault("port", "8080")
    viper.SetDefault("timeout", "30s")
    
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, err
        }
        // Config file not found, continue with env vars and defaults
    }
    
    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }
    
    return &cfg, nil
}

config.yaml:

port: "8080"
database_url: "postgres://localhost/myapp"
debug: false
timeout: "30s"

Viper is powerful but adds complexity. Use it when you need it.

Comparing to .NET

Feature.NET IConfigurationGo
Multiple sourcesBuilt-inViper or manual
Environment variablesProvideros.Getenv
JSON/YAML filesProvidersViper or manual
Strong typingIOptions<T>Struct + unmarshal
ValidationData annotationsManual or validator
Hot reloadIOptionsMonitor<T>Viper.WatchConfig
DI integrationBuilt-inManual
SecretsUser secrets, Key VaultEnv vars, external

.NET’s configuration system is more integrated. Go’s is simpler but less cohesive.

Configuration Patterns

Environment-Specific Loading

func LoadConfig() (*Config, error) {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    // Base config
    cfg := Config{
        Port:    "8080",
        Debug:   false,
        Timeout: 30 * time.Second,
    }
    
    // Environment overrides
    switch env {
    case "production":
        cfg.Debug = false
    case "development":
        cfg.Debug = true
    }
    
    // Env vars override everything
    if port := os.Getenv("PORT"); port != "" {
        cfg.Port = port
    }
    // ... more overrides
    
    return &cfg, nil
}

Validation

func (c *Config) Validate() error {
    if c.DatabaseURL == "" {
        return errors.New("database_url is required")
    }
    if c.Port == "" {
        return errors.New("port is required")
    }
    if c.Timeout <= 0 {
        return errors.New("timeout must be positive")
    }
    return nil
}

func LoadConfig() (*Config, error) {
    cfg := &Config{...}
    // ... load values ...
    
    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("config validation: %w", err)
    }
    
    return cfg, nil
}

Immutable Config

Make config read-only after loading:

type Config struct {
    port        string
    databaseURL string
}

func (c *Config) Port() string        { return c.port }
func (c *Config) DatabaseURL() string { return c.databaseURL }

func LoadConfig() *Config {
    return &Config{
        port:        os.Getenv("PORT"),
        databaseURL: os.Getenv("DATABASE_URL"),
    }
}

Unexported fields + getter methods = immutable from outside the package.

What About IOptions?

.NET’s IOptions<T> pattern has benefits:

  • Strongly typed configuration sections
  • Validation on startup
  • Hot reload with IOptionsMonitor<T>
  • Clean injection into services

Go’s equivalent is just… passing a struct:

// .NET
public class MyService
{
    public MyService(IOptions<DatabaseConfig> options)
    {
        _connectionString = options.Value.ConnectionString;
    }
}

// Go
type MyService struct {
    connString string
}

func NewMyService(cfg *Config) *MyService {
    return &MyService{connString: cfg.DatabaseURL}
}

Less ceremony in Go. But also less framework support if you want validation, hot reload, or named options.

The Honest Take

Go’s configuration story is simpler. Whether that’s better depends on your needs.

What Go does well:

  • Environment variables are trivial
  • No framework to learn
  • Full control over loading logic
  • Fast startup (no config system initialization)

What .NET does better:

  • Integrated with DI
  • Multiple providers out of the box
  • Better hot reload story
  • Options validation built-in
  • Secrets management

The verdict: For microservices that read environment variables and a config file, Go’s approach is fine. envconfig handles 90% of cases.

For complex applications with layered configuration, validation requirements, and hot reload, you’ll either use Viper or miss .NET’s configuration system.

Start simple. Environment variables and envconfig get you far. Reach for Viper when you actually need its features.


Next up: logging with slog—Go’s new structured logging standard and how it compares to ILogger.