golang

Building Robust CLI Applications in Go: Best Practices and Patterns

Learn to build professional-grade CLI apps in Go with best practices for argument parsing, validation, and UX. This practical guide covers command handling, progress indicators, config management, and output formatting to create tools users will love.

Building Robust CLI Applications in Go: Best Practices and Patterns

Go has become a popular choice for building command-line interface (CLI) applications due to its simplicity, performance, and rich ecosystem. I’ve worked with numerous CLI tools in production environments and found certain patterns consistently lead to better user experiences and maintainable codebases.

Command Argument Parsing

The foundation of any CLI application is proper argument parsing. While Go’s standard library includes the flag package, more sophisticated applications often benefit from third-party libraries.

package main

import (
    "fmt"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:  "fileutil",
        Usage: "file manipulation utilities",
        Commands: []*cli.Command{
            {
                Name:    "compress",
                Aliases: []string{"c"},
                Usage:   "compress a file",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "algorithm",
                        Aliases:  []string{"a"},
                        Value:    "gzip",
                        Usage:    "compression algorithm to use",
                        Required: false,
                    },
                },
                Action: func(c *cli.Context) error {
                    filename := c.Args().First()
                    algorithm := c.String("algorithm")
                    fmt.Printf("Compressing %s using %s\n", filename, algorithm)
                    return nil
                },
            },
        },
    }

    if err := app.Run(os.Args); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(1)
    }
}

Cobra is another excellent choice for complex command hierarchies:

package main

import (
    "fmt"

    "github.com/spf13/cobra"
)

func main() {
    var algorithm string

    compressCmd := &cobra.Command{
        Use:   "compress [file]",
        Short: "Compress a file",
        Args:  cobra.ExactArgs(1),
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("Compressing %s using %s\n", args[0], algorithm)
        },
    }

    compressCmd.Flags().StringVarP(&algorithm, "algorithm", "a", "gzip", "compression algorithm to use")

    rootCmd := &cobra.Command{Use: "fileutil"}
    rootCmd.AddCommand(compressCmd)
    rootCmd.Execute()
}

Input Validation

Validating user input prevents runtime errors and improves user experience. I always implement validation logic early in the command processing chain.

func validateFilePath(path string) error {
    if path == "" {
        return errors.New("file path cannot be empty")
    }
    
    info, err := os.Stat(path)
    if err != nil {
        if os.IsNotExist(err) {
            return fmt.Errorf("file does not exist: %s", path)
        }
        return fmt.Errorf("error accessing file: %w", err)
    }
    
    if info.IsDir() {
        return fmt.Errorf("path is a directory, not a file: %s", path)
    }
    
    return nil
}

func compressFile(c *cli.Context) error {
    filePath := c.Args().First()
    if err := validateFilePath(filePath); err != nil {
        return err
    }
    
    // Proceed with compression...
    return nil
}

For sensitive inputs like passwords, use terminal packages to mask input:

import "golang.org/x/term"

func getPassword() (string, error) {
    fmt.Print("Enter password: ")
    passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
    fmt.Println() // Add newline after password input
    
    if err != nil {
        return "", err
    }
    
    return string(passwordBytes), nil
}

Progress Indicators

Long-running operations benefit from progress indicators to assure users the application hasn’t stalled.

package main

import (
    "time"

    "github.com/schollz/progressbar/v3"
)

func main() {
    totalItems := 1000
    bar := progressbar.Default(int64(totalItems))
    
    for i := 0; i < totalItems; i++ {
        bar.Add(1)
        time.Sleep(5 * time.Millisecond)
    }
}

For indeterminate operations, spinners provide feedback without showing percentage:

package main

import (
    "time"

    "github.com/briandowns/spinner"
)

func main() {
    s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
    s.Prefix = "Processing "
    s.Start()
    
    // Simulate work
    time.Sleep(3 * time.Second)
    
    s.Stop()
    fmt.Println("Done!")
}

