JavaRush /Courses /Go SELF /Dispatcher pattern: app <cmd> [flags]

Dispatcher pattern: app <cmd> [flags]

Go SELF
Level 49, Lesson 1
Available

1. Why subcommands are needed

When a program does more than one thing, a beginner naturally wants: “let’s add a -mode=... flag and switch on it”. That works exactly until you have 5 modes, 12 flags, and infinitely many combinations (and you suddenly end up writing your own mini command-line language without planning to).

Subcommands solve this pain in a human way: the first “position” after the program name is the name of the operating mode. That is, a command is not a flag but the first argument. You get a clear launch contract:

app <cmd> [flags] [args]

Where <cmd> is a subcommand (for example, add, list, done), and after that come flags and positional arguments specific to this subcommand.

For our learning app, we’ll pretend we’re writing a tiny task manager todo. (The superhero part here is that it doesn’t store anything on disk yet and doesn’t sync to the cloud. Yes, I’m sad too. But at least you won’t drown in details.)

2. The role of the subcommand dispatcher

When you do subcommands, an important architectural role appears: the dispatcher. It’s like a bouncer at a club entrance (not the smartest, but very useful): it checks that you actually showed up (a command is provided), that you’re not trying to get in “under a fake name” (the command exists), and then lets you inside—into the handler for the specific command.

It’s important that the dispatcher must not do “all the work of the program”. Its job is routing: “who gets control”.

It helps to keep a simple scheme in mind:

flowchart TD
    A[main: read os.Args] --> B{Command provided?}
    B -- no --> U[Usage error: missing command]
    B -- yes --> C{Known command?}
    C -- no --> X[Usage error: unknown command]
    C -- yes --> D["run<Cmd>(command argv)"]
    D --> E[Result or error]

If the dispatcher starts parsing flags for all commands, validating all arguments for all modes, and printing everything under the sun, it quickly turns into a “god function” that even its own tests are afraid of.

3. Where the command comes from: os.Args

Before writing a dispatcher, you need to clearly understand: os.Args includes the program name.

For example, the user runs:

todo add -title "Buy milk"
  • os.Args[0] is "todo" (or the path to the binary)
  • os.Args[1] is "add" (our command)
  • after that come the command arguments

A mini example that prints the command:

package main

import (
	"fmt"
	"os"
)

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		fmt.Fprintln(os.Stderr, "missing command") // missing command
		return
	}
	fmt.Fprintln(os.Stdout, "cmd:", args[0]) // cmd: add
}

There’s a small but critical detail here: first check len(args), and only then touch args[0]. Otherwise you’ll meet a panic (which is, of course, an interesting experience, but we’re building a CLI after all).

4. A minimal dispatcher using switch

The simplest dispatcher is a switch on a string. That’s fine, readable, and works great while there are few commands.

We’ll agree on a simple contract for a command handler: the handler receives the argv of this command (that is, everything after the command name) and returns an error if something goes wrong.

package main

import "fmt"

func dispatch(args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("missing command")
	}

	cmd := args[0]
	argv := args[1:]

	switch cmd {
	case "add":
		return runAdd(argv)
	case "list":
		return runList(argv)
	default:
		return fmt.Errorf("unknown command: %s", cmd)
	}
}

func runAdd(argv []string) error  { _ = argv; return nil }
func runList(argv []string) error { _ = argv; return nil }

Note: the dispatcher does not read os.Args itself. It receives args as a parameter. This small decision suddenly makes the code much nicer: dispatch can be tested and called with any arguments without mocking the process environment.

5. A routing table instead of a big switch

While there are two commands, switch is ideal. But if there are 10–15 commands, the switch grows, and you start scrolling the code like a news feed: “where is my done…”.

A routing table helps here: map[string]handler, where the key is the command name and the value is the handler function.

First, declare the handler type:

package main

type handler func([]string) error

Now the dispatcher itself:

package main

import "fmt"

func dispatchByTable(args []string, routes map[string]handler) error {
	if len(args) == 0 {
		return fmt.Errorf("missing command")
	}

	cmd := args[0]
	h, ok := routes[cmd]
	if !ok {
		return fmt.Errorf("unknown command: %s", cmd)
	}

	return h(args[1:])
}

Note that we also pass routes as a parameter. This again helps testability and makes dependencies explicit.

In main it will look like this (for now without output details and process exit codes—we’ll unify those in the next steps):

package main

import (
	"fmt"
	"os"
)

func main() {
	routes := map[string]handler{
		"add":  runAdd,
		"list": runList,
	}

	if err := dispatchByTable(os.Args[1:], routes); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err) // error: missing command
	}
}

Mini routing table

User input Command (cmd) Which handler will be called
todo add ...
add
runAdd(argv)
todo list ...
list
runList(argv)
todo foo ...
foo
error "unknown command"

Yes, it looks almost too simple. But those are exactly the places in code that should be boring.

6. Responsibility boundary: flags are parsed by the command

At this point a temptation may appear: “let’s just do flag.Parse() in the dispatcher and then pass things along”. And this is exactly the moment when you want to say politely but firmly: don’t.

