Go CLI Apps: De 0 a Experto con Arquitectura Hexagonal

Go CLI Apps: De 0 a Experto con Arquitectura Hexagonal

Guía completa para crear CLI apps profesionales en Go. Arquitectura hexagonal, goroutines, concurrencia, Linux y deployment.

Por Omar Flores

Introducción

¿Por qué Go para CLI apps?

Porque Go es el lenguaje predilecto para herramientas de infraestructura. Docker, Kubernetes, Terraform, Prometheus, etcd… todos escritos en Go. ¿Razón? Go compila a un binario único sin dependencias externas.

Imagina: usuario ejecuta curl -L https://github.com/myapp/releases | bash y en 10 segundos tiene tu app funcionando. No necesita Node.js, Python, Java runtime. Solo el binario.

Así comienza este viaje: aprenderás a construir CLI apps profesionales, desde conceptos básicos hasta deployment en producción.


Parte 1: Fundamentos de CLI en Go

¿Qué es una CLI App?

CLI = Command Line Interface. Es una aplicación que se ejecuta desde la terminal, tomando argumentos y flags.

Ejemplos que usas diariamente:

# git - control de versiones
git commit -m "mensaje"

# docker - containerización
docker run -it ubuntu bash

# kubectl - orquestación
kubectl get pods -n production

# terraform - infraestructura
terraform apply -auto-approve

Cada uno es una CLI app.

Go vs Otros Lenguajes

AspectoGoPythonNode.jsRust
Binary único
Sin runtime
Startup time<10ms100ms+200ms+<10ms
Memory5MB50MB+100MB+10MB
Concurrencia✅ Nativa❌ Threads pesados✅ Async
Curva aprendizaje🟢 Media🟢 Baja🟢 Media🔴 Alta

Go gana en: simplicidad + performance + distribución.

Setup Inicial

Crea tu proyecto:

mkdir -p myapp/cmd/myapp
cd myapp

# Inicializa módulo Go
go mod init github.com/username/myapp

# Estructura básica
touch cmd/myapp/main.go

Tu primer programa:

// cmd/myapp/main.go
package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("Uso: myapp [comando]")
		os.Exit(1)
	}

	command := os.Args[1]
	fmt.Printf("Ejecutando: %s\n", command)
}

Ejecuta:

go run cmd/myapp/main.go deploy
# Output: Ejecutando: deploy

Flags: Parsing de Argumentos

Go tiene un paquete built-in flag:

package main

import (
	"flag"
	"fmt"
)

func main() {
	env := flag.String("env", "dev", "Ambiente (dev/staging/prod)")
	verbose := flag.Bool("v", false, "Modo verbose")
	flag.Parse()

	fmt.Printf("Ambiente: %s\n", *env)
	fmt.Printf("Verbose: %v\n", *verbose)
	fmt.Printf("Argumentos: %v\n", flag.Args())
}

Uso:

go run main.go -env production -v server1 server2
# Output:
# Ambiente: production
# Verbose: true
# Argumentos: [server1 server2]

Cobra: El Framework Profesional

El paquete flag es simple pero limitado. Para CLI apps serias, usamos Cobra:

go get github.com/spf13/cobra@latest

Estructura con Cobra:

// cmd/myapp/main.go
package main

import (
	"fmt"
	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "myapp",
	Short: "Mi aplicación awesome",
	Long:  "Una descripción larga...",
}

var deployCmd = &cobra.Command{
	Use:   "deploy [servers...]",
	Short: "Deploy la aplicación",
	Args:  cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		for _, server := range args {
			fmt.Printf("Deployando a: %s\n", server)
		}
		return nil
	},
}

var statusCmd = &cobra.Command{
	Use:   "status",
	Short: "Ver status",
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("Status: OK")
		return nil
	},
}

func init() {
	rootCmd.AddCommand(deployCmd)
	rootCmd.AddCommand(statusCmd)

	deployCmd.Flags().String("environment", "production", "Ambiente")
	deployCmd.Flags().String("version", "latest", "Versión")
}

func main() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Println(err)
	}
}

Uso:

go run cmd/myapp/main.go deploy --environment staging --version 2.0 server1 server2
# Output: Deployando a: server1
#         Deployando a: server2

