Skip to content

A lightweight, type-safe, multi-source configuration loader for Go (supports .env, JSON, YAML) with defaults, validation, and overrides.

License

Notifications You must be signed in to change notification settings

psmohan/configmate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

🧩 ConfigMate

Go Version Go Report Card License: MIT GoDoc

ConfigMate is a lightweight, type-safe, production-ready configuration loader for Go applications. Load configurations from .env, JSON, and YAML files with built-in defaults, validation, hot-reload, and layered overrides.

✨ Features

  • 🎯 Type-Safe - Struct-based configuration with compile-time type checking
  • πŸ“ Multi-Source - Load from .env, JSON, and YAML files
  • πŸ”„ Hot Reload - Watch files for changes and reload automatically
  • βœ… Validation - Required field validation and default values via struct tags
  • πŸ” Secure - Built-in security checks (path traversal, file size limits)
  • πŸš€ Production-Ready - Structured logging, metrics, and context support
  • πŸŽ›οΈ Flexible - Environment variable overrides with custom prefixes
  • πŸ’Ύ Caching - Optional configuration caching with TTL
  • πŸ§ͺ Well-Tested - Comprehensive test suite with >85% coverage
  • πŸ“¦ Minimal Dependencies - Only YAML parser and file watcher

πŸ“¦ Installation

go get github.com/psmohan/configmate

πŸš€ Quick Start

1. Define Your Configuration Struct

package main

import (
    "log"
    "github.com/psmohan/configmate/pkg/configmate"
)

type Config struct {
    AppName  string         `config:"app_name,default=MyApp"`
    Port     int            `config:"port,default=8080"`
    Debug    bool           `config:"debug,default=false"`
    Database DatabaseConfig `config:"database"`
}

type DatabaseConfig struct {
    Host     string `config:"host,required"`
    Port     int    `config:"port,default=5432"`
    Username string `config:"username,required"`
    Password string `config:"password,required"`
}

2. Create Configuration Files

config.yaml:

app_name: "MyApp"
port: 8080
debug: true

database:
  host: "localhost"
  port: 5432
  username: "admin"
  password: "secret"

.env:

APP_NAME=MyApp
PORT=8080
DEBUG=true
DATABASE_HOST=localhost
DATABASE_USERNAME=admin
DATABASE_PASSWORD=secret

3. Load Configuration

func main() {
    var cfg Config

    // Simple load
    if err := configmate.Load(&cfg, ".env", "config.yaml"); err != nil {
        log.Fatal(err)
    }

    // Use your config
    log.Printf("Starting %s on port %d", cfg.AppName, cfg.Port)
}

πŸ“– Usage Guide

Basic Loading

var cfg Config

// Load from single source
err := configmate.Load(&cfg, "config.yaml")

// Load from multiple sources (later sources override earlier ones)
err := configmate.Load(&cfg, ".env", "config.yaml")

// Panic on error (useful for startup)
configmate.MustLoad(&cfg, "config.yaml")

Advanced Options

loader := configmate.NewLoader(
    configmate.WithPrefix("APP"),              // ENV prefix: APP_PORT
    configmate.WithWatch(true),                // Enable hot reload
    configmate.WithCache(true),                // Enable caching
    configmate.WithCacheTTL(5*time.Minute),   // Cache for 5 minutes
    configmate.WithSecureMode(true),          // Enable security checks
    configmate.WithOnReload(func(cfg interface{}) {
        log.Println("Config reloaded!")
    }),
)

err := loader.Load(&cfg, "config.yaml")

With Context (Timeouts & Cancellation)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

err := loader.LoadWithContext(ctx, &cfg, "config.yaml")

Hot Reload

loader := configmate.NewLoader(
    configmate.WithWatch(true),
    configmate.WithOnReload(func(newCfg interface{}) {
        c := newCfg.(*Config)
        log.Printf("Config reloaded! New port: %d", c.Port)
    }),
)

err := loader.Load(&cfg, "config.yaml")
// Config auto-reloads when file changes

With Logging & Metrics

logger := configmate.NewDefaultLogger(configmate.LogLevelInfo)
metrics := configmate.NewDefaultMetrics()

loader := configmate.NewLoader(
    configmate.WithLogger(logger),
    configmate.WithMetrics(metrics),
)

err := loader.Load(&cfg, "config.yaml")

// Check metrics
stats := metrics.GetStats()
fmt.Printf("Loads: %d, Errors: %d\n", stats.LoadCount, stats.ErrorCount)

🏷️ Struct Tags Reference

Tag Syntax

`config:"key_name,option1,option2"`

Available Options

Option Description Example
config:"name" Map to config key config:"app_name"
,default=value Set default value config:"port,default=8080"
,required Mark as required config:"api_key,required"
config:"-" Ignore field config:"-"

