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.
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
| Aspecto | Go | Python | Node.js | Rust |
|---|---|---|---|---|
| Binary único | ✅ | ❌ | ❌ | ✅ |
| Sin runtime | ✅ | ❌ | ❌ | ✅ |
| Startup time | <10ms | 100ms+ | 200ms+ | <10ms |
| Memory | 5MB | 50MB+ | 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:
- ❌ Imposible testear (necesita SSH real, SMTP real)
- ❌ Acoplado a SSH (¿qué si mañana usas Kubernetes?)
- ❌ Acoplado a email (¿qué si necesitas Slack?)
- ❌ 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.
- Define interfaces (puertos) para lo que necesitas
- Tu lógica depende de interfaces, NO de implementaciones concretas
- 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:
- Testeable: Puedes testear
DeployServicesin Docker, sin CLI, con mocks simples - Flexible: Cambiar de Docker a Kubernetes es solo crear un nuevo adaptador
- Mantenible: La lógica de negocio está clara y separada
- 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:
- Hacer 100 requests HTTP a 100 servidores
- Procesar archivos grandes mientras monitorea el sistema
- 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:
- Ejecutar comandos del sistema (git, docker, helm, etc.)
- Capturar su output
- Manejar timeouts
- 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):
- Valores por defecto hardcodeados
- Archivo de config en home (
~/.config/myapp/config.yaml) - Variables de entorno
- 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
Artículos relacionados
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.
AWS Desde la Perspectiva de un Solutions Architect: Teoría, Decisiones y Go
Una guía teórica exhaustiva sobre AWS desde la mentalidad de un Solutions Architect: cómo pensar en decisiones arquitectónicas, trade-offs fundamentales, integración con Go y otros lenguajes, patrones de diseño empresarial, y cómo aplicar esto en proyectos reales.
Go Concurrente: Goroutines, Channels y Context - De Cero a Experto
Guía exhaustiva sobre programación concurrente en Go 1.25: goroutines, channels, context, patrones, race conditions, manejo de errores, y arquitectura profesional. Desde principiante absoluto hasta nivel experto con ejemplos prácticos.