Cobra features:

  • Subcommands automáticos
  • Help text automático (myapp --help, myapp deploy --help)
  • Bash/Zsh completion auto-generada
  • Validación de argumentos

Parte 2: Flags y Argumentos Avanzados

El Problema

Imagina tu CLI app que necesita:

# 1. Usar secrets desde env vars (no hardcodear)
myapp deploy --api-key $API_KEY

# 2. Configuración desde archivos
myapp deploy --config config.yaml

# 3. Múltiples valores
myapp notify --to user1@example.com --to user2@example.com

# 4. Valores complejos
myapp configure --json '{"port": 8080, "debug": true}'

Con flag básico, todo esto es tedioso.

Cobra Avanzado

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/spf13/cobra"
)

var deployCmd = &cobra.Command{
	Use:   "deploy [services...]",
	Short: "Deploy a múltiples servicios",
	Long: `Despliega tu aplicación a los servidores especificados.

Ejemplo:
  myapp deploy api web db --environment production --version 2.0`,

	Args:  cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		// Leer flags
		env, _ := cmd.Flags().GetString("environment")
		version, _ := cmd.Flags().GetString("version")
		regions, _ := cmd.Flags().GetStringSlice("region")
		dryRun, _ := cmd.Flags().GetBool("dry-run")
		configJSON, _ := cmd.Flags().GetString("config")

		// Parsear config JSON
		var config map[string]interface{}
		if configJSON != "" {
			if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
				return fmt.Errorf("config JSON inválido: %w", err)
			}
		}

		// Imprimir lo que haremos
		fmt.Printf("Desplegando: %s\n", strings.Join(args, ", "))
		fmt.Printf("Ambiente: %s, Versión: %s\n", env, version)
		fmt.Printf("Regiones: %s\n", strings.Join(regions, ", "))

		if dryRun {
			fmt.Println("[DRY RUN] No se realizaron cambios")
			return nil
		}

		if config != nil {
			fmt.Printf("Configuración: %+v\n", config)
		}

		fmt.Println("✓ Deploy completado")
		return nil
	},
}

func init() {
	// String flag
	deployCmd.Flags().String("environment", "dev", "Ambiente (dev/staging/prod)")
	deployCmd.Flags().String("version", "latest", "Versión a desplegar")

	// Slice flag (múltiples valores)
	deployCmd.Flags().StringSlice("region", []string{"us-east"}, "Regiones para deploy")

	// Boolean flag
	deployCmd.Flags().Bool("dry-run", false, "Simular sin realizar cambios")

	// String complejo
	deployCmd.Flags().String("config", "", "Configuración en JSON")

	// Marcar como requerido
	deployCmd.MarkFlagRequired("version")
}

func main() {
	if err := deployCmd.Execute(); err != nil {
		log.Fatal(err)
	}
}

Uso:

go run main.go deploy api web \
  --environment production \
  --version 2.0 \
  --region us-east \
  --region eu-west \
  --config '{"timeout": 30, "retries": 3}'

# Output:
# Desplegando: api, web
# Ambiente: production, Versión: 2.0
# Regiones: us-east, eu-west
# Configuración: map[retries:3 timeout:30]
# ✓ Deploy completado

Parte 3: Arquitectura Hexagonal en CLI Apps

El Problema Que Resuelve

Imagina este código acoplado:

