Multiple Sources

Combine configuration from files, environment variables, and remote sources

The config package supports loading configuration from multiple sources simultaneously. This enables powerful patterns like base configuration with environment-specific overrides.

Source Precedence

When multiple sources are configured, later sources override earlier ones:

cfg := config.MustNew(
    config.WithFile("config.yaml"),      // Base configuration
    config.WithFile("config.prod.yaml"), // Production overrides
    config.WithEnv("MYAPP_"),            // Environment overrides all
)

Priority: Environment variables > config.prod.yaml > config.yaml

Visualization

graph LR
    File1[config.yaml] --> Merge1[Merge]
    File2[config.json] --> Merge2[Merge]
    Merge1 --> Merge2
    Env[Environment] --> Merge3[Merge]
    Merge2 --> Merge3
    Consul[Consul KV] --> Merge4[Merge]
    Merge3 --> Merge4
    Merge4 --> Final[Final Config]

Hierarchical Merging

Sources are merged hierarchically - nested structures are combined intelligently:

config.yaml (base):

server:
  host: localhost
  port: 8080
  timeout: 30s
database:
  host: localhost
  port: 5432

config.prod.yaml (overrides):

server:
  host: 0.0.0.0
database:
  host: db.example.com

Merged result:

server:
  host: 0.0.0.0      # Overridden.
  port: 8080         # From base.
  timeout: 30s       # From base.
database:
  host: db.example.com  # Overridden.
  port: 5432            # From base.

File Sources

Load configuration from local files:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config.json"),
    config.WithFile("config.toml"),
)

Format Auto-Detection

File formats are detected automatically from extensions:

config.WithFile("app.yaml")  // YAML
config.WithFile("app.yml")   // YAML
config.WithFile("app.json")  // JSON
config.WithFile("app.toml")  // TOML

Explicit Format

Use explicit format when the file name doesn’t have an extension:

config.WithFileAs("config.txt", codec.TypeYAML)
config.WithFileAs("data.conf", codec.TypeJSON)

Environment Variable Sources

Load configuration from environment variables:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("MYAPP_"),  // Prefix filter
)

Environment variables override file-based configuration. See Environment Variables for detailed naming conventions.

Content Sources

Load configuration from byte slices. This is useful for testing or when you have dynamic configuration:

configData := []byte(`{
    "server": {
        "port": 8080,
        "host": "localhost"
    }
}`)

cfg := config.MustNew(
    config.WithContent(configData, codec.TypeJSON),
)

Use Cases for Content Sources

Testing:

func TestConfig(t *testing.T) {
    testConfig := []byte(`port: 8080`)
    cfg := config.MustNew(
        config.WithContent(testConfig, codec.TypeYAML),
    )
    cfg.Load(context.Background())
    
    assert.Equal(t, 8080, cfg.Int("port"))
}

Dynamic Configuration:

// Configuration from HTTP response
resp, _ := http.Get("https://config-server/config.json")
configBytes, _ := io.ReadAll(resp.Body)

cfg := config.MustNew(
    config.WithContent(configBytes, codec.TypeJSON),
)

Remote Sources

Consul

Load configuration from HashiCorp Consul:

cfg := config.MustNew(
    config.WithConsul("production/service.yaml"),
)

Environment variables:

export CONSUL_HTTP_ADDR=consul.example.com:8500
export CONSUL_HTTP_TOKEN=secret-token

Loading from Consul:

cfg := config.MustNew(
    config.WithFile("config.yaml"),           // Local defaults
    config.WithConsul("staging/myapp.json"),  // Staging overrides
    config.WithEnv("MYAPP_"),                 // Environment overrides
)

The format is detected from the key path extension (.json, .yaml, .toml).

Custom Sources

Implement custom sources for any data source:

type Source interface {
    Load(ctx context.Context) (map[string]any, error)
}

Example: Database Source

type DatabaseSource struct {
    db *sql.DB
}

func (s *DatabaseSource) Load(ctx context.Context) (map[string]any, error) {
    rows, err := s.db.QueryContext(ctx, "SELECT key, value FROM config")
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    config := make(map[string]any)
    for rows.Next() {
        var key, value string
        if err := rows.Scan(&key, &value); err != nil {
            return nil, err
        }
        config[key] = value
    }
    
    return config, nil
}

// Usage
cfg := config.MustNew(
    config.WithSource(&DatabaseSource{db: db}),
)

Example: HTTP Source

type HTTPSource struct {
    url   string
    codec codec.Codec
}

func (s *HTTPSource) Load(ctx context.Context) (map[string]any, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", s.url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    
    var config map[string]any
    if err := s.codec.Decode(data, &config); err != nil {
        return nil, err
    }
    
    return config, nil
}

