La gestión de la configuración en una aplicación profesional no se trata de leer un archivo y ya; se trata de establecer una jerarquía de precedencia clara. El estándar de la industria (Twelve-Factor App) sugiere que la configuración debe residir en el entorno, pero la realidad de sistemas complejos, entornos de desarrollo y pipelines de CI/CD exige un modelo híbrido.
Una estrategia robusta utiliza una estructura de datos tipada como única fuente de verdad, alimentada por cuatro capas en este orden de importancia: flags de línea de comando (lo más específico, para overrides manuales), variables de entorno (para contenedores y orquestadores como Kubernetes), archivos de configuración (para estructuras complejas como JSON o YAML) y, finalmente, valores por defecto (para que la aplicación arranque sin configuración mínima).
Implementar esto de forma dispersa, usando os.Getenv directamente en la lógica de negocio, es un antipatrón que rompe la testabilidad y oculta errores de tipado hasta que la aplicación ya está corriendo. El objetivo es que el sistema falle inmediatamente al arrancar (fail-fast) si una variable crítica no es válida o falta, y que el resto del código solo vea un objeto Config con valores ya validados y tipados.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
)
// Config es la única fuente de verdad para la aplicación.
// Al estar tipada, evitamos errores de "stringly-typed" en el resto del código.
type Config struct {
Port int `json:"port"`
DBAddr string `json:"db_addr"`
Debug bool `json:"debug"`
Timeout int `json:"timeout_seconds"`
}
// LoadConfig orquestra la jerarquía de configuración.
// Precedencia: Flags > Env Vars > Config File > Defaults.
func LoadConfig() (*Config, error) {
// 1. Definir valores por defecto
cfg := &Config{
Port: 8080,
DBAddr: "localhost:5432",
Debug: false,
Timeout: 30,
}
// Para que los flags tengan la máxima precedencia,
// primero debemos procesar los archivos y env vars
// y usarlos como "defaults" para los flags.
// 2. Cargar desde un archivo (si se proporciona la ruta vía flag)
// Usamos una variable temporal para capturar el path del archivo antes de parsear flags
var configPath string
// Definimos un flag temporal para no interferir con el flujo principal de parsing
flag.StringVar(&configPath, "config", "", "Ruta al archivo de configuración JSON")
// Parseamos primero para obtener la ruta del archivo si existe
// Nota: En una CLI real, podrías requerir un orden más complejo,
// pero para servicios esto es suficiente.
flag.Parse()
if configPath != "" {
if err := loadFile(configPath, cfg); err != nil {
return nil, fmt.Errorf("error cargando archivo de configuración: %w", err)
}
}
// 3. Sobrescribir con Variables de Entorno
if val := os.Getenv("APP_PORT"); val != "" {
if p, err := strconv.Atoi(val); err == nil {
cfg.Port = p
}
}
if val := os.Getenv("DB_ADDR"); val != "" {
cfg.DBAddr = val
}
if val := os.Getenv("APP_DEBUG"); val != "" {
cfg.Debug, _ = strconv.ParseBool(val)
}
// 4. Sobrescribir con Flags (Precedencia máxima)
// Redefinimos los flags usando los valores actuales de cfg como base
// para que flag.Parse() solo cambie lo que el usuario realmente escribió.
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
flag.IntVar(&cfg.Port, "port", cfg.Port, "Puerto de escucha")
flag.StringVar(&cfg.DBAddr, "db", cfg.DBAddr, "Dirección de la base de datos")
flag.BoolVar(&cfg.Debug, "debug", cfg.Debug, "Modo debug activado")
flag.Parse()
return cfg, nil
}
func loadFile(path string, cfg *Config) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
// Unmarshal sobre la estructura existente aplica el "override"
// sobre los valores por defecto.
return json.Unmarshal(data, cfg)
}
func main() {
// Simulamos un entorno de ejecución donde:
// 1. El archivo tiene un puerto 9000
// 2. La Env Var tiene un puerto 7000 (debe ganar al archivo)
// 3. El Flag tiene un puerto 5000 (debe ganar a todo)
// Simulamos la existencia de un archivo de config
dummyConfig := `{"port": 9000, "db_addr": "prod-db:5432"}`
os.WriteFile("config.json", []byte(dummyConfig), 0644)
defer os.Remove("config.json")
// Simulamos variables de entorno
os.Setenv("APP_PORT", "7000")
os.Setenv("APP_DEBUG", "true")
// Simulamos ejecución de la aplicación con flags
// En un entorno real, os.Args vendría del shell
os.Args = []string{"cmd", "-config", "config.json", "-port", "5000"}
cfg, err := LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error crítico: %v\n", err)
os.Exit(1)
}
fmt.Printf("Configuración final cargada:\n")
fmt.Printf("- Puerto: %d (Esperado: 5000)\n", cfg.Port)
fmt.Printf("- DB: %s (Esperado: prod-db:5432)\n", cfg.DBAddr)
fmt.Printf("- Debug: %v (Esperado: true)\n", cfg.Debug)
}
Desglose de la implementación
El flujo de control en LoadConfig es lo que garantiza la jerarquía.
- Inicialización: Empezamos con una instancia de
Configcon valores por defecto. Esto asegura que la aplicación siempre tenga un estado válido. - Carga de archivos: Usamos
json.Unmarshaldirectamente sobre la instanciacfg. Esto es un truco eficiente: los campos presentes en el JSON sobrescriben los valores por defecto, pero si un campo falta en el JSON, el valor por defecto se mantiene intacto. - Variables de Entorno: Iteramos sobre las variables clave. Fíjate en el uso de
strconv.Atoiystrconv.ParseBool. Nunca asumas que el entorno te dará el tipo correcto; el parsing debe ocurrir en el arranque para detectar errores de formato inmediatamente. - El truco de los Flags: Para que
flag.Parse()respete la jerarquía, no podemos simplemente definir los flags con sus valores por defecto habituales. Primero cargamos todo lo anterior en el structcfgy luego inicializamosflag.IntVar(&cfg.Port, "port", cfg.Port, ...)usandocfg.Port(que ya tiene el valor del archivo o de la variable de entorno) como el valor por defecto para el flag. Así, si el usuario no pasa el flag, se mantiene el valor previo; si lo pasa, el flag gana.
El error frecuente
El error más común en aplicaciones Go que escalan es el uso de os.Getenv disperso por toda la lógica de negocio.
// ANTIPATRÓN: Configuración dispersa
func ConnectDB() {
// Esto es un desastre:
// 1. No sabes qué variables necesita la app sin leer todo el código.
// 2. No puedes testear esto fácilmente sin ensuciar el entorno global.
// 3. Si APP_PORT no es un número, el error ocurre en medio de una operación.
port := os.Getenv("PORT")
db := os.Getenv("DB_URL")
// ...
}
Esto crea dependencias ocultas. Tu función ConnectDB ya no es pura; depende de un estado global externo (os.Environ). Además, os.Setenv no es seguro para hilos (thread-safe), lo que significa que tus tests paralelos podrían fallar aleatoriamente si intentan cambiar la misma variable de entorno. Al centralizar todo en un struct Config inyectado en los constructores de tus servicios, haces que el código sea predecible, testeable y robusto.
N° 207