// ❌ TODO MEZCLADO
var deployCmd = &cobra.Command{
	RunE: func(cmd *cobra.Command, args []string) error {
		// Conectar a SSH
		conn, err := ssh.Dial("tcp", "prod-1.example.com:22", &ssh.ClientConfig{...})
		if err != nil { return err }
		defer conn.Close()

		// Ejecutar comandos
		session, _ := conn.NewSession()
		output, _ := session.CombinedOutput("./deploy.sh")

		// Procesar resultado
		if strings.Contains(string(output), "ERROR") {
			// Log en archivo
			f, _ := os.Create("/var/log/deploy.log")
			f.WriteString("Deploy falló\n")
			f.Close()
		}

		// Enviar notificación por email
		smtp.SendMail("smtp.example.com:25", nil, "admin@example.com", ...

		fmt.Println("Deploy completado")
		return nil
	},
}

Problemas:

  1. ❌ Imposible testear (necesita SSH real, SMTP real)
  2. ❌ Acoplado a SSH (¿qué si mañana usas Kubernetes?)
  3. ❌ Acoplado a email (¿qué si necesitas Slack?)
  4. ❌ Lógica de negocio mezclada con detalles técnicos

La Solución: Hexagonal

Hexagonal (Ports & Adapters) separa la lógica de negocio de los detalles técnicos:

┌─────────────────────────────────────────┐
│          LÓGICA DE NEGOCIO (DOMAIN)      │
│  - DeployService                         │
│  - ValidationService                     │
│  - NotificationService                   │
└─────────────────────────────────────────┘
         △                          △
         │ PORTS (Interfaces)      │
         │                          │
┌────────┴─────────┐    ┌──────────┴────────┐
│   CLI ADAPTER     │    │ SSH ADAPTER       │
│   (Cobra)         │    │ (ssh package)     │
└───────────────────┘    └───────────────────┘

Idea: Invertir dependencias.

  1. Define interfaces (puertos) para lo que necesitas
  2. Tu lógica depende de interfaces, NO de implementaciones concretas
  3. Los adaptadores implementan esas interfaces

Implementación

Paso 1: Define el dominio

// domain/deploy.go
package domain

type DeployService struct {
	connector ServerConnector
	notifier  Notifier
	logger    Logger
}

// PUERTO 1: Cómo conectarse a servidores
type ServerConnector interface {
	ConnectAndDeploy(server string, version string) (string, error)
}

// PUERTO 2: Cómo notificar resultados
type Notifier interface {
	Notify(message string) error
}

// PUERTO 3: Cómo loguear
type Logger interface {
	Info(msg string)
	Error(msg string, err error)
}

// Constructor: inyectar dependencias
func NewDeployService(
	connector ServerConnector,
	notifier Notifier,
	logger Logger,
) *DeployService {
	return &DeployService{
		connector: connector,
		notifier:  notifier,
		logger:    logger,
	}
}

// LÓGICA DE NEGOCIO (pura, sin detalles técnicos)
func (s *DeployService) Deploy(servers []string, version string) error {
	s.logger.Info("Iniciando deploy")

	for _, server := range servers {
		s.logger.Info("Desplegando a " + server)

		result, err := s.connector.ConnectAndDeploy(server, version)
		if err != nil {
			s.logger.Error("Deploy falló", err)
			s.notifier.Notify("Deploy falló en " + server)
			return err
		}

		s.logger.Info("Deploy exitoso: " + result)
	}

	s.notifier.Notify("Deploy completado en todos los servidores")
	return nil
}

Paso 2: Crea adaptadores (implementaciones concretas)

// adapters/ssh_connector.go
package adapters

import (
	"golang.org/x/crypto/ssh"
	"github.com/myapp/domain"
)

type SSHConnector struct {
	config *ssh.ClientConfig
}

func (c *SSHConnector) ConnectAndDeploy(server string, version string) (string, error) {
	conn, err := ssh.Dial("tcp", server+":22", c.config)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	session, _ := conn.NewSession()
	defer session.Close()

	// Ejecutar script de deploy
	output, err := session.CombinedOutput("./deploy.sh " + version)
	return string(output), err
}

// adapters/slack_notifier.go
package adapters

import "net/http"

type SlackNotifier struct {
	webhookURL string
}

func (n *SlackNotifier) Notify(message string) error {
	payload := map[string]string{"text": message}
	// Enviar a Slack
	_, err := http.Post(n.webhookURL, "application/json", ...)
	return err
}

// adapters/log_logger.go
package adapters

import "log"

type LogLogger struct{}

func (l *LogLogger) Info(msg string) {
	log.Println("[INFO]", msg)
}

func (l *LogLogger) Error(msg string, err error) {
	log.Println("[ERROR]", msg, err)
}

Paso 3: Integra en CLI con Cobra

// adapters/cli.go
package adapters

import (
	"fmt"
	"github.com/spf13/cobra"
	"github.com/myapp/domain"
)

var deployCmd = &cobra.Command{
	Use:   "deploy [servers...]",
	Short: "Deploy a múltiples servidores",
	Args:  cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		version, _ := cmd.Flags().GetString("version")

		// Crear adaptadores
		connector := NewSSHConnector(...)
		notifier := NewSlackNotifier(webhookURL)
		logger := NewLogLogger()

		// Crear servicio de dominio (inyectar adaptadores)
		service := domain.NewDeployService(connector, notifier, logger)

		// Ejecutar lógica de negocio
		return service.Deploy(args, version)
	},
}