Examples

type Config struct {
    // Custom key name
    AppName string `config:"app_name"`

    // With default value
    Port int `config:"port,default=8080"`

    // Required field
    APIKey string `config:"api_key,required"`

    // Required with default
    DBHost string `config:"db_host,required,default=localhost"`

    // Nested struct
    Server ServerConfig `config:"server"`

    // Ignored field
    Internal string `config:"-"`

    // Private field (auto-ignored)
    private string
}

πŸ”§ Configuration Precedence

ConfigMate loads configurations in this order (later sources override earlier):

1. Struct Defaults (from tags)
   ↓
2. Config Files (in order provided)
   ↓
3. Environment Variables (if EnvOverride=true, default)

Example:

loader.Load(&cfg, "config.yaml", ".env.local")
// Precedence: defaults < config.yaml < .env.local < ENV vars

🌍 Environment Variables

Automatic Mapping

Environment variables are automatically mapped:

type Config struct {
    Port int `config:"port"` // Maps to PORT or APP_PORT (with prefix)
}

With Prefix

loader := configmate.NewLoader(
    configmate.WithPrefix("APP"),
)
// PORT becomes APP_PORT
// DATABASE_URL becomes APP_DATABASE_URL

Nested Structs

type Config struct {
    Database DatabaseConfig `config:"database"`
}

type DatabaseConfig struct {
    Host string `config:"host"`
    Port int    `config:"port"`
}

// Environment variables:
// DATABASE_HOST=localhost
// DATABASE_PORT=5432

πŸ“ Supported File Formats

YAML (.yaml, .yml)

app_name: "MyApp"
port: 8080
debug: true

database:
  host: "localhost"
  port: 5432

servers:
  - name: "server1"
    port: 8081
  - name: "server2"
    port: 8082

JSON (.json)

{
  "app_name": "MyApp",
  "port": 8080,
  "debug": true,
  "database": {
    "host": "localhost",
    "port": 5432
  }
}

Environment (.env)

# Application settings
APP_NAME=MyApp
PORT=8080
DEBUG=true

# Database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_URL=postgresql://localhost:5432/mydb

πŸ” Security Features

Built-in Security Checks

When WithSecureMode(true) is enabled:

  • βœ… Path Traversal Protection - Prevents ../ attacks
  • βœ… File Size Limits - Default 10MB maximum
  • βœ… Extension Validation - Only .env, .json, .yaml, .yml
  • βœ… Path Depth Limits - Prevents excessive nesting
loader := configmate.NewLoader(
    configmate.WithSecureMode(true),  // Enabled by default
)

Custom Validation

loader := configmate.NewLoader(
    configmate.WithValidator(func(cfg interface{}) error {
        c := cfg.(*Config)
        if c.Port < 1024 {
            return errors.New("port must be >= 1024")
        }
        return nil
    }),
)

βš™οΈ Configuration Options

All Available Options

loader := configmate.NewLoader(
    // Environment variable prefix
    configmate.WithPrefix("APP"),

    // Enable file watching for hot reload
    configmate.WithWatch(true),

    // Callback when config reloads
    configmate.WithOnReload(func(cfg interface{}) {
        // Handle reload
    }),

    // Fail if config file not found
    configmate.WithFailOnMissingFile(true),

    // Environment variables override file config
    configmate.WithEnvOverride(true),

    // Custom logger
    configmate.WithLogger(logger),

    // Custom metrics collector
    configmate.WithMetrics(metrics),

    // Enable configuration caching
    configmate.WithCache(true),

    // Cache time-to-live
    configmate.WithCacheTTL(5 * time.Minute),

    // Custom validation function
    configmate.WithValidator(validatorFunc),

    // Enable security checks
    configmate.WithSecureMode(true),
)

πŸ“Š Logging & Metrics

Built-in Logger

// Create logger with log level
logger := configmate.NewDefaultLogger(configmate.LogLevelInfo)

loader := configmate.NewLoader(
    configmate.WithLogger(logger),
)

// Log Levels: LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError

Custom Logger

type MyLogger struct{}

func (l *MyLogger) Debug(msg string, fields map[string]interface{}) {
    // Your implementation
}

func (l *MyLogger) Info(msg string, fields map[string]interface{}) {
    // Your implementation
}

func (l *MyLogger) Warn(msg string, fields map[string]interface{}) {
    // Your implementation
}

func (l *MyLogger) Error(msg string, err error, fields map[string]interface{}) {
    // Your implementation
}

// Use custom logger
loader := configmate.NewLoader(
    configmate.WithLogger(&MyLogger{}),
)

Metrics

metrics := configmate.NewDefaultMetrics()

loader := configmate.NewLoader(
    configmate.WithMetrics(metrics),
)

