La gestión de la configuración en Go suele derivar en dos extremos: el uso de librerías “todo en uno” como viper que cargan todo en un mapa global de interface{}, o la lectura manual y desordenada de os.Getenv por toda la aplicación. El enfoque profesional consiste en transformar la configuración en un objeto de dominio tipado mediante un struct de Go en el momento de la inicialización. Este proceso consiste en leer múltiples fuentes (flags, archivos, variables de entorno), volcarlas en un único struct con tipos primitivos y validarlas antes de que el primer componente de la aplicación arranque.
Este patrón funciona así porque desacopla la fuente de la configuración de su uso en la lógica de negocio. Al mapear los datos a un struct, aprovechas el sistema de tipos de Go, evitando el overhead de reflexión constante y las conversiones de tipos en tiempo de ejecución que librerías como viper imponen al usar interface{}. Implementar un método Validate() que se ejecute inmediatamente después de la carga permite aplicar el principio fail-fast: si la configuración es inválida, la aplicación muere en el arranque con un error claro, en lugar de fallar de forma errática horas después cuando un componente intente usar una variable de entorno vacía.
Debes usar este patrón siempre que construyas servicios que necesiten ser testeados de forma aislada y robusta. En aplicaciones simples de CLI, un mapeo directo de envconfig o cleanenv es suficiente, pero en sistemas complejos, la inyección de la configuración como una dependencia explícita es innegociable. Si no lo haces, lo que rompes es la capacidad de ejecutar tests en paralelo (t.Parallel()) y la previsibilidad de tu sistema, ya que cualquier parte del código podría modificar el estado global de la configuración, provocando condiciones de carrera (race conditions) difíciles de depurar.
package main
import (
"errors"
"fmt"
"log"
"os"
"strconv"
)
// Config define el esquema de configuración de la aplicación.
// Usar un struct tipado permite que el resto del sistema sea agnóstico
// a la fuente (YAML, Env, Flags).
type Config struct {
Port int
DBURL string
LogLevel string
}
// Validate asegura que la configuración sea semánticamente correcta.
// Esto implementa la estrategia fail-fast.
func (c *Config) Validate() error {
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("puerto inválido: %d", c.Port)
}
if c.DBURL == "" {
return errors.New("DBURL es obligatoria")
}
if c.LogLevel == "" {
c.LogLevel = "info" // Valor por defecto
}
return nil
}
// LoadConfig centraliza la lógica de carga. En un escenario real,
// aquí podrías usar una librería como koanf para mezclar archivos y envs.
func LoadConfig() (*Config, error) {
// Simulamos la carga desde variables de entorno
portStr := os.Getenv("APP_PORT")
if portStr == "" {
portStr = "8080" // Default
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("error parseando APP_PORT: %w", err)
}
cfg := &Config{
Port: port,
DBURL: os.Getenv("DATABASE_URL"),
LogLevel: os.Getenv("LOG_LEVEL"),
}
// Validamos inmediatamente tras la carga
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Server representa un componente de la aplicación.
// La configuración se inyecta como una dependencia, no se lee de un global.
type Server struct {
cfg *Config
}
func NewServer(cfg *Config) *Server {
return &Server{cfg: cfg}
}
func (s *Server) Run() error {
fmt.Printf("Iniciando servidor en puerto %d con DB: %s [%s]\n",
s.cfg.Port, s.cfg.DBURL, s.cfg.LogLevel)
return nil
}
func main() {
// Configuramos el entorno para el ejemplo
os.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
os.Setenv("APP_PORT", "9000")
// 1. Carga y validación al inicio
cfg, err := LoadConfig()
if err != nil {
log.Fatalf("Error crítico al cargar configuración: %v", err)
}
// 2. Inyección de dependencias
srv := NewServer(cfg)
// 3. Ejecución
if err := srv.Run(); err != nil {
log.Fatalf("Error en el servidor: %v", err)
}
}
Análisis del diseño
En el ejemplo, LoadConfig actúa como la frontera entre el mundo externo (variables de entorno) y el mundo tipado de Go. Fíjate cómo strconv.Atoi convierte APP_PORT a un int de forma inmediata; esto evita que, más adelante, el Server tenga que lidiar con strings o errores de conversión.
El método Validate sobre la estructura Config es la pieza clave para la estabilidad. Si DATABASE_URL no está definida, LoadConfig devuelve un error y el programa termina en main con log.Fatalf. Esto evita el escenario catastrófico de arrancar el servidor y que falle minutos después cuando el primer request intente conectar a una base de datos con un string vacío.
Finalmente, la estructura Server no conoce la existencia de os.Getenv ni de LoadConfig. Solo conoce su dependencia *Config. Esto permite que en un test unitario, puedas instanciar un Server pasando manualmente un &Config{Port: 8080, DBURL: "test_db"} sin necesidad de manipular variables de entorno globales, permitiendo que los tests se ejecuten de forma segura y en paralelo.
El error frecuente
El error más común es utilizar una variable global para la configuración, algo como var AppConfig *Config. Esto es una trampa en sistemas distribuidos o aplicaciones con alta concurrencia.
// MAL: Variable global que rompe los tests
var GlobalConfig *Config
func TestComponent(t *testing.T) {
t.Parallel() // Error: Los tests en paralelo fallarán si modifican el global
GlobalConfig = &Config{Port: 1234}
// ...
}
func TestOtroComponent(t *testing.T) {
t.Parallel()
GlobalConfig = &Config{Port: 5678} // Esto sobrescribe el valor del TestComponent
// ...
}
Cuando ejecutas go test -parallel, el scheduler de Go distribuirá los tests en diferentes goroutines. Si ambos tests modifican GlobalConfig, tendrás un race condition que hará que tus tests fallen de forma intermitente, sin que el código de tu lógica de negocio tenga un error real. La inyección de dependencias elimina este riesgo por completo.
N° 208