func init() {
	deployCmd.Flags().String("environment", "production", "Ambiente")
	deployCmd.Flags().String("version", "latest", "Versión")
}

¿Por Qué Todo Esto Importa?

Mira lo que logramos:

  1. Testeable: Puedes testear DeployService sin Docker, sin CLI, con mocks simples
  2. Flexible: Cambiar de Docker a Kubernetes es solo crear un nuevo adaptador
  3. Mantenible: La lógica de negocio está clara y separada
  4. Escalable: Agregar nuevas formas de usar la app (HTTP API, gRPC, scripts) es fácil

Esto es lo que diferencia a aplicaciones profesionales de scripts casuales.


Parte 4: Goroutines, Context y Concurrencia

El Problema: Las Cosas Toman Tiempo

Imagina que tu CLI app necesita:

  1. Hacer 100 requests HTTP a 100 servidores
  2. Procesar archivos grandes mientras monitorea el sistema
  3. Ejecutar múltiples tareas y permitir cancelación por Ctrl+C

Si haces esto secuencialmente, tu usuario esperaría horas. Aquí es donde Go brilla.

Go fue diseñado por Google para programar sistemas concurrentes con facilidad. Las goroutines son threads ultra-ligeros que permiten ejecutar miles simultáneamente.

Goroutines: Concurrencia para Mortales

Una goroutine es como un “thread” pero muchísimo más ligero:

  • Un thread del SO pesa ~2MB, una goroutine pesa ~2KB
  • Puedes tener 1000 threads, pero fácilmente 100,000 goroutines
  • Go maneja el scheduling automáticamente

Crear una goroutine es trivial:

package main

import (
	"fmt"
	"time"
)

func worker(id int, task string) {
	fmt.Printf("Worker %d: iniciando %s\n", id, task)
	time.Sleep(2 * time.Second)
	fmt.Printf("Worker %d: completó %s\n", id, task)
}

func main() {
	// Ejecuta 3 tareas EN PARALELO
	go worker(1, "deploy")
	go worker(2, "backup")
	go worker(3, "monitor")

	// Espera a que terminen
	time.Sleep(3 * time.Second)
	fmt.Println("Todas las tareas completadas")
}

Output:

Worker 1: iniciando deploy
Worker 2: iniciando backup
Worker 3: iniciando monitor
Worker 1: completó deploy
Worker 2: completó backup
Worker 3: completó monitor
Todas las tareas completadas

¿Tiempo total? 2 segundos (no 6), porque ocurrieron en paralelo.

Channels: Comunicación Entre Goroutines

El problema: ¿Cómo se comunican las goroutines? ¿Cómo sabe main() cuándo terminaron?

Respuesta: Channels (canales). Son pipes seguros entre goroutines:

package main

import (
	"fmt"
)

func worker(id int, results chan string) {
	// Envía resultado al channel
	results <- fmt.Sprintf("Worker %d completó", id)
}

func main() {
	results := make(chan string, 3)

	// Lanza 3 goroutines
	go worker(1, results)
	go worker(2, results)
	go worker(3, results)

	// Recibe resultados
	for i := 0; i < 3; i++ {
		fmt.Println(<-results)
	}
}

Output:

Worker 3 completó
Worker 1 completó
Worker 2 completó

Context: Control Maestro

Pero aquí viene el truco profesional: ¿Qué pasa si el usuario presiona Ctrl+C? ¿O si un timeout expira?

Necesitas enviar una señal a TODAS las goroutines diciendo “¡párenla ahora!”

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, id int, results chan string) {
	for {
		select {
		case <-ctx.Done():
			// Context fue cancelado
			fmt.Printf("Worker %d: cancelado\n", id)
			return
		case <-time.After(1 * time.Second):
			// Haz trabajo
			results <- fmt.Sprintf("Worker %d: tick", id)
		}
	}
}