Graceful Shutdown Handling

A well-designed CLI responds properly to termination signals:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    
    // Set up signal handling
    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-signalChan
        fmt.Println("\nReceived termination signal")
        cancel() // Cancel the context
    }()
    
    if err := runLongOperation(ctx); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(1)
    }
}

func runLongOperation(ctx context.Context) error {
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("Performing cleanup...")
            time.Sleep(500 * time.Millisecond)
            fmt.Println("Cleanup complete")
            return nil
        default:
            fmt.Printf("Working... step %d/10\n", i+1)
            time.Sleep(1 * time.Second)
        }
    }
    return nil
}

Configuration Management

Supporting multiple configuration sources makes applications flexible across environments:

package main

import (
    "fmt"

    "github.com/spf13/viper"
)

func main() {
    // Set defaults
    viper.SetDefault("port", 8080)
    viper.SetDefault("database.timeout", 30)
    
    // Config file support
    viper.SetConfigName("config")           // name of config file (without extension)
    viper.SetConfigType("yaml")             // type of config file
    viper.AddConfigPath("/etc/myapp/")      // system-wide config
    viper.AddConfigPath("$HOME/.myapp")     // user-specific config
    viper.AddConfigPath(".")                // current directory
    
    // Read environment variables
    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYAPP")             // will be uppercased automatically
    
    // Read config file
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            fmt.Printf("Error reading config file: %s\n", err)
        }
    }
    
    // Display configuration
    fmt.Printf("Port: %d\n", viper.GetInt("port"))
    fmt.Printf("Database Timeout: %d seconds\n", viper.GetInt("database.timeout"))
}

A sample config.yaml file might look like:

port: 9090
database:
  timeout: 60
  host: localhost
  username: appuser

Interactive Prompts

For complex operations, interactive prompts guide users through choices:

package main

import (
    "fmt"

    "github.com/AlecAivazis/survey/v2"
)

func main() {
    var deployEnv string
    var features []string
    var confirm bool
    
    // Ask for environment
    prompt := &survey.Select{
        Message: "Choose deployment environment:",
        Options: []string{"development", "staging", "production"},
    }
    survey.AskOne(prompt, &deployEnv)
    
    // Multiple selection
    multiPrompt := &survey.MultiSelect{
        Message: "Select features to enable:",
        Options: []string{"analytics", "authentication", "notifications", "reporting"},
    }
    survey.AskOne(multiPrompt, &features)
    
    // Confirmation
    confirmPrompt := &survey.Confirm{
        Message: fmt.Sprintf("Deploy to %s with %d features?", deployEnv, len(features)),
    }
    survey.AskOne(confirmPrompt, &confirm)
    
    if confirm {
        fmt.Println("Deploying...")
    } else {
        fmt.Println("Deployment canceled")
    }
}

Proper Exit Codes

Using meaningful exit codes helps automation tools understand command outcomes:

package main

import (
    "errors"
    "fmt"
    "os"
)

const (
    ExitSuccess          = 0
    ExitFailure          = 1
    ExitInvalidInput     = 2
    ExitFileError        = 3
    ExitNetworkError     = 4
    ExitPermissionDenied = 5
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "Error: Missing required argument")
        os.Exit(ExitInvalidInput)
    }
    
    if err := processFile(os.Args[1]); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        
        var exitCode int
        switch {
        case errors.Is(err, os.ErrNotExist):
            exitCode = ExitFileError
        case errors.Is(err, os.ErrPermission):
            exitCode = ExitPermissionDenied
        default:
            exitCode = ExitFailure
        }
        
        os.Exit(exitCode)
    }
    
    os.Exit(ExitSuccess)
}

func processFile(path string) error {
    // Implementation
    return nil
}

Output Formatting

Supporting multiple output formats helps both humans and automated tools consume your CLI’s output:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "text/tabwriter"

    "github.com/urfave/cli/v2"
    "gopkg.in/yaml.v3"
)