// Get metrics snapshot
stats := metrics.GetStats()
fmt.Printf("Load Count: %d\n", stats.LoadCount)
fmt.Printf("Error Count: %d\n", stats.ErrorCount)
fmt.Printf("Reload Count: %d\n", stats.ReloadCount)
fmt.Printf("Cache Hits: %d\n", stats.CacheHits)
fmt.Printf("Cache Misses: %d\n", stats.CacheMisses)
fmt.Printf("Average Duration: %v\n", stats.AverageDuration)

πŸ§ͺ Testing

Run Tests

# Run all tests
go test ./...

# With coverage
go test -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

# Run benchmarks
go test -bench=. -benchmem ./...

Test Example

func TestMyApp(t *testing.T) {
    // Create temp config
    tmpDir := t.TempDir()
    configPath := filepath.Join(tmpDir, "config.yaml")

    content := `
app_name: "TestApp"
port: 9000
`
    os.WriteFile(configPath, []byte(content), 0644)

    // Load config
    var cfg Config
    err := configmate.Load(&cfg, configPath)

    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "TestApp", cfg.AppName)
    assert.Equal(t, 9000, cfg.Port)
}

🎯 Best Practices

1. Use Struct Tags

// βœ… Good: Clear tags with defaults
type Config struct {
    Port int `config:"port,default=8080"`
    API  string `config:"api_key,required"`
}

// ❌ Avoid: No tags, unclear mapping
type Config struct {
    Port int
    API  string
}

2. Separate Concerns

// βœ… Good: Separate configs
type Config struct {
    Server   ServerConfig   `config:"server"`
    Database DatabaseConfig `config:"database"`
    Cache    CacheConfig    `config:"cache"`
}

// ❌ Avoid: Flat structure
type Config struct {
    ServerHost string
    ServerPort int
    DBHost     string
    DBPort     int
    CacheHost  string
    CachePort  int
}

3. Environment-Specific Configs

// Development
loader.Load(&cfg, ".env.development", "config.yaml")

// Production
loader.Load(&cfg, ".env.production", "config.yaml")

// Use ENV var to select
env := os.Getenv("APP_ENV")
loader.Load(&cfg, fmt.Sprintf(".env.%s", env), "config.yaml")

4. Graceful Shutdown with Hot Reload

func main() {
    var cfg Config

    loader := configmate.NewLoader(
        configmate.WithWatch(true),
        configmate.WithOnReload(func(newCfg interface{}) {
            // Gracefully update running services
            updateServer(newCfg.(*Config))
        }),
    )

    loader.Load(&cfg, "config.yaml")
    defer loader.Close() // Clean up watcher

    // Run app...
}

5. Validate Critical Fields

loader := configmate.NewLoader(
    configmate.WithValidator(func(cfg interface{}) error {
        c := cfg.(*Config)

        // Validate port range
        if c.Port < 1024 || c.Port > 65535 {
            return errors.New("invalid port range")
        }

        // Validate database connection
        if !strings.HasPrefix(c.Database.URL, "postgresql://") {
            return errors.New("invalid database URL")
        }

        return nil
    }),
)

πŸ› Troubleshooting

Config File Not Found

// Check file exists
if _, err := os.Stat("config.yaml"); os.IsNotExist(err) {
    log.Fatal("config.yaml not found")
}

// Or use FailOnMissingFile
loader := configmate.NewLoader(
    configmate.WithFailOnMissingFile(true),
)

Required Field Missing

// Error: field 'APIKey': required field is missing or empty

// Solution: Check struct tag
type Config struct {
    APIKey string `config:"api_key,required"` // Correct
}

// Ensure value in config file
// config.yaml:
// api_key: "your-key-here"

Environment Variables Not Working

// 1. Check EnvOverride is enabled (default: true)
loader := configmate.NewLoader(
    configmate.WithEnvOverride(true),
)

// 2. Check variable name matches
// Struct: Port int `config:"port"`
// ENV: PORT=8080 (or APP_PORT=8080 with prefix)

// 3. Set environment variable
os.Setenv("PORT", "8080")

Nested Struct Not Populated

# βœ… Correct YAML indentation (2 spaces)
database:
  host: "localhost"
  port: 5432

# ❌ Wrong (tabs or wrong indentation)
database:
	host: "localhost"  # Tab character
   port: 5432          # 3 spaces

πŸ“„ Examples

See cmd/example/ for a complete working example.

Run Example

cd cmd/example
go run main.go

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

πŸ“ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Acknowledgments

  • Inspired by viper and envconfig
  • Built with ❀️ for the Go community

πŸ“§ Contact

For questions or support, please open an issue on GitHub.


Happy Configuring! πŸš€

About

A lightweight, type-safe, multi-source configuration loader for Go (supports .env, JSON, YAML) with defaults, validation, and overrides.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

No packages published