func main() {
	// Crea un context que expira en 3 segundos
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	results := make(chan string, 10)

	// Lanza 3 workers
	for i := 1; i <= 3; i++ {
		go worker(ctx, i, results)
	}

	// Recibe resultados hasta que se complete
	for result := range results {
		fmt.Println(result)
		select {
		case <-ctx.Done():
			fmt.Println("Contexto expirado, terminando")
			return
		default:
		}
	}
}

Aplicando a tu CLI App

Imagina que tu DeployService necesita conectarse a 100 servidores:

// domain/deploy.go
package domain

import (
	"context"
	"fmt"
	"strings"
)

type DeployService struct {
	connector ServerConnector
}

type ServerConnector interface {
	ConnectAndDeploy(ctx context.Context, server string) (string, error)
}

func (s *DeployService) DeployToServers(ctx context.Context, servers []string) map[string]error {
	results := make(chan error, len(servers))
	errors := make(map[string]error)

	for _, server := range servers {
		go func(srv string) {
			if err := s.connector.ConnectAndDeploy(ctx, srv); err != nil {
				results <- fmt.Errorf("%s: %w", srv, err)
			} else {
				results <- nil
			}
		}(server)
	}

	// Recolecta resultados
	for i := 0; i < len(servers); i++ {
		err := <-results
		if err != nil {
			parts := strings.Split(err.Error(), ":")
			errors[parts[0]] = err
		}
	}

	return errors
}

// adapters/cli.go
package adapters

import (
	"context"
	"fmt"
	"time"

	"github.com/spf13/cobra"
)

var deployCmd = &cobra.Command{
	Use:   "deploy [servers...]",
	Short: "Deploy a múltiples servidores",
	RunE: func(cmd *cobra.Command, args []string) error {
		// Context con timeout
		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
		defer cancel()

		service := domain.NewDeployService(adapters.NewSSHConnector())
		errors := service.DeployToServers(ctx, args)

		if len(errors) > 0 {
			fmt.Printf("Fallos en deploy:\n")
			for server, err := range errors {
				fmt.Printf("  %s: %v\n", server, err)
			}
			return fmt.Errorf("deploy completó con errores")
		}

		fmt.Println("Todos los servidores deployados exitosamente")
		return nil
	},
}

Ventaja: 100 servidores se despliegan en paralelo, no secuencialmente.


Parte 5: Ejecutar Procesos del Sistema

El Caso de Uso Real

Tu CLI necesita:

  1. Ejecutar comandos del sistema (git, docker, helm, etc.)
  2. Capturar su output
  3. Manejar timeouts
  4. Entender si fracasaron

Ejecutar Comandos Simples

package adapters

import (
	"context"
	"fmt"
	"os/exec"
)

type SystemExecutor interface {
	Execute(ctx context.Context, command string, args ...string) (string, error)
}

type OSExecutor struct{}

func (e *OSExecutor) Execute(ctx context.Context, command string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, command, args...)
	output, err := cmd.CombinedOutput()

	if err != nil {
		return "", fmt.Errorf("comando %s falló: %w\n%s", command, err, output)
	}

	return string(output), nil
}

Uso en CLI:

var statusCmd = &cobra.Command{
	Use:   "status",
	Short: "Ver status del deployment",
	RunE: func(cmd *cobra.Command, args []string) error {
		ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
		defer cancel()

		executor := adapters.NewOSExecutor()
		output, err := executor.Execute(ctx, "kubectl", "get", "pods")
		if err != nil {
			return err
		}

		fmt.Println(output)
		return nil
	},
}

Streaming Output en Tiempo Real

A veces necesitas ver el output mientras ocurre (como kubectl logs -f):

package adapters

import (
	"context"
	"os"
	"os/exec"
)

type StreamingExecutor struct{}

func (e *StreamingExecutor) Stream(ctx context.Context, command string, args ...string) error {
	cmd := exec.CommandContext(ctx, command, args...)

	// Pipe directamente a stdout
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	return cmd.Run()
}

Uso:

var logsCmd = &cobra.Command{
	Use:   "logs [pod]",
	Short: "Stream logs de un pod",
	RunE: func(cmd *cobra.Command, args []string) error {
		ctx := cmd.Context()
		executor := adapters.NewStreamingExecutor()
		return executor.Stream(ctx, "kubectl", "logs", "-f", args[0])
	},
}

Ahora kubectl logs -f se ejecuta nativa en tu CLI, con Ctrl+C funcionando perfectamente.


Parte 6: Linux, Filesystems y Automatización

Permisos: Lo Que Necesitas Saber

En Linux, un archivo tiene 3 niveles de permisos:

  • Owner (usuario que lo creó)
  • Group (grupo del usuario)
  • Others (todos)

Cada nivel tiene: read (4), write (2), execute (1).

En Go, puedes setear permisos así:

package adapters

import (
	"fmt"
	"os"
)

type FileManager interface {
	Create(path string, content []byte, mode os.FileMode) error
	SetPermissions(path string, mode os.FileMode) error
}

type LinuxFileManager struct{}

func (fm *LinuxFileManager) Create(path string, content []byte, mode os.FileMode) error {
	return os.WriteFile(path, content, mode)
}

func (fm *LinuxFileManager) SetPermissions(path string, mode os.FileMode) error {
	return os.Chmod(path, mode)
}

Uso práctico:

// Crear archivo ejecutable (755 = rwxr-xr-x)
fm := adapters.NewLinuxFileManager()
fm.Create("/usr/local/bin/myapp", binaryData, 0755)

// Crear archivo seguro (600 = rw-------)
fm.Create("/home/user/.ssh/id_rsa", keyData, 0600)

// Crear archivo de config legible (644 = rw-r--r--)
fm.Create("/etc/myapp/config.yaml", configData, 0644)

Configuración: Cargar desde Múltiples Fuentes

Las CLI apps profesionales cargan config así (en orden de prioridad):

  1. Valores por defecto hardcodeados
  2. Archivo de config en home (~/.config/myapp/config.yaml)
  3. Variables de entorno
  4. Flags de línea de comandos
package domain

import (
	"os"
	"path/filepath"
	"time"

	"gopkg.in/yaml.v2"
)

type Config struct {
	ApiUrl      string        `yaml:"api_url"`
	ApiKey      string        `yaml:"api_key"`
	Environment string        `yaml:"environment"`
	Timeout     time.Duration `yaml:"timeout"`
}

func LoadConfig() *Config {
	cfg := &Config{
		// Valores por defecto
		ApiUrl:      "https://api.example.com",
		ApiKey:      "",
		Environment: "production",
		Timeout:     30 * time.Second,
	}

	// 1. Leer archivo de config
	if homeDir, err := os.UserHomeDir(); err == nil {
		configPath := filepath.Join(homeDir, ".config", "myapp", "config.yaml")
		if data, err := os.ReadFile(configPath); err == nil {
			yaml.Unmarshal(data, cfg)
		}
	}

	// 2. Sobrescribir con env vars
	if url := os.Getenv("MYAPP_API_URL"); url != "" {
		cfg.ApiUrl = url
	}
	if key := os.Getenv("MYAPP_API_KEY"); key != "" {
		cfg.ApiKey = key
	}

	// 3. Flags de CLI (se pasan después)

	return cfg
}

Uso en CLI:

var rootCmd = &cobra.Command{
	Use:   "myapp",
	Short: "Mi app awesome",
	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
		cfg := domain.LoadConfig()

		// Sobrescribir con flags si fueron especificados
		if apiUrl, err := cmd.Flags().GetString("api-url"); err == nil && apiUrl != "" {
			cfg.ApiUrl = apiUrl
		}

		// Guardar en context
		ctx := context.WithValue(cmd.Context(), "config", cfg)
		// ... usar ctx

		return nil
	},
}

func init() {
	rootCmd.PersistentFlags().String("api-url", "", "URL de la API")
	rootCmd.PersistentFlags().String("api-key", "", "API Key")
}

Parte 7: Mejores Prácticas vs Antipatterns

✅ HAZLO BIEN: Logging Estructurado

// Antipattern
fmt.Println("Error occurred")
fmt.Printf("Status: %d, Message: %s\n", status, msg)