type ServerInfo struct {
    Name     string `json:"name" yaml:"name"`
    IP       string `json:"ip" yaml:"ip"`
    Status   string `json:"status" yaml:"status"`
    Uptime   int    `json:"uptime" yaml:"uptime"`
    CPU      float64 `json:"cpu" yaml:"cpu"`
    Memory   float64 `json:"memory" yaml:"memory"`
}

func getServers() []ServerInfo {
    return []ServerInfo{
        {"web-01", "192.168.1.10", "running", 15, 45.2, 60.5},
        {"db-01", "192.168.1.11", "running", 20, 78.3, 82.1},
        {"cache-01", "192.168.1.12", "stopped", 0, 0.0, 0.0},
    }
}

func main() {
    var outputFormat string
    
    app := &cli.App{
        Name:  "serverctl",
        Usage: "server management utility",
        Flags: []cli.Flag{
            &cli.StringFlag{
                Name:        "output",
                Aliases:     []string{"o"},
                Value:       "table",
                Usage:       "Output format (table, json, yaml)",
                Destination: &outputFormat,
            },
        },
        Commands: []*cli.Command{
            {
                Name:  "list",
                Usage: "list all servers",
                Action: func(c *cli.Context) error {
                    servers := getServers()
                    
                    switch c.String("output") {
                    case "json":
                        return outputJSON(servers)
                    case "yaml":
                        return outputYAML(servers)
                    default:
                        return outputTable(servers)
                    }
                },
            },
        },
    }
    
    app.Run(os.Args)
}

func outputTable(servers []ServerInfo) error {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
    fmt.Fprintln(w, "NAME\tIP\tSTATUS\tUPTIME (h)\tCPU (%)\tMEMORY (%)")
    
    for _, server := range servers {
        fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%.1f\t%.1f\n",
            server.Name, server.IP, server.Status, server.Uptime, server.CPU, server.Memory)
    }
    
    return w.Flush()
}

func outputJSON(servers []ServerInfo) error {
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    return encoder.Encode(servers)
}

func outputYAML(servers []ServerInfo) error {
    encoder := yaml.NewEncoder(os.Stdout)
    return encoder.Encode(servers)
}

Comprehensive Example

Let’s tie these patterns together in a more complete example:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "text/tabwriter"
    "time"

    "github.com/briandowns/spinner"
    "github.com/schollz/progressbar/v3"
    "github.com/spf13/viper"
    "github.com/urfave/cli/v2"
    "gopkg.in/yaml.v3"
)

// Exit codes
const (
    ExitSuccess      = 0
    ExitFailure      = 1
    ExitInvalidInput = 2
    ExitFileError    = 3
)

// Data structures
type Task struct {
    ID          string    `json:"id" yaml:"id"`
    Title       string    `json:"title" yaml:"title"`
    Description string    `json:"description" yaml:"description"`
    Status      string    `json:"status" yaml:"status"`
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
}

// Global variables
var tasks = []Task{
    {
        ID:          "task-001",
        Title:       "Implement user authentication",
        Description: "Add OAuth2 support for user login",
        Status:      "in_progress",
        CreatedAt:   time.Now().Add(-72 * time.Hour),
    },
    {
        ID:          "task-002",
        Title:       "Optimize database queries",
        Description: "Add indices and rewrite slow queries",
        Status:      "todo",
        CreatedAt:   time.Now().Add(-48 * time.Hour),
    },
    {
        ID:          "task-003",
        Title:       "Write documentation",
        Description: "Create user and developer guides",
        Status:      "completed",
        CreatedAt:   time.Now().Add(-24 * time.Hour),
    },
}

