Gathuk is a type-safe, flexible configuration management library for Go that converts configuration files into strongly-typed structs. It supports multiple file formats (currently .env .json), nested structures, and automatic environment variable binding.
- 🎯 Type-Safe: Uses Go generics for compile-time type safety
- 📁 Multiple File Formats: Support for
.env,.json(YAML, TOML coming soon) - 🔄 Multiple File Loading: Load and merge configurations from multiple files
- 🔄 Automatic Environment Variables: Automatically bind OS environment variables to struct fields
- 🏗️ Nested Structures: Full support for nested struct configurations with custom prefixes
- 🔧 Flexible Options: Configure priority between file configs and environment variables
- 💾 Write Support: Export configurations back to files
- 🎨 Custom Codecs: Extensible codec system for adding new file formats
- 🚀 Zero Dependencies: Minimal external dependencies
- ⚡ High Performance: Optimized for speed with efficient parsing
go get github.com/ahyalfan/gathukRequirements:
- Go 1.21 or higher (for generics support)
Verify installation:
go list -m github.com/ahyalfan/gathukpackage main
import (
"fmt"
"log"
"github.com/ahyalfan/gathuk"
)
type Config struct {
Port int
Host string
}
func main() {
// Create a new Gathuk instance
gt := gathuk.NewGathuk[Config]()
// Load configuration from file
if err := gt.LoadConfigFiles(".env"); err != nil {
log.Fatal(err)
}
// Get the parsed configuration
config := gt.GetConfig()
fmt.Printf("Server: %s:%d\n", config.Host, config.Port)
}.env file:
PORT=8080
HOST=localhosttype Config struct {
Port int `config:"port"`
Host string `config:"host"`
}
func main() {
gt := gathuk.NewGathuk[Config]()
if err := gt.LoadConfigFiles("config.json"); err != nil {
log.Fatal(err)
}
config := gt.GetConfig()
fmt.Printf("Server: %s:%d\n", config.Host, config.Port)
}config.json file:
{
"port": 8080,
"host": "localhost"
}Gathuk uses Go generics to provide type-safe configuration loading:
// Concrete struct type (RECOMMENDED)
gt := gathuk.NewGathuk[Config]()
// Generic any type (LIMITED - see warnings)
gt := gathuk.NewGathuk[any]()
// Map type (LIMITED - see warnings)
gt := gathuk.NewGathuk[map[string]any]()Always prefer concrete struct types for:
- ✅ Type safety at compile time
- ✅ Proper merging when loading multiple files
- ✅ Better IDE support and autocomplete
- ✅ Self-documenting code
Config Files → Tokenize → Parse → Decode → Struct
↓
Environment Variables
↓
Merge & Apply Options
↓
Final Config Struct
Gathuk automatically converts field names to appropriate conventions:
| Go Field Name | .env Format | JSON Format |
|---|---|---|
Port |
PORT |
port |
ServerPort |
SERVER_PORT |
server_port |
DatabaseURL |
DATABASE_U_R_L |
database_u_r_l |
APIKey |
A_P_I_KEY |
a_p_i_key |
Override with tags:
type Config struct {
APIKey string `config:"api_key"` // → API_KEY or api_key
}| Format | Extension | Status | Tag Convention |
|---|---|---|---|
| Environment Variables | .env |
✅ Stable | UPPER_SNAKE_CASE |
| JSON | .json |
✅ Stable | lower_snake_case |
| YAML | .yaml, .yml |
🚧 Coming Soon | lower_snake_case |
| TOML | .toml |
🚧 Coming Soon | lower_snake_case |
Features:
- Simple key-value pairs:
KEY=value - Comments start with
# - Keys automatically converted to UPPER_SNAKE_CASE
- No quotes needed for string values
- Inline comments supported:
PORT=8080 # server port
Example:
# Server Configuration
SERVER_PORT=8080
SERVER_HOST=localhost
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
# Feature Flags
DEBUG=true
ENABLE_LOGGING=trueSupported Types:
string: Direct textint,int64: Integersfloat64: Floating-point numbersbool:trueorfalse
Features:
- Full JSON specification compliance
- Nested objects and arrays
- Keys use lower_snake_case by default
- Type-safe parsing
- Pretty-print support for writing
Example:
{
"server": {
"port": 8080,
"host": "localhost",
"timeout": 30
},
"database": {
"host": "localhost",
"port": 5432,
"credentials": {
"username": "admin",
"password": "secret"
}
},
"features": {
"debug": true,
"cache_enabled": true
}
}Supported Types:
- All primitive types (string, number, boolean, null)
- Objects (nested structs)
- Arrays (slices)
- Mixed arrays with
[]interface{}
Gathuk supports two main struct tags for customization:
Maps struct fields to specific configuration keys:
type Config struct {
// .env: SERVER_PORT | JSON: server_port
Port int `config:"server_port"`
// .env: API_KEY | JSON: api_key
APIKey string `config:"api_key"`
}Defines prefix for nested structures:
type Config struct {
// All Database fields will have DB_ prefix in .env
// In JSON: nested under "db" object
Database Database `config:"db"`
// or
// Database Database `config:"db"`
}
type Database struct {
Host string // .env: DB_HOST | JSON: db.host
Port int // .env: DB_PORT | JSON: db.port
}Example .env:
DB_HOST=localhost
DB_PORT=5432Example JSON:
{
"db": {
"host": "localhost",
"port": 5432
}
}Use - to exclude fields from configuration:
type Config struct {
Internal string `config:"-"` // Will be ignored
}gt := gathuk.NewGathuk[Config]()
// Enable automatic environment variable binding
gt.globalDecodeOpt.AutomaticEnv = true
// Prefer file values over environment variables
gt.globalDecodeOpt.PreferFileOverEnv = true
// Persist decoded values to OS environment
gt.globalDecodeOpt.PersistToOSEnv = true
err := gt.LoadConfigFiles("config.env")| Option | Description |
|---|---|
AutomaticEnv |
When true, automatically reads from OS environment variables |
PreferFileOverEnv |
When true, prioritizes file config over environment variables (requires AutomaticEnv) |
PersistToOSEnv |
When true, saves decoded values to OS environment variables |
Scenario 1: File Only (Default)
gt := gathuk.NewGathuk[Config]()
// Only reads from config.env
err := gt.LoadConfigFiles("config.env")Scenario 2: Environment Override
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// Environment variables override file values
err := gt.LoadConfigFiles("config.env")Scenario 3: File Override
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
gt.globalDecodeOpt.PreferFileOverEnv = true
// File values override environment variables
err := gt.LoadConfigFiles("config.env")Load and merge configurations from multiple files:
gt := gathuk.NewGathuk[Config]()
// Method 1: Load multiple files at once
err := gt.LoadConfigFiles("base.env", "dev.env", "local.env")
// Method 2: Set base files, then load additional files
gt.SetConfigFiles("base.env", "defaults.env")
err := gt.LoadConfigFiles("override.env")
// Method 3: Mix different formats
err := gt.LoadConfigFiles("base.json", "override.env")Files are processed sequentially:
- First file loaded → Initial config
- Second file loaded → Merged with first
- Third file loaded → Merged with result of 1+2
- Continue...
Merge rules:
- ✅ Non-zero values from later files override earlier files
- ❌ Zero values from later files do NOT override earlier files
- ✅ New fields from later files are added
- ✅ Nested structs merge recursively
// Load base config + environment-specific config
env := os.Getenv("APP_ENV") // "development", "staging", "production"
if env == "" {
env = "development"
}
gt := gathuk.NewGathuk[Config]()
gt.SetConfigFiles("config/base.json")
err := gt.LoadConfigFiles(fmt.Sprintf("config/%s.json", env))IMPORTANT: Zero values are NOT merged to prevent accidental clearing:
// base.env
PORT=8080
HOST=localhost
MAX_CONNECTIONS=100
// override.env
PORT=0 # Zero value - IGNORED
HOST= # Empty string - IGNORED
MAX_CONNECTIONS=50 # Non-zero - USEDgt := gathuk.NewGathuk[Config]()
err := gt.LoadConfigFiles("base.env", "override.env")
config := gt.GetConfig()
// Result:
// Port: 8080 (NOT overridden by 0)
// Host: "localhost" (NOT overridden by "")
// MaxConnections: 50 (overridden by non-zero)Rationale: This prevents accidentally clearing important configuration values with empty or zero values in override files.
func LoadConfig() (*Config, error) {
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
files := []string{
"config/base.env", // Always loaded
fmt.Sprintf("config/%s.env", env), // Environment-specific
}
// Add local overrides if exists
localFile := "config/local.env"
if _, err := os.Stat(localFile); err == nil {
files = append(files, localFile)
}
gt := gathuk.NewGathuk[Config]()
if err := gt.LoadConfigFiles(files...); err != nil {
return nil, err
}
return &config, nil
}Directory structure:
config/
├── base.env # Common settings
├── development.env # Dev overrides
├── staging.env # Staging overrides
├── production.env # Production overrides
└── local.env # Local dev (gitignored)
os.Setenv("PORT", "9000")
os.Setenv("HOST", "0.0.0.0")
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// No files - only environment
err := gt.LoadConfigFiles()
config := gt.GetConfig()
// Port: 9000, Host: "0.0.0.0"# config.env
PORT=8080
HOST=localhostos.Setenv("PORT", "9000") // This will win
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
err := gt.LoadConfigFiles("config.env")
config := gt.GetConfig()
// Port: 9000 (from env), Host: "localhost" (from file)os.Setenv("PORT", "9000") // This will be ignored
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
gt.globalDecodeOpt.PreferFileOverEnv = true
err := gt.LoadConfigFiles("config.env")
config := gt.GetConfig()
// Port: 8080 (from file), Host: "localhost" (from file)# config.env
PORT=8080
HOST=localhostos.Setenv("DEBUG", "true") // Additional env var
os.Setenv("LOG_LEVEL", "info") // Additional env var
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
err := gt.LoadConfigFiles("config.env")
config := gt.GetConfig()
// Port: 8080 (file), Host: "localhost" (file)
// Debug: true (env), LogLevel: "info" (env)Gathuk works seamlessly with containerized deployments:
type Config struct {
Port int `config:"port"`
DatabaseURL string `config:"database_url"`
RedisURL string `config:"redis_url"`
}
func main() {
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// In Docker/K8s, all config comes from environment
// Set via docker-compose.yml, Dockerfile ENV, or K8s ConfigMap
err := gt.LoadConfigFiles()
config := gt.GetConfig()
// Ready to use!
}docker-compose.yml:
services:
app:
environment:
- PORT=8080
- DATABASE_URL=postgres://localhost:5432/db
- REDIS_URL=redis://localhost:6379Export your configuration to files:
config := Config{
Port: 8080,
Host: "localhost",
}
// Write to .env file
err := gt.WriteConfigFile("output.env", 0644, config)
// Write to JSON file
err := gt.WriteConfigFile("output.json", 0644, config)
// Write to io.Writer
var buf bytes.Buffer
err := gt.WriteConfig(&buf, "json", config)
fmt.Println(buf.String())Create and register custom codecs for different file formats:
// Create custom codec
type JSONCodec[T any] struct {
option.DefaultCodec[T]
}
func (c *JSONCodec[T]) Decode(buf []byte,val *T) error {
err := json.Unmarshal(buf, val)
return err
}
func (c *JSONCodec[T]) Encode(val T) ([]byte, error) {
return json.Marshal(val)
}
// Register codec
func main() {
registry := gathuk.NewDefaultCodecRegister[Config]()
registry.RegisterCodec("json", &JSONCodec[Config]{})
gt := gathuk.NewGathuk[Config]()
gt.SetCustomCodecRegistry(registry)
err := gt.LoadConfigFiles("config.json")
}// From file
file, err := os.Open("config.env")
if err != nil {
log.Fatal(err)
}
defer file.Close()
gt := gathuk.NewGathuk[Config]()
err = gt.LoadConfig(file, "env")
// From HTTP response
resp, err := http.Get("https://api.example.com/config")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
err = gt.LoadConfig(resp.Body, "json")
// From string
configStr := `{"port": 8080, "host": "localhost"}`
reader := strings.NewReader(configStr)
err = gt.LoadConfig(reader, "json")gt := gathuk.NewGathuk[Config]()
// Set decode options for specific format
envOpt := &option.DecodeOption{
AutomaticEnv: true,
PreferFileOverEnv: true,
}
gt.SetDecodeOption("env", envOpt)
// JSON doesn't need env options
jsonOpt := &option.DecodeOption{
AutomaticEnv: false,
}
gt.SetDecodeOption("json", jsonOpt)type Config struct {
Port int `config:"port"`
Host string `config:"host"`
LogLevel string `config:"log_level"`
}
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("invalid port: %d", c.Port)
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
validLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if !validLevels[c.LogLevel] {
return fmt.Errorf("invalid log level: %s", c.LogLevel)
}
return nil
}
func main() {
gt := gathuk.NewGathuk[Config]()
err := gt.LoadConfigFiles("config.env")
if err != nil {
log.Fatal(err)
}
config := gt.GetConfig()
if err := config.Validate(); err != nil {
log.Fatal("Config validation failed:", err)
}
}type App struct {
config *Config
gt *gathuk.Gathuk[Config]
}
func (app *App) ReloadConfig() error {
if err := app.gt.LoadConfigFiles("config.env"); err != nil {
return err
}
newConfig := app.gt.GetConfig()
// Validate before applying
if err := newConfig.Validate(); err != nil {
return err
}
// Atomic update
app.config = &newConfig
log.Println("Configuration reloaded successfully")
return nil
}
// Reload on signal
func (app *App) WatchConfig() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP)
for {
<-sigChan
if err := app.ReloadConfig(); err != nil {
log.Printf("Failed to reload config: %v", err)
}
}
}CRITICAL: When using any or map[string]any, multiple file loading does NOT merge:
// ❌ WRONG: Files are NOT merged!
gt := gathuk.NewGathuk[any]()
gt.LoadConfigFiles("base.env", "dev.env")
// Only dev.env values are kept!
// All base.env values are LOST!
// ✅ CORRECT: Use struct type for merging
type Config struct {
Port int
Host string
}
gt := gathuk.NewGathuk[Config]()
gt.LoadConfigFiles("base.env", "dev.env")
// Properly merged!Why?
- Struct types: Gathuk knows which fields to merge
any/map: Gathuk sees generic map, replaces entirely- Each load creates new map, discarding previous
Solutions:
- Use concrete struct types (recommended)
- Load files separately and merge manually
- Load single file at a time
See Multiple Files & Merging Documentation for details.
Zero values from later files do NOT override earlier files:
// base.env
PORT=8080
// override.env
PORT=0 # Will NOT override!
gt.LoadConfigFiles("base.env", "override.env")
// Result: Port = 8080 (not 0)To force zero values:
- Load only the override file
- Use a non-zero sentinel value
- Manually set after loading
type Config struct {
APIKey string // → A_P_I_KEY (not API_KEY)
HTTPURL string // → H_T_T_P_U_R_L (not HTTP_URL)
}
// Use tags for better names
type Config struct {
APIKey string `config:"api_key"` // → API_KEY
HTTPURL string `config:"http_url"` // → HTTP_URL
}// ❌ NOT safe for concurrent access during load
gt := gathuk.NewGathuk[Config]()
go gt.LoadConfigFiles("config1.env") // Unsafe!
go gt.LoadConfigFiles("config2.env") // Unsafe!
// ✅ Safe: Load once, read concurrently
gt.LoadConfigFiles("config.env")
config := gt.GetConfig()
go func() { use(config) }() // Safe
go func() { use(config) }() // Safe// .env file
// PORT=8080
// HOST=localhost
type Config struct {
Port int
Host string
}
gt := gathuk.NewGathuk[Config]()
err := gt.LoadConfigFiles(".env")
// Result: {Port: 8080, Host: "localhost"}// config.json
// {
// "server": {
// "port": 8080,
// "host": "localhost"
// },
// "database": {
// "host": "db.example.com",
// "port": 5432
// }
// }
type Server struct {
Port int `config:"port"`
Host string `config:"host"`
}
type Database struct {
Host string `config:"host"`
Port int `config:"port"`
}
type Config struct {
Server Server `config:"server"`
Database Database `config:"database"`
}// config.env
// USER=file_user
// PORT=8080
type Config struct {
User string
Port int
Editor string
}
// Set environment variables
os.Setenv("USER", "env_user")
os.Setenv("EDITOR", "nvim")
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
err := gt.LoadConfigFiles("config.env")
// Result: {User: "env_user", Port: 8080, Editor: "nvim"}
// USER from env overrides file, EDITOR only in env, PORT from file
// With PreferFileOverEnv
gt.globalDecodeOpt.PreferFileOverEnv = true
err = gt.LoadConfigFiles("config.env")
// Result: {User: "file_user", Port: 8080, Editor: "nvim"}
// USER from file overrides env, EDITOR still from envtype Config struct {
Server ServerConfig `config:"server"`
Database DatabaseConfig `config:"database"`
}
gt := gathuk.NewGathuk[Config]()
// Load base config from JSON, override with .env
err := gt.LoadConfigFiles("config.json", "override.env")// Load based on environment
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
configFiles := []string{
"config/base.json",
fmt.Sprintf("config/%s.json", env),
}
// Add local override if exists
if _, err := os.Stat("config/local.json"); err == nil {
configFiles = append(configFiles, "config/local.json")
}
gt := gathuk.NewGathuk[Config]()
err := gt.LoadConfigFiles(configFiles...)type Config struct {
Server ServerConfig `config:"server"`
Database DatabaseConfig `config:"db"`
Redis RedisConfig `config:"redis"`
Logging LogConfig `config:"log"`
}
type ServerConfig struct {
Port int `config:"port"`
Host string `config:"host"`
ReadTimeout int `config:"read_timeout"`
WriteTimeout int `config:"write_timeout"`
}
type DatabaseConfig struct {
Host string `config:"host"`
Port int `config:"port"`
User string `config:"user"`
Password string `config:"password"`
Database string `config:"name"`
MaxConns int `config:"max_connections"`
}
type RedisConfig struct {
Host string `config:"host"`
Port int `config:"port"`
Password string `config:"password"`
DB int `config:"db"`
}
type LogConfig struct {
Level string `config:"level"`
Format string `config:"format"`
}
func main() {
// Load configuration
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
files := []string{
"config/base.env",
fmt.Sprintf("config/%s.env", env),
}
if err := gt.LoadConfigFiles(files...); err != nil {
log.Fatal("Failed to load config:", err)
}
config := gt.GetConfig()
// Validate configuration
if config.Server.Port < 1 || config.Server.Port > 65535 {
log.Fatal("Invalid server port")
}
// Start server
addr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
log.Printf("Starting server on %s", addr)
// Use configuration
db := connectDatabase(config.Database)
redis := connectRedis(config.Redis)
server := &http.Server{
Addr: addr,
ReadTimeout: time.Duration(config.Server.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(config.Server.WriteTimeout) * time.Second,
}
log.Fatal(server.ListenAndServe())
}config/base.env:
# Server Configuration
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
SERVER_READ_TIMEOUT=30
SERVER_WRITE_TIMEOUT=30
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=app
DB_PASSWORD=secret
DB_NAME=myapp
DB_MAX_CONNECTIONS=25
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Logging Configuration
LOG_LEVEL=info
LOG_FORMAT=jsonconfig/development.env:
# Override for development
SERVER_PORT=3000
DB_MAX_CONNECTIONS=5
LOG_LEVEL=debug
LOG_FORMAT=texttype Config struct {
Service ServiceConfig `config:"service"`
HTTP HTTPConfig `config:"http"`
GRPC GRPCConfig `config:"grpc"`
Observability ObservabilityConfig `config:"obs"`
Dependencies DependenciesConfig `config:"deps"`
}
type ServiceConfig struct {
Name string `config:"name"`
Version string `config:"version"`
Environment string `config:"environment"`
}
type HTTPConfig struct {
Enabled bool `config:"enabled"`
Port int `config:"port"`
Timeout int `config:"timeout"`
}
type GRPCConfig struct {
Enabled bool `config:"enabled"`
Port int `config:"port"`
Timeout int `config:"timeout"`
}
type ObservabilityConfig struct {
Metrics MetricsConfig `config:"metrics"`
Tracing TracingConfig `config:"tracing"`
Logging LoggingConfig `config:"logging"`
}
type MetricsConfig struct {
Enabled bool `config:"enabled"`
Port int `config:"port"`
Path string `config:"path"`
}
type TracingConfig struct {
Enabled bool `config:"enabled"`
Endpoint string `config:"endpoint"`
SampleRate float64 `config:"sample_rate"`
}
type LoggingConfig struct {
Level string `config:"level"`
Format string `config:"format"`
}
type DependenciesConfig struct {
Database DatabaseConfig `config:"db"`
Cache CacheConfig `config:"cache"`
Queue QueueConfig `config:"queue"`
}
type DatabaseConfig struct {
Host string `config:"host"`
Port int `config:"port"`
User string `config:"user"`
Password string `config:"password"`
Database string `config:"name"`
MaxOpenConns int `config:"max_open_conns"`
MaxIdleConns int `config:"max_idle_conns"`
}
type CacheConfig struct {
Host string `config:"host"`
Port int `config:"port"`
Password string `config:"password"`
TTL int `config:"ttl"`
}
type QueueConfig struct {
URL string `config:"url"`
MaxRetries int `config:"max_retries"`
RetryDelay int `config:"retry_delay"`
}
func LoadConfig() (*Config, error) {
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// Load base + environment-specific config
env := os.Getenv("SERVICE_ENVIRONMENT")
if env == "" {
env = "development"
}
files := []string{
"config/base.env",
fmt.Sprintf("config/%s.env", env),
}
// Add secrets file if exists (for local development)
if _, err := os.Stat("config/secrets.env"); err == nil {
files = append(files, "config/secrets.env")
}
if err := gt.LoadConfigFiles(files...); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
config := gt.GetConfig()
// Validate
if err := validateConfig(&config); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
return &config, nil
}
func validateConfig(cfg *Config) error {
if cfg.Service.Name == "" {
return fmt.Errorf("service name is required")
}
if !cfg.HTTP.Enabled && !cfg.GRPC.Enabled {
return fmt.Errorf("at least one protocol (HTTP or GRPC) must be enabled")
}
if cfg.HTTP.Enabled && (cfg.HTTP.Port < 1 || cfg.HTTP.Port > 65535) {
return fmt.Errorf("invalid HTTP port: %d", cfg.HTTP.Port)
}
if cfg.GRPC.Enabled && (cfg.GRPC.Port < 1 || cfg.GRPC.Port > 65535) {
return fmt.Errorf("invalid GRPC port: %d", cfg.GRPC.Port)
}
return nil
}type Config struct {
App AppConfig `config:"app"`
API APIConfig `config:"api"`
Output OutputConfig `config:"output"`
Advanced AdvancedConfig `config:"advanced"`
}
type AppConfig struct {
Name string `config:"name"`
Version string `config:"version"`
Debug bool `config:"debug"`
}
type APIConfig struct {
BaseURL string `config:"base_url"`
Token string `config:"token"`
Timeout int `config:"timeout"`
}
type OutputConfig struct {
Format string `config:"format"` // json, yaml, table
Color bool `config:"color"`
Quiet bool `config:"quiet"`
}
type AdvancedConfig struct {
CacheDir string `config:"cache_dir"`
MaxRetries int `config:"max_retries"`
RetryDelay int `config:"retry_delay"`
}
func main() {
// Parse flags
configFile := flag.String("config", "", "Config file path")
debug := flag.Bool("debug", false, "Enable debug mode")
flag.Parse()
// Load configuration
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
files := []string{}
// Load from default locations
homeDir, _ := os.UserHomeDir()
defaultFiles := []string{
filepath.Join(homeDir, ".myapp", "config.env"),
".myapp.env",
}
for _, f := range defaultFiles {
if _, err := os.Stat(f); err == nil {
files = append(files, f)
}
}
// Load from specified config file
if *configFile != "" {
files = append(files, *configFile)
}
if len(files) > 0 {
if err := gt.LoadConfigFiles(files...); err != nil {
log.Fatal("Failed to load config:", err)
}
}
config := gt.GetConfig()
// Override with flags
if *debug {
config.App.Debug = true
}
// Use configuration
runCLI(config)
}func TestConfigLoading(t *testing.T) {
tests := []struct {
name string
envFile string
envVars map[string]string
want Config
wantErr bool
}{
{
name: "basic config",
envFile: "testdata/basic.env",
want: Config{
Port: 8080,
Host: "localhost",
},
wantErr: false,
},
{
name: "with environment override",
envFile: "testdata/basic.env",
envVars: map[string]string{
"PORT": "9000",
},
want: Config{
Port: 9000,
Host: "localhost",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set environment variables
for k, v := range tt.envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
// Load config
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
err := gt.LoadConfigFiles(tt.envFile)
if (err != nil) != tt.wantErr {
t.Errorf("LoadConfigFiles() error = %v, wantErr %v", err, tt.wantErr)
return
}
got := gt.GetConfig()
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetConfig() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigMerging(t *testing.T) {
// Create temporary files
baseFile := createTempFile(t, `
PORT=8080
HOST=localhost
DEBUG=false
`)
defer os.Remove(baseFile)
devFile := createTempFile(t, `
DEBUG=true
LOG_LEVEL=debug
`)
defer os.Remove(devFile)
// Load and merge
gt := gathuk.NewGathuk[Config]()
err := gt.LoadConfigFiles(baseFile, devFile)
if err != nil {
t.Fatal(err)
}
config := gt.GetConfig()
// Verify merged results
if config.Port != 8080 {
t.Errorf("Port = %d, want 8080", config.Port)
}
if config.Host != "localhost" {
t.Errorf("Host = %s, want localhost", config.Host)
}
if config.Debug != true {
t.Errorf("Debug = %v, want true", config.Debug)
}
if config.LogLevel != "debug" {
t.Errorf("LogLevel = %s, want debug", config.LogLevel)
}
}
func createTempFile(t *testing.T, content string) string {
t.Helper()
file, err := os.CreateTemp("", "config-*.env")
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString(content); err != nil {
t.Fatal(err)
}
file.Close()
return file.Name()
}type ConfigManager struct {
configs map[string]*Config
active string
mu sync.RWMutex
}
func NewConfigManager() *ConfigManager {
return &ConfigManager{
configs: make(map[string]*Config),
active: "default",
}
}
func (cm *ConfigManager) LoadProfile(name, file string) error {
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
if err := gt.LoadConfigFiles(file); err != nil {
return fmt.Errorf("failed to load profile %s: %w", name, err)
}
config := gt.GetConfig()
cm.mu.Lock()
cm.configs[name] = &config
cm.mu.Unlock()
return nil
}
func (cm *ConfigManager) SwitchProfile(name string) error {
cm.mu.Lock()
defer cm.mu.Unlock()
if _, exists := cm.configs[name]; !exists {
return fmt.Errorf("profile %s not found", name)
}
cm.active = name
log.Printf("Switched to profile: %s", name)
return nil
}
func (cm *ConfigManager) GetConfig() Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
return *cm.configs[cm.active]
}
func main() {
manager := NewConfigManager()
// Load multiple profiles
profiles := map[string]string{
"development": "config/dev.env",
"staging": "config/staging.env",
"production": "config/prod.env",
}
for name, file := range profiles {
if err := manager.LoadProfile(name, file); err != nil {
log.Printf("Warning: %v", err)
}
}
// Use active profile
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
if err := manager.SwitchProfile(env); err != nil {
log.Fatal(err)
}
config := manager.GetConfig()
log.Printf("Running with config: %+v", config)
}Gathuk is optimized for performance with efficient parsing and minimal allocations.
goos: linux
goarch: amd64
pkg: github.com/ahyalfan/gathuk
cpu: AMD Ryzen 5 6600H with Radeon Graphics
BenchmarkGathuk/Simple_Load-12 116638 10046 ns/op 3240 B/op 50 allocs/op
BenchmarkGathuk/Nested_Struct-12 113784 10685 ns/op 3432 B/op 62 allocs/op
BenchmarkGathuk/Multiple_Files-12 57288 20465 ns/op 6152 B/op 102 allocs/op
# Run all benchmarks
go test -bench=. -benchmem
# Run specific benchmark
go test -bench=BenchmarkGathuk/Simple -benchmem
# With CPU profiling
go test -bench=. -benchmem -cpuprofile=cpu.prof
# With memory profiling
go test -bench=. -benchmem -memprofile=mem.profWhat this means:
- Simple Load: ~10 microseconds per operation
- Nested Struct: ~11 microseconds per operation
- Multiple Files: ~20 microseconds per operation (loading 2 files)
Memory efficiency:
- Simple config: ~3.2 KB per load
- Nested struct: ~3.4 KB per load
- Multiple files: ~6.1 KB per load
- Reuse Gathuk instances when possible
// ✅ Good: Reuse instance
gt := gathuk.NewGathuk[Config]()
for _, file := range files {
gt.LoadConfigFiles(file)
}
// ❌ Avoid: Creating new instance each time
for _, file := range files {
gt := gathuk.NewGathuk[Config]() // Unnecessary allocation
gt.LoadConfigFiles(file)
}- Load once, use many times
// ✅ Good: Load once at startup
var globalConfig Config
func init() {
gt := gathuk.NewGathuk[Config]()
gt.LoadConfigFiles("config.env")
globalConfig = gt.GetConfig()
}
func handler1() {
// Use globalConfig
}
func handler2() {
// Use globalConfig
}- Use concrete struct types
// ✅ Good: Concrete type (faster)
gt := gathuk.NewGathuk[Config]()
// ❌ Slower: Generic any type
gt := gathuk.NewGathuk[any]()// ✅ DO: Use concrete struct for merging
type Config struct {
Port int
Host string
}
gt := gathuk.NewGathuk[Config]()
gt.LoadConfigFiles("base.env", "dev.env")
// ❌ DON'T: Use any with multiple files
gt := gathuk.NewGathuk[any]()
gt.LoadConfigFiles("base.env", "dev.env") // Only last file kept!config/
├── base.env # Common settings (all environments)
├── development.env # Dev-specific overrides
├── staging.env # Staging-specific overrides
├── production.env # Production-specific overrides
├── secrets.env # Secrets (gitignored, optional)
└── local.env # Local dev overrides (gitignored)
type Config struct {
Port int
Host string
LogLevel string
}
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("invalid port: %d", c.Port)
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
if !validLevels[c.LogLevel] {
return fmt.Errorf("invalid log level: %s", c.LogLevel)
}
return nil
}
func main() {
gt := gathuk.NewGathuk[Config]()
gt.LoadConfigFiles("config.env")
config := gt.GetConfig()
if err := config.Validate(); err != nil {
log.Fatal("Configuration error:", err)
}
}// ✅ Good: Explicit and consistent
type Config struct {
ServerPort int `config:"server_port"`
DBHost string `config:"db_host"`
APIKey string `config:"api_key"`
}
// ❌ Avoid: Mixing conventions
type Config struct {
ServerPort int // Auto-mapped
DBHost string `config:"database_host"` // Custom
APIKey string // Auto-mapped (becomes A_P_I_KEY!)
}type Config struct {
// Server listening port (default: 8080, range: 1-65535)
Port int `config:"port"`
// Server bind address (default: "localhost")
// Use "0.0.0.0" to listen on all interfaces
Host string `config:"host"`
// Maximum number of database connections (default: 100)
MaxConnections int `config:"max_connections"`
// Enable debug logging (default: false)
// WARNING: Debug logging may expose sensitive information
Debug bool `config:"debug"`
}// ❌ Don't: Store secrets in config files
// config.env (committed to git)
DATABASE_PASSWORD=mysecret123
// ✅ Do: Use environment variables for secrets
// config.env (committed to git)
DATABASE_HOST=localhost
DATABASE_PORT=5432
// Set secrets via environment
export DATABASE_PASSWORD=mysecret123
// Or use separate secrets file (gitignored)
// config/secrets.env (gitignored)
DATABASE_PASSWORD=mysecret123type Config struct {
Port int `config:"port"`
Host string `config:"host"`
ReadTimeout int `config:"read_timeout"`
WriteTimeout int `config:"write_timeout"`
}
func LoadConfigWithDefaults() (*Config, error) {
// Set defaults
config := Config{
Port: 8080,
Host: "localhost",
ReadTimeout: 30,
WriteTimeout: 30,
}
// Override with file values
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// LoadConfigFiles will only override non-zero values
if err := gt.LoadConfigFiles("config.env"); err != nil {
// If config file doesn't exist, use defaults
if !os.IsNotExist(err) {
return nil, err
}
} else {
config = gt.GetConfig()
}
return &config, nil
}func TestConfigLoading(t *testing.T) {
// Create test config file
content := `
PORT=8080
HOST=localhost
DEBUG=true
`
tmpfile, err := os.CreateTemp("", "config-*.env")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
// Load config
gt := gathuk.NewGathuk[Config]()
if err := gt.LoadConfigFiles(tmpfile.Name()); err != nil {
t.Fatal(err)
}
config := gt.GetConfig()
// Assert values
if config.Port != 8080 {
t.Errorf("Port = %d, want 8080", config.Port)
}
if config.Host != "localhost" {
t.Errorf("Host = %s, want localhost", config.Host)
}
if config.Debug != true {
t.Errorf("Debug = %v, want true", config.Debug)
}
}func LoadConfig(files ...string) (*Config, error) {
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
// Filter existing files
existingFiles := []string{}
for _, file := range files {
if _, err := os.Stat(file); err == nil {
existingFiles = append(existingFiles, file)
} else {
log.Printf("Config file not found (skipping): %s", file)
}
}
if len(existingFiles) == 0 {
return nil, fmt.Errorf("no config files found")
}
if err := gt.LoadConfigFiles(existingFiles...); err != nil {
return nil, err
}
config := gt.GetConfig()
return &config, nil
}type Config struct {
Features FeatureFlags `config:"feature"`
}
type FeatureFlags struct {
EnableNewUI bool `config:"new_ui"`
EnableBetaAPI bool `config:"beta_api"`
EnableCaching bool `config:"caching"`
}
// Load base config with all features disabled
// Then override based on environment
// base.env
FEATURE_NEW_UI=false
FEATURE_BETA_API=false
FEATURE_CACHING=true
// production.env
FEATURE_CACHING=true
// development.env
FEATURE_NEW_UI=true
FEATURE_BETA_API=true
FEATURE_CACHING=falseBefore (Viper):
import "github.com/spf13/viper"
viper.SetConfigName("config")
viper.SetConfigType("json")
viper.AddConfigPath(".")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
log.Fatal(err)
}
port := viper.GetInt("server.port")
host := viper.GetString("server.host")After (Gathuk):
import "github.com/ahyalfan/gathuk"
type Config struct {
Server struct {
Port int `config:"port"`
Host string `config:"host"`
} `config:"server"`
}
gt := gathuk.NewGathuk[Config]()
gt.globalDecodeOpt.AutomaticEnv = true
if err := gt.LoadConfigFiles("config.json"); err != nil {
log.Fatal(err)
}
config := gt.GetConfig()
port := config.Server.Port
host := config.Server.HostBenefits:
- ✅ Type-safe access (no Get* methods)
- ✅ Compile-time checking
- ✅ Better IDE support
- ✅ No string keys to remember
Before (godotenv):
import "github.com/joho/godotenv"
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
port, _ := strconv.Atoi(os.Getenv("PORT"))
host := os.Getenv("HOST")
debug := os.Getenv("DEBUG") == "true"After (Gathuk):
import "github.com/ahyalfan/gathuk"
type Config struct {
Port int
Host string
Debug bool
}
gt := gathuk.NewGathuk[Config]()
if err := gt.LoadConfigFiles(".env"); err != nil {
log.Fatal(err)
}
config := gt.GetConfig()
port := config.Port // Automatically converted to int
host := config.Host
debug := config.Debug // Automatically converted to boolBenefits:
- ✅ Automatic type conversion
- ✅ No manual parsing
- ✅ Type-safe struct
- ✅ Less boilerplate
Before (encoding/json):
import "encoding/json"
file, err := os.Open("config.json")
if err != nil {
log.Fatal(err)
}
defer file.Close()
var config Config
decoder := json.NewDecoder(file)
if err := decoder.Decode(&config); err != nil {
log.Fatal(err)
}After (Gathuk):
import "github.com/ahyalfan/gathuk"
gt := gathuk.NewGathuk[Config]()
if err := gt.LoadConfigFiles("config.json"); err != nil {
log.Fatal(err)
}
config := gt.GetConfig()Benefits:
- ✅ Environment variable support
- ✅ Multiple file merging
- ✅ Format-agnostic (same code for .env, JSON, etc.)
- ✅ Less boilerplate
Creates a new Gathuk instance with default configuration.
Loads and merges configurations from one or more files.
Loads configuration from an io.Reader with specified format.
Returns the parsed configuration struct.
Writes configuration to a file.
Writes configuration to an io.Writer.
Sets base configuration files without loading them.
Sets a custom codec registry for handling different file formats.
Sets decode options for a specific format.
Sets encode options for a specific format.
For complete API documentation, see GoDoc
Q: Can I use multiple formats simultaneously?
A: Yes! You can load different formats in sequence: gt.LoadConfigFiles("base.json", "override.env")
Q: How do I handle missing configuration files?
A: Check for os.IsNotExist(err) and provide defaults or use fallback files.
Q: Can I reload configuration at runtime?
A: Yes, call LoadConfigFiles() again. Values will be merged with existing configuration.
Q: Does it support configuration validation?
A: Validate after loading using your own validation logic or libraries like go-playground/validator.
Q: How do I set default values?
A: Initialize your struct with defaults before loading: config := Config{Port: 8080}
Q: Can I use with Docker/Kubernetes?
A: Yes! Use AutomaticEnv to read from environment variables set by orchestration tools.
Q: Is it thread-safe?
A: Reading config after loading is thread-safe. Loading config should be done during initialization.
- .env format support
- JSON format support
- Environment variable binding
- Multiple file merging
- Nested structure support
- Write support
- YAML format support
- TOML format support
- Configuration validation
- Hot reload support
- Configuration encryption
- Remote config sources (etcd, consul)
Contributions are welcome! Please feel free to submit a Pull Request.
# Clone the repository
git clone https://github.com/ahyalfan/gathuk.git
cd gathuk
# Run tests
go test ./...
# Run benchmarks
go test -bench=. -benchmem
# Run with coverage
go test -cover ./...
# Generate coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Write tests for your changes
- Ensure all tests pass (
go test ./...) - Run
go fmt ./...to format code - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
If you find this project helpful, please give it a ⭐️!
For issues and questions, please use the GitHub issue tracker.