// ✅ BIEN: Logging estructurado
import "log/slog"

logger.Error("deployment failed",
	"error", err,
	"server", serverName,
	"status_code", statusCode,
	"elapsed_ms", elapsed,
)

Salida:

time=2024-01-15T10:30:45.123Z level=ERROR msg="deployment failed" error="connection timeout" server="prod-1.example.com" status_code=500 elapsed_ms=5000

Esto es parseable, filtrable, perfecto para logs en producción.

✅ HAZLO BIEN: Validación de Entrada

// Antipattern
func DeployToServer(server string) error {
	// ¿Qué si server está vacío?
	cmd := exec.Command("ssh", server, "deploy.sh")
	return cmd.Run()
}

// ✅ BIEN
func DeployToServer(ctx context.Context, server string) error {
	if server = strings.TrimSpace(server); server == "" {
		return errors.New("servidor no puede estar vacío")
	}
	if !strings.Contains(server, ".") {
		return errors.New("servidor debe ser un dominio válido")
	}
	if ctx.Err() != nil {
		return ctx.Err()
	}

	cmd := exec.CommandContext(ctx, "ssh", server, "deploy.sh")
	return cmd.Run()
}

✅ HAZLO BIEN: Retorna Errores Significativos

// Antipattern
return fmt.Errorf("error")

// ✅ BIEN
return fmt.Errorf("deploy en servidor %s falló después de %d intentos: %w",
	serverName, attempts, lastError)

✅ HAZLO BIEN: Cleanup con Defer

func BackupDatabase(ctx context.Context, dbPath string) error {
	backupFile, err := os.Create("/tmp/backup.sql")
	if err != nil {
		return err
	}
	defer backupFile.Close()

	lockFile, err := os.Create("/var/lock/myapp.lock")
	if err != nil {
		return err
	}
	defer lockFile.Close()

	// Tu lógica aquí - los archivos se cerrarán automáticamente al salir
	// Incluso si hay error en el medio
}

Parte 8: Testing

Testea la Lógica de Negocio

Gracias a la arquitectura hexagonal, la lógica está separada:

// domain/deploy_test.go
package domain

import (
	"context"
	"errors"
	"testing"
	"time"
)

type MockConnector struct {
	shouldFail bool
}

func (m *MockConnector) ConnectAndDeploy(ctx context.Context, server string) (string, error) {
	if m.shouldFail {
		return "", errors.New("mock error")
	}
	return "success", nil
}

func TestDeployService_Success(t *testing.T) {
	connector := &MockConnector{shouldFail: false}
	service := NewDeployService(connector)

	result, err := service.Deploy(context.Background(), "prod-1")

	if err != nil {
		t.Fatalf("esperaba nil, obtuve %v", err)
	}
	if result != "success" {
		t.Fatalf("resultado incorrecto: %s", result)
	}
}

func TestDeployService_Failure(t *testing.T) {
	connector := &MockConnector{shouldFail: true}
	service := NewDeployService(connector)

	_, err := service.Deploy(context.Background(), "prod-1")

	if err == nil {
		t.Fatal("esperaba error, obtuve nil")
	}
}

func TestDeployService_Timeout(t *testing.T) {
	connector := &MockConnector{}
	service := NewDeployService(connector)

	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancel()

	_, err := service.Deploy(ctx, "prod-1")

	if err != context.DeadlineExceeded {
		t.Fatalf("esperaba timeout, obtuve %v", err)
	}
}

Ejecutar tests:

go test ./... -v
go test ./domain -cover  # Ver coverage

Integración: Testea Adaptadores

// adapters/executor_test.go
package adapters

import (
	"context"
	"strings"
	"testing"
	"time"
)

func TestOSExecutor_Success(t *testing.T) {
	executor := &OSExecutor{}
	output, err := executor.Execute(context.Background(), "echo", "hello")

	if err != nil {
		t.Fatalf("error inesperado: %v", err)
	}
	if !strings.Contains(output, "hello") {
		t.Fatalf("output incorrecto: %s", output)
	}
}