// Usage
cfg := config.MustNew(
    config.WithSource(&HTTPSource{
        url:   "https://config-server/config.json",
        codec: codec.JSON{},
    }),
)

Multi-Environment Pattern

There are two ways to handle environment-specific configuration.

The simplest approach is to use environment variables directly in paths:

cfg := config.MustNew(
    config.WithFile("config.yaml"),              // Base config
    config.WithFile("${APP_ENV}/config.yaml"),   // Environment-specific (e.g., "production/config.yaml")
    config.WithEnv("MYAPP_"),                    // Environment variables
)

This is cleaner and works great when your config files are in environment-named folders.

Using String Concatenation

If you need more control or want to set a default, use Go code:

package main

import (
    "context"
    "log"
    "os"
    "rivaas.dev/config"
)

func loadConfig() *config.Config {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),              // Base config
        config.WithFile("config."+env+".yaml"),      // Environment-specific
        config.WithEnv("MYAPP_"),                    // Environment variables
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
    
    return cfg
}

func main() {
    cfg := loadConfig()
    // Use configuration
}

File structure:

config.yaml           # Base configuration
config.development.yaml
config.staging.yaml
config.production.yaml

Dumping Configuration

Save the effective merged configuration to a file:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config.prod.yaml"),
    config.WithEnv("MYAPP_"),
    config.WithFileDumper("effective-config.yaml"),
)

cfg.Load(context.Background())
cfg.Dump(context.Background())  // Writes merged config to effective-config.yaml

Use Cases for Dumping

Debugging:

See the final merged configuration:

config.WithFileDumper("debug-config.json")

Configuration Snapshots:

Save configuration state for auditing:

timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("config-snapshot-%s.yaml", timestamp)
config.WithFileDumper(filename)

Configuration Templates:

Generate configuration files:

cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
    config.WithFileDumper("generated-config.yaml"),
)

Custom File Permissions

Control file permissions when dumping:

import "rivaas.dev/config/dumper"

// Default permissions (0644)
fileDumper := dumper.NewFile("config.yaml", encoder)

// Custom permissions (0600 - owner read/write only)
fileDumper := dumper.NewFileWithPermissions("config.yaml", encoder, 0600)

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithDumper(fileDumper),
)

Custom Dumpers

Implement custom dumpers for any destination:

type Dumper interface {
    Dump(ctx context.Context, data map[string]any) error
}

Example: S3 Dumper

type S3Dumper struct {
    bucket string
    key    string
    client *s3.Client
    codec  codec.Codec
}

func (d *S3Dumper) Dump(ctx context.Context, data map[string]any) error {
    bytes, err := d.codec.Encode(data)
    if err != nil {
        return err
    }
    
    _, err = d.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(d.bucket),
        Key:    aws.String(d.key),
        Body:   bytes.NewReader(bytes),
    })
    return err
}

// Usage
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithDumper(&S3Dumper{
        bucket: "my-configs",
        key:    "app-config.json",
        client: s3Client,
        codec:  codec.JSON{},
    }),
)

Error Handling

Errors from sources include context about which source failed:

if err := cfg.Load(context.Background()); err != nil {
    // Error format:
    // "config error in source[0] during load: file not found: config.yaml"
    // "config error in source[2] during load: consul key not found"
    log.Printf("Configuration error: %v", err)
}

Complete Example

package main

import (
    "context"
    "log"
    "os"
    "rivaas.dev/config"
    "rivaas.dev/config/codec"
)

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"server"`
    Database struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"database"`
}

func main() {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    var appConfig AppConfig
    
    cfg := config.MustNew(
        // Base configuration
        config.WithFile("config.yaml"),
        
        // Environment-specific overrides
        config.WithFile("config."+env+".yaml"),
        
        // Remote configuration (production only)
        func() config.Option {
            if env == "production" {
                return config.WithConsul("production/myapp.json")
            }
            return nil
        }(),
        
        // Environment variables (highest priority)
        config.WithEnv("MYAPP_"),
        
        // Struct binding
        config.WithBinding(&appConfig),
        
        // Dump effective config for debugging
        config.WithFileDumper("effective-config.yaml"),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load configuration: %v", err)
    }
    
    // Save effective configuration
    if err := cfg.Dump(context.Background()); err != nil {
        log.Printf("warning: failed to dump config: %v", err)
    }

    log.Printf("Server: %s:%d", appConfig.Server.Host, appConfig.Server.Port)
    log.Printf("Database: %s:%d", appConfig.Database.Host, appConfig.Database.Port)
}

Next Steps

For complete API details, see Options Reference.