func main() {
    // Setup configuration
    viper.SetDefault("storage.path", "./tasks.json")
    viper.SetConfigName("taskctl")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.taskctl")
    viper.AddConfigPath(".")
    viper.AutomaticEnv()
    viper.SetEnvPrefix("TASKCTL")
    
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            fmt.Printf("Error reading config file: %s\n", err)
        }
    }

    // Create CLI app
    app := &cli.App{
        Name:  "taskctl",
        Usage: "task management utility",
        Flags: []cli.Flag{
            &cli.StringFlag{
                Name:    "output",
                Aliases: []string{"o"},
                Value:   "table",
                Usage:   "Output format (table, json, yaml)",
            },
        },
        Commands: []*cli.Command{
            {
                Name:    "list",
                Aliases: []string{"ls"},
                Usage:   "list all tasks",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:    "status",
                        Aliases: []string{"s"},
                        Usage:   "Filter by status (todo, in_progress, completed)",
                    },
                },
                Action: listTasks,
            },
            {
                Name:  "add",
                Usage: "add a new task",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "title",
                        Aliases:  []string{"t"},
                        Usage:    "Task title",
                        Required: true,
                    },
                    &cli.StringFlag{
                        Name:    "description",
                        Aliases: []string{"d"},
                        Usage:   "Task description",
                    },
                    &cli.StringFlag{
                        Name:    "status",
                        Aliases: []string{"s"},
                        Value:   "todo",
                        Usage:   "Task status (todo, in_progress, completed)",
                    },
                },
                Action: addTask,
            },
            {
                Name:  "export",
                Usage: "export tasks to a file",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "file",
                        Aliases:  []string{"f"},
                        Usage:    "File to export to",
                        Required: true,
                    },
                    &cli.StringFlag{
                        Name:    "format",
                        Value:   "json",
                        Usage:   "Export format (json, yaml)",
                    },
                },
                Action: exportTasks,
            },
        },
    }

    // Handle interrupts
    ctx, cancel := context.WithCancel(context.Background())
    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-signalChan
        fmt.Println("\nReceived termination signal")
        cancel()
    }()

    // Run the application
    if err := app.RunContext(ctx, os.Args); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(ExitFailure)
    }
}

func listTasks(c *cli.Context) error {
    statusFilter := c.String("status")
    var filteredTasks []Task
    
    if statusFilter != "" {
        for _, task := range tasks {
            if task.Status == statusFilter {
                filteredTasks = append(filteredTasks, task)
            }
        }
    } else {
        filteredTasks = tasks
    }
    
    switch c.String("output") {
    case "json":
        return outputJSON(filteredTasks)
    case "yaml":
        return outputYAML(filteredTasks)
    default:
        return outputTaskTable(filteredTasks)
    }
}

func addTask(c *cli.Context) error {
    title := c.String("title")
    description := c.String("description")
    status := c.String("status")
    
    // Validate input
    if title == "" {
        return fmt.Errorf("title cannot be empty")
    }
    
    if status != "todo" && status != "in_progress" && status != "completed" {
        return fmt.Errorf("invalid status: %s", status)
    }
    
    // Show progress
    s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
    s.Prefix = "Adding task "
    s.Start()
    
    // Simulate work
    time.Sleep(1 * time.Second)
    
    // Create task
    task := Task{
        ID:          fmt.Sprintf("task-%03d", len(tasks)+1),
        Title:       title,
        Description: description,
        Status:      status,
        CreatedAt:   time.Now(),
    }
    
    tasks = append(tasks, task)
    
    s.Stop()
    fmt.Printf("Task added with ID: %s\n", task.ID)
    
    return nil
}

