skip to content

What does it cost when a Go CLI's subcommands register themselves into a package-level map from init?

level: middleimportance: should knowfreq 38%

answer

  1. the import block is the feature list
  2. init cannot return an error
  3. one map, one process, every test
  4. delete a blank import, lose a command
  5. make the registry a type

basics

~20 s

The set of subcommands becomes a property of the import graph rather than of readable code, registration cannot return an error so conflicts only panic before main, and every test shares one registry it cannot rebuild.

solid answer

~50 s

Self-registration means each subcommand package writes itself into a shared package-level map from `init`, and `main` pulls them in with blank imports. The build then decides what the binary can do: to know the command list you have to read the import block, and deleting a blank import silently removes a feature. `init` cannot return an error, so a duplicate name can only be handled by panicking during startup, before logging or usage output exists. There is exactly one registry per process, so a test that registers a fake leaves it there for every later test in that package, and no test can build an isolated registry to assert against. The alternative keeps the same decoupling but makes the container a value: each package exports a constructor or a `Register(r *Registry)` function, and the assembled registry is passed around explicitly so tests can create their own.

code

go · 22 lines
go
// Anti-pattern: one map per process, filled during startup.
var registry = map[string]*Command{}

func Register(c *Command) {
	if _, dup := registry[c.Name]; dup {
		panic("duplicate command: " + c.Name) // before main, before logging
	}
	registry[c.Name] = c
}

// Alternative: a type, so tests and variants can build their own.
type Registry struct {
	cmds map[string]*Command
}

func (r *Registry) Add(c *Command) error {
	if _, dup := r.cmds[c.Name]; dup {
		return fmt.Errorf("duplicate command %q", c.Name)
	}
	r.cmds[c.Name] = c
	return nil
}

go deeper

for a junior

Be ready to explain what a blank import is for and why a package can end up in the build with no identifier referencing it. Then say what makes that hard to read for someone new to the codebase.

for a middle

Explain the mechanics: registration happens during startup, an init function cannot return an error, and there is one map per process shared by every test. Sketch the rewrite where the registry is a type with methods.

for a senior

Show the operational angle: a duplicate name panics before logging exists, an unused command still gets initialized, and a dropped blank import ships a binary missing a feature with no failing test. Say what you would put in place to catch each.

for a principal

Own the boundary. Decide when a process-wide registry is genuinely warranted, such as open-ended plugins from packages that cannot import each other, and require every blessed exception to also be constructible as a value.

## The pattern A CLI grows subcommands. Someone notices that if each subcommand package writes itself into a shared map from an `init` function, the top-level package never has to know their names — it just needs to import them. The result is a `main` file whose only content is a block of blank imports, and a `cli` package holding `var registry = map[string]*Command{}` plus a `Register` function that everyone calls at startup. It is genuinely attractive: adding a command touches one new package and one import line, and no central file grows forever. It is also the pattern reviewers most often push back on, for reasons worth being able to state precisely. ## 1. The import graph is now the specification What the binary can do is decided by which packages happen to be linked in. Nothing in the source says "this tool has fourteen commands"; you learn it by reading an import block whose entries have no visible use, and whose identifiers do not appear anywhere else in the file. Delete one by accident and you have shipped a build that is missing a feature with no compile error and no test failure — the registry simply has one fewer entry. The same property means you cannot build a variant of the tool with a subset of commands without editing that import block, and a reader tracing "where does this command come from?" has to search for a string literal rather than follow a reference. ## 2. Registration cannot report an error `init` returns nothing. If two packages claim the same command name, the only things `Register` can do are overwrite silently, or panic. Overwriting is worse: whichever package initialized last wins, and initialization order between independent packages is not something you should be encoding intent in. Panicking is honest but happens during startup, before flags are parsed, before logging is set up, and before your usage text can be printed — the user sees a runtime stack trace. The standard library does exactly this in `database/sql`: `sql.Register` panics on a duplicate driver name. That is a deliberate trade for a case where the driver package and the consumer must not import each other and the plugin set is genuinely open-ended. It is a poor trade for a fixed set of subcommands inside one binary you own. ## 3. Ordering is unspecified beyond dependencies Initialization is ordered by dependency, and independent packages have no ordering relationship you should rely on. A map absorbs that fine. Anything order-sensitive — help output, a first-match dispatch chain, a list of middleware — does not, and will produce a stable-looking result until someone adds an import or renames a package. If ordered output matters, sort explicitly rather than depending on registration order. ## 4. Tests inherit one process-wide registry All tests of a package compile into one binary and run in one process, so there is one registry for the whole test run, already fully populated before the first test function starts. That produces three separate problems. A test cannot construct an empty registry to assert against — it always sees every command the test binary happened to link. A test that registers a fake command leaves it behind for every later test, so behaviour depends on the order tests run in. And a test cannot easily assert the error path for a duplicate name, because the only way to trigger it is to panic the test binary. ## 5. Nothing can be dropped Because `init` has side effects, an unused subcommand is still linked and still initialized. If a command's package dials a service or reads a file to prepare itself, every binary and every test that imports it pays that cost even when the command is never invoked. ## The shape that keeps the good part The useful property is decoupling: the top-level package should not have to know each subcommand's internals. You keep that without a global. Make the registry a type with methods instead of a package-level map, so any number of them can exist. Have each subcommand package export a constructor — `func New() *Command` — or a `Register(r *Registry) error` that takes the registry as a parameter. Then assembling the tool is ordinary code: a short function that adds each command in a visible order, returning an error on a duplicate instead of panicking. What you gain: the command list is readable, the duplicate check returns an error you can print properly, an ordered listing is deterministic, and every test builds its own registry with exactly the commands it cares about and leaves nothing behind. What you give up: one line per command in one file. That is the whole cost, and it is the reason this rewrite is usually an easy sell in review.

  • The standard library registers database drivers this way. Why is that acceptable there?
    Because the driver package and the consuming code must not import each other, the set of drivers is open-ended and supplied by third parties, and selection happens by name at run time. That justifies a process-wide registry. A fixed set of subcommands inside one binary you own has none of those constraints, so it gets no benefit from the trade.
  • How would you make the help output list commands in a predictable order under this pattern?
    Sort at display time. Initialization order between independent packages is not something to rely on, so any ordering must be derived from the data — sort by name, or by an explicit group and weight field on each command — rather than from the sequence in which registrations happened to run.
  • What breaks if a test registers a fake command into the package-level map?
    It stays there for every test that runs afterwards in the same test binary, because all tests of a package share one process and one copy of that map. The suite then passes or fails depending on the order tests run in, and no later test can assume a clean registry.

saying these in an interview costs you the question

  • Blank imports are self-documenting once you know the convention
  • Registration order is fine, packages initialize in source order
  • A duplicate name is impossible, we control all the packages
  • Tests can just delete the entry they added afterwards
  • It is idiomatic because the standard library does it for drivers