Go CLI Apps: De 0 a Experto con Arquitectura Hexagonal
Backend

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
#go #cli #arquitectura-hexagonal #linux #sistemas #automatización

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.