Go CLI Architecture: Cobra & Viper
Idiomatic patterns and best practices for building robust, configuration-driven command-line interfaces using Cobra and Viper.
When to Activate
- Writing a new CLI application in Go
- Adding commands, subcommands, or flags to an existing Cobra application
- Integrating Viper for configuration file, environment variable, or flag management
- Reviewing or refactoring CLI code that uses Cobra and/or Viper
- Designing the command structure or configuration schema for a CLI tool
- Testing CLI commands
Core Philosophy
The Command-First Architecture
Treat your application binary as a router for commands. The CLI framework (Cobra) should solely handle flags, arguments, and routing. Your core business logic should remain completely unaware of the CLI layer, making it highly testable and reusable.
Unified Configuration
Configuration should be environment-aware and unified. Viper acts as the single source of truth, merging defaults, config files, environment variables, and command-line flags into a cohesive state before passing it to the application logic.
CLI Package Organization
Anti-Pattern: Hiding all your commands and core logic deep inside an internal/ directory tree, or shoving everything into main.go.
Discoverable, Flat Structures
Command routing and business logic should live in standard, logically named packages. The cmd/ package handles the CLI surface area, while other top-level packages handle the domain logic.
mycli/
├── main.go # Minimal entry point: strictly calls cmd.Execute()
├── cmd/ # The Cobra routing layer
│ ├── root.go # Base command, global flags, and Viper setup
│ ├── serve.go # The 'serve' subcommand
│ └── build.go # The 'build' subcommand
├── engine/ # Core business logic (name based on your domain)
│ ├── server.go
│ └── compiler.go
├── go.mod
└── go.sum
main.go is intentionally minimal:
package main
import "github.com/spf13/myapp/cmd"
func main() {
cmd.Execute()
}
Decouple Commands from Execution
The files in your cmd/ package should do exactly three things:
- Define the Cobra command, its aliases, and help text.
- Bind Viper flags and configuration for that specific command.
- Call a function in your core logic package (e.g.,
engine), passing in the parsed configuration and the command context.
Your core logic (the engine package) should have absolutely zero imports from github.com/spf13/cobra or github.com/spf13/viper.
Cobra Best Practices
1. Use RunE for Native Error Handling
Avoid Run. If a command fails, use RunE to return the error up the execution chain. This allows the root command to handle errors gracefully and consistently, rather than relying on scattered log.Fatal calls that bypass defer statements.
// Idiomatic: Returning errors to be handled by the executor
var serverCmd = &cobra.Command{
Use: "server",
Short: "Starts the primary application server",
RunE: func(cmd *cobra.Command, args []string) error {
server := engine.NewServer()
if err := server.Start(); err != nil {
return fmt.Errorf("server failure: %w", err)
}
return nil
},
}
2. Silence Usage on Application Errors
By default, Cobra prints the full help text whenever an error is returned. This is confusing if the error was a runtime failure (like a network timeout) rather than a syntax error.
// In cmd/root.go
rootCmd := &cobra.Command{
Use: "mycli",
SilenceUsage: true, // Don't print help on runtime errors
SilenceErrors: true, // Allow main.go to handle the error printing
}
3. Context-Aware Commands
Modern Go relies heavily on context.Context for cancellation and timeouts. Pass the Cobra command's context directly to your business logic. This context automatically listens for OS termination signals (like SIGINT or Ctrl+C).
RunE: func(cmd *cobra.Command, args []string) error {
// Passes context down for graceful shutdown
return engine.Process(cmd.Context(), args)
}
4. PersistentPreRunE for Shared Setup
Use PersistentPreRunE on the root command to run setup (logging, config validation) after flags are parsed but before any subcommand runs:
rootCmd = &cobra.Command{
Use: "myapp",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Safe to read viper values here — flags + config + env are all merged
return setupLogger(viper.GetString("log-level"))
},
}
Cobra runs PersistentPreRunE for every subcommand automatically. If a subcommand also defines PersistentPreRunE, you must call the parent's explicitly — Cobra does not chain them automatically.
5. Flag Design
// Persistent flags — inherited by all subcommands
rootCmd.PersistentFlags().String("config", "", "config file path")
rootCmd.PersistentFlags().Bool("verbose", false, "enable verbose output")
// Local flags — only for this command
serveCmd.Flags().String("addr", ":8080", "listen address")
// Required flags — Cobra validates before RunE is called
serveCmd.Flags().String("name", "", "required name")
serveCmd.MarkFlagRequired("name")
// Mutually exclusive flags
serveCmd.MarkFlagsMutuallyExclusive("json", "yaml")
- Use
PersistentFlagsfor cross-cutting concerns (config, verbosity, output format). - Use
Flagsfor command-specific options. - Always provide short flags (
-v,-o) for common options.
6. Shell Completion
Cobra generates shell completion for free:
// Custom completions for a flag
serveCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})
myapp completion bash > /etc/bash_completion.d/myapp
myapp completion zsh > "${fpath[1]}/_myapp"
myapp completion fish > ~/.config/fish/completions/myapp.fish
myapp completion powershell | Out-File -Encoding utf8 "$PROFILE\myapp.ps1"
Viper Configuration Patterns
1. Unmarshal into Typed Structs
Anti-Pattern: Calling viper.GetString("database.host") deep inside your business logic. This tightly couples your domain to Viper and scatters magic strings throughout your codebase.
Instead, define a strongly-typed configuration struct, unmarshal Viper's state into it at the routing layer (cmd/), and pass that struct down.
type Config struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
func initConfig() (*Config, error) {
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unable to decode config: %w", err)
}
return &cfg, nil
}
2. The Binding Hierarchy
Viper seamlessly merges configuration sources in this order (highest → lowest priority):
- Explicit
Set()calls in code - Flags (bound via
BindPFlag) - Environment variables (
MYCLI_PORT) - Config file (
~/.mycli.yaml,./.mycli.yaml) - Defaults (
viper.SetDefault)
You must explicitly bind each source. Binding environment variables is crucial for containerized deployments:
func init() {
// 1. Define the flag
rootCmd.PersistentFlags().Int("port", 8080, "Server port")
// 2. Bind the flag to Viper
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
// 3. Enable environment variables (e.g., MYCLI_PORT)
viper.SetEnvPrefix("mycli")
viper.AutomaticEnv()
// 4. Set fallback defaults
viper.SetDefault("port", 8080)
}
3. Environment Variable Mapping
With viper.SetEnvPrefix("MYAPP") and viper.AutomaticEnv():
| Viper key | Environment variable |
|---|---|
log-level | `MYAPP_LOG_LEVE |