Why?

Because each command has its own flags. add might have -title, list might have -done, done might have -id. If you start parsing everything globally, flags will conflict, help output will get confusing, and the user will have to guess in what order to write arguments so the parser doesn’t get upset.

The correct idea is: the dispatcher chooses the command, and the command itself parses its flags and arguments.

In this lecture we only establish the boundary. The technical details of “how exactly a command parses its flags via a separate parser” we already started covering in a neighboring topic, and next we’ll refine it into a stable scaffold.

7. UX: usage and clear errors

Let’s carefully add two things that make a CLI friendlier without going too far into future topics:

  • the “missing command” message should suggest the launch format;
  • the “unknown command” message should also suggest what can be done.

Let’s make a small usage function (for now, just printing):

package main

import (
	"fmt"
	"io"
)

func printUsage(w io.Writer) {
	fmt.Fprintln(w, "Usage: todo <cmd> [args]")
	fmt.Fprintln(w, "Commands: add, list")
}

And use it on dispatcher errors:

package main

import (
	"fmt"
	"os"
)

func main() {
	routes := map[string]handler{
		"add":  runAdd,
		"list": runList,
	}

	args := os.Args[1:]
	if len(args) == 0 {
		fmt.Fprintln(os.Stderr, "error: missing command") // error: missing command
		printUsage(os.Stderr)
		return
	}

	if err := dispatchByTable(args, routes); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err) // error: unknown command: foo
		printUsage(os.Stderr)
	}
}

This is not an “ideal” scheme yet, because later we’ll bring error printing and the decision of “what exactly to print” to a single contract. But as a learning intermediate step, it’s great: dispatcher errors become fixable (“aha, I need to specify a command”), not mysterious.

What a command handler looks like at this stage

While we’re not diving into FlagSet parsing in this lecture, it’s still useful to see the handler “shape”: it receives argv, checks what came in, and performs the action.

For example, a “stub” for the list command:

package main

import (
	"fmt"
	"os"
)

func runList(argv []string) error {
	_ = argv // later we will parse command arguments
	fmt.Fprintln(os.Stdout, "no tasks yet") // no tasks yet
	return nil
}

And a “stub” for the add command:

package main

import "fmt"

func runAdd(argv []string) error {
	if len(argv) == 0 {
		return fmt.Errorf("add: missing title")
	}
	// for now just a demo, no storage yet
	return nil
}

Right now this looks at least weird (“where are the tasks?”), but that’s fine: the goal of the lecture is the dispatcher and routing. Storage, data models, and rich output are separate big topics.

Why it’s better to pass args []string as a parameter

In practice, one of the most useful disciplines in CLI code is to avoid “magic global state”. When a command reads os.Args by itself, you get three problems at once.

The first problem is tests. To test runAdd, you’d have to mutate os.Args (which already smells like tricks and unexpected side effects). When runAdd(argv) gets a string slice as a parameter, the test becomes “normal”: call the function, check the error.

The second problem is reuse. You might want to call a command handler from somewhere else (for example, implement an alias command or a help scenario that triggers another command’s Usage). If a handler is glued to os.Args, reuse turns into a hassle.

The third problem is clarity. When a function depends on argv as a parameter, you can see it in the signature. When a function pulls os.Args “out of thin air”, the code reader has to remember it does that. And human memory is not infinite (unlike the list of “open browser tabs”, which somehow is).

8. Common mistakes

Mistake #1: accessing args[0] without checking len(args).
This is the classic “index out of range” panic. In a CLI it’s especially annoying, because the user didn’t “break” anything—they just forgot the command. The correct behavior is a neat error plus a usage hint.

Mistake #2: trying to parse flags for all commands in the dispatcher.
At first it seems like “less code”, but very quickly flags start to conflict, and a command like todo list -title ... suddenly becomes valid even though it makes no sense. The dispatcher chooses the command; the command parses flags.

Mistake #3: reading os.Args inside command handlers.
This ties commands to the process’s global state. The code becomes harder to test and harder to reuse. It’s much calmer to pass argv as a parameter and treat the handler as a pure function “arguments → action/error”.

Mistake #4: turning the dispatcher into a “combine harvester” (routing + business logic).
The dispatcher should be boring. If it suddenly starts “adding a task itself”, “computing stats”, “formatting output”— it means you mixed responsibility levels. As a result, any change to a command forces you to rewrite the dispatcher, and vice versa.

Mistake #5: making dispatcher errors unclear for the user.
The “unknown command” message is already not bad, but it’s much better when there’s a hint nearby like Usage: todo <cmd> ... and a list of available commands. A CLI is a dialog with the user—just a very concise one.

1
Task
Go SELF, level 49, lesson 1
Locked
Peek the command
Peek the command
1
Task
Go SELF, level 49, lesson 1
Locked
Switch-based router
Switch-based router
1
Task
Go SELF, level 49, lesson 1
Locked
Routing table
Routing table
1
Task
Go SELF, level 49, lesson 1
Locked
Commands with flags
Commands with flags
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION