Structuring Larger CLI Apps
Structuring Larger CLI Apps
Overview
Small tools fit in main.go. Larger CLIs need packages so commands, domain logic, and I/O stay testable. This chapter shows a stdlib-friendly layout that also migrates cleanly to Cobra.
Recommended layout
mytool/
go.mod
cmd/
mytool/
main.go # os.Exit(realMain(...)) only
internal/
app/
app.go # App struct, Run(args)
root.go # dispatch
cmd/
get.go
set.go
list.go
config/
config.go
domain/
store.go # business logic
scripts/
README.md
internal/ keeps API private to the module.
App struct
package app
type App struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// optional: Version string, FS for embed, HTTP client
}
func New() *App {
return &App{
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}
func (a *App) Run(args []string) error {
if len(args) < 1 {
return a.usage()
}
switch args[0] {
case "get":
return a.cmdGet(args[1:])
case "set":
return a.cmdSet(args[1:])
case "version":
fmt.Fprintln(a.Stdout, version)
return nil
default:
return fmt.Errorf("unknown command %q", args[0])
}
}// cmd/mytool/main.go
func main() {
a := app.New()
if err := a.Run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Dependency injection for domain
type Store interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, val string) error
}
type App struct {
Store Store
Stdout io.Writer
// ...
}
func (a *App) cmdGet(args []string) error {
// parse flags...
val, err := a.Store.Get(context.Background(), key)
// write to a.Stdout
return err
}Tests inject a memory store.
Cobra migration path
When you adopt Cobra later:
internal/app → cobra root command constructor
internal/cmd → cobra subcommands calling domain
domain/ → unchanged
Keep domain free of cobra/flag types.
Version and commit via ldflags
// internal/buildinfo/buildinfo.go
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)go build -ldflags "-X example.com/mytool/internal/buildinfo.Version=1.2.3 \
-X example.com/mytool/internal/buildinfo.Commit=$(git rev-parse --short HEAD)" \
-o mytool ./cmd/mytoolMulti-binary monorepo
cmd/
mytool/
mytool-helper/
Share internal/ packages; thin mains.
Example: split packages sketch
// internal/cmd/get.go
package cmd
func Get(stdout io.Writer, store Store, args []string) error {
fs := flag.NewFlagSet("get", flag.ContinueOnError)
// ...
}// internal/app/root.go
case "get":
return cmd.Get(a.Stdout, a.Store, args[1:])Config package
package config
type Config struct {
Path string
// ...
}
func Load(path string) (Config, error) { /* json */ }Main/app resolve path; commands receive Config values, not global state.
Rules of thumb
| Do | Don’t |
|---|---|
cmd/ + internal/ |
Everything in package main forever |
| Inject Store/IO | Global var db *sql.DB in all files |
| Keep domain pure | Import flag into domain validators |
| One binary entry per UX | Hidden side-effect init() parsers |
Try next
- Split the kv CLI into
cmd/mytool+internal/app. - Add
versionsubcommand from ldflags. - Test
App.Runwith a fake Store.