func exportTasks(c *cli.Context) error {
    filename := c.String("file")
    format := c.String("format")
    
    // Validate input
    if filename == "" {
        return fmt.Errorf("filename cannot be empty")
    }
    
    if format != "json" && format != "yaml" {
        return fmt.Errorf("unsupported format: %s", format)
    }
    
    // Create progress bar
    bar := progressbar.Default(100)
    
    // Simulate export progress
    for i := 0; i < 100; i++ {
        bar.Add(1)
        time.Sleep(20 * time.Millisecond)
    }
    
    // Write file
    file, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("could not create file: %w", err)
    }
    defer file.Close()
    
    switch format {
    case "json":
        encoder := json.NewEncoder(file)
        encoder.SetIndent("", "  ")
        if err := encoder.Encode(tasks); err != nil {
            return fmt.Errorf("could not encode tasks to JSON: %w", err)
        }
    case "yaml":
        encoder := yaml.NewEncoder(file)
        if err := encoder.Encode(tasks); err != nil {
            return fmt.Errorf("could not encode tasks to YAML: %w", err)
        }
    }
    
    fmt.Printf("\nExported %d tasks to %s in %s format\n", len(tasks), filename, format)
    return nil
}

func outputTaskTable(tasks []Task) error {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
    fmt.Fprintln(w, "ID\tTITLE\tSTATUS\tCREATED")
    
    for _, task := range tasks {
        fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
            task.ID, task.Title, task.Status, task.CreatedAt.Format("2006-01-02"))
    }
    
    return w.Flush()
}

func outputJSON(data interface{}) error {
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    return encoder.Encode(data)
}

func outputYAML(data interface{}) error {
    encoder := yaml.NewEncoder(os.Stdout)
    return encoder.Encode(data)
}

In my experience, following these patterns creates CLI applications that are intuitive, maintainable, and robust. The Go ecosystem offers excellent tooling for command-line application development, balancing simplicity with power.

When I build CLI tools, I focus on making them feel like Unix tools users are already familiar with. This means supporting standard flags like --help and --version, using stdin/stdout for piping data between commands, and providing clear, concise error messages.

These patterns have served me well in production environments where reliability and ease of use are paramount. Command-line tools are often the first interface users have with your software, so making a good impression with a thoughtful CLI design is invaluable.

Keywords: Go CLI development keywords, Go command-line interface, CLI application development, Go CLI libraries, Go Cobra library, Go Urfave CLI, terminal application Go, CLI argument parsing, Go user input validation, CLI progress indicators, graceful shutdown handling, Go configuration management, Viper configuration, interactive CLI prompts, Survey prompts Go, proper exit codes, CLI output formatting, Go CLI best practices, building command-line tools, Go CLI tools, Go tabwriter, CLI signal handling, Go error handling, Go context cancellation, Go CLI examples, JSON YAML output CLI, Go CLI patterns, production-grade CLI, cross-platform CLI applications, Go CLI spinners, Go CLI progress bars, command hierarchies Go



Similar Posts
Blog Image
Are You Ready to Master URL Rewriting in Gin Like a Pro?

Spice Up Your Gin Web Apps with Clever URL Rewriting Tricks

Blog Image
7 Proven Debugging Strategies for Golang Microservices in Production

Discover 7 proven debugging strategies for Golang microservices. Learn how to implement distributed tracing, correlation IDs, and structured logging to quickly identify issues in complex architectures. Practical code examples included.

Blog Image
Go Microservices Architecture: Scaling Your Applications with gRPC and Protobuf

Go microservices with gRPC and Protobuf offer scalable, efficient architecture. Enables independent service scaling, efficient communication, and flexible deployment. Challenges include complexity, testing, and monitoring, but tools like Kubernetes and service meshes help manage these issues.

Blog Image
7 Essential Go Design Patterns: Boost Code Quality and Maintainability

Explore 7 essential Go design patterns to enhance code quality and maintainability. Learn practical implementations with examples. Improve your Go projects today!

Blog Image
10 Unique Golang Project Ideas for Developers of All Skill Levels

Golang project ideas for skill improvement: chat app, web scraper, key-value store, game engine, time series database. Practical learning through hands-on coding. Start small, break tasks down, use documentation, and practice consistently.

Blog Image
How Can You Make Your Golang App Lightning-Fast with Creative Caching?

Yeah, We Made Gin with Golang Fly—Fast, Fresh, and Freakin’ Future-Ready!