func TestOSExecutor_Timeout(t *testing.T) {
	executor := &OSExecutor{}
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancel()

	_, err := executor.Execute(ctx, "sleep", "10")

	if err == nil {
		t.Fatal("esperaba error de timeout")
	}
}

Parte 9: Deployment y Distribución

Compilación Cross-Platform

Go hace trivial compilar para múltiples plataformas:

# Linux x86_64
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 ./cmd/myapp

# macOS ARM64 (M1/M2)
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 ./cmd/myapp

# Windows
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe ./cmd/myapp

Resultado: 3 binarios ejecutables, sin dependencias.

Distribución: Homebrew

Crear un formulario Homebrew para que users instalen con brew install myapp:

# homebrew-myapp/myapp.rb
class Myapp < Formula
  desc "Mi awesome CLI app"
  homepage "https://github.com/username/myapp"
  url "https://github.com/username/myapp/releases/download/v1.0.0/myapp-darwin-arm64.tar.gz"
  sha256 "abc123def456..."

  def install
    bin.install "myapp"
  end

  test do
    system "#{bin}/myapp", "--version"
  end
end

Versionado Automático

En tu cmd/myapp/main.go:

package main

import (
	"fmt"
	"github.com/spf13/cobra"
)

var (
	Version = "dev"
	Build   = "unknown"
)

var rootCmd = &cobra.Command{
	Use:   "myapp",
	Short: "Mi awesome CLI app",
	Version: fmt.Sprintf("%s (build: %s)", Version, Build),
}

func main() {
	rootCmd.Execute()
}

En tu build script:

#!/bin/bash
VERSION=$(git describe --tags --always)
BUILD=$(git rev-parse --short HEAD)

go build \
  -ldflags="-X main.Version=$VERSION -X main.Build=$BUILD" \
  -o myapp \
  ./cmd/myapp

Resultado:

$ myapp --version
v1.2.3 (build: a1b2c3d4)

GitHub Releases y Automatización

Con GitHub Actions, puedes automatizar el build y release:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-go@v4
        with:
          go-version: "1.25"

      - name: Build
        run: |
          GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 ./cmd/myapp
          GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 ./cmd/myapp

      - name: Release
        uses: softprops/action-gh-release@v1
        with:
          files: |
            myapp-linux-amd64
            myapp-darwin-arm64

Cuando haces git push --tags v1.0.0, todo se automatiza.


El Viaje Completo

Hemos cubierto:

Parte 1: Fundamentos de CLI en Go ✅ Parte 2: Flags y Arguments con Cobra ✅ Parte 3: Arquitectura Hexagonal en CLI ✅ Parte 4: Goroutines, Channels y Context ✅ Parte 5: Ejecutar Procesos del Sistema ✅ Parte 6: Linux, Filesystems y Config ✅ Parte 7: Mejores Prácticas vs Antipatterns ✅ Parte 8: Testing Completo ✅ Parte 9: Deployment y Distribución

Checklist para tu Primer CLI App

  • Usar Cobra para argumentos
  • Separar CLI (adaptador) de lógica (dominio)
  • Implementar interfaces para dependencias
  • Usar context.Context para cancelaciones y timeouts
  • Logging estructurado con slog
  • Tests para lógica de negocio y adaptadores
  • Validación de entrada siempre
  • Build cross-platform con scripts
  • Publicar en GitHub Releases
  • Distribuir por Homebrew

Reflexión Final

Hace una década, escribir CLI apps era tedioso. Necesitabas lenguajes pesados, frameworks complejos, y mucho boilerplate.

Go cambió eso. Go hace fácil lo que es difícil en otros lenguajes:

  • Concurrencia sin threads
  • Compilación a binario único (sin runtime)
  • Distribución simple (copia el archivo)
  • Performance cercana a C

Por eso Go es el lenguaje predilecto para infraestructura: Docker, Kubernetes, Terraform, Prometheus… todos en Go.

Ahora que tienes este conocimiento, estás listo para contribuir a esos proyectos, o crear el tuyo propio.

Tu próximo paso: crea un pequeño CLI app, publica en GitHub, comparte. Aprenderás más en 2 semanas que en 2 meses leyendo.

¿Preguntas? Déjalas en los comentarios.

Tags

#go #cli #arquitectura-hexagonal #linux #sistemas #automatización