Structuring Larger CLI Apps

Updated

September 8, 2026

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.

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/mytool

Multi-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

  1. Split the kv CLI into cmd/mytool + internal/app.
  2. Add version subcommand from ldflags.
  3. Test App.Run with a fake Store.