From main to Listening

The startup path of a Go service traced through real code, from a safe default logger in main to three bound listeners, and the failures each step is placed to catch.


Startup is a sequence of steps that can each fail, arranged so that the ones most likely to fail happen first and report clearly. Get the order wrong and a service crashes with a nil pointer dereference and no log line, because logging was configured after the thing that broke.

Sequence diagram of the startup path, from main through cli.New and Execute to bootstrap, bind and three started servers. Each step is described in the sections below.

Four files, in the order the process visits them:

FileResponsibility
cmd/main.goProcess entry, default logger, exit codes
internal/cli/cli.goCommand-line surface, configuration, lifecycle handoff
internal/cli/config.goThe configuration schema, defaults and validation
internal/cli/bind.goThe dependency graph

main is small on purpose

func main() {
	logattr := []logutil.Attr{
		slog.String("program", cli.AppName),
		slog.String("version", programVersion),
		slog.String("release", programRelease),
	}

	logcfg, _ := logutil.NewConfig(
		logutil.WithOutWriter(os.Stderr),
		logutil.WithFormat(logutil.FormatJSON),
		logutil.WithLevel(logutil.LevelDebug),
		logutil.WithCommonAttr(logattr...),
	)
	l := logsrv.NewLogger(logcfg)

	rootCmd, err := cli.New(programVersion, programRelease, bootstrap.Bootstrap)
	if err != nil {
		l.With(slog.Any("error", err)).Error("UNABLE TO START THE PROGRAM")
		exitFn(1)
	} else {
		err = rootCmd.Execute()
		if err != nil {
			l.With(slog.Any("error", err)).Error("UNABLE TO RUN THE COMMAND")
			exitFn(2)
		}
	}
}

A logger comes first, before anything that could fail: structured JSON on stderr, debug level, tagged with the program name, version and release. It is deliberately not the configured logger, because the configuration has not been read yet and reading it is one of the things that can go wrong. Without it, a service with a malformed configuration file dies with a message from the Go runtime and nothing saying which service or which build it was.

programVersion and programRelease are package-level variables set at compile time with linker flags, shown in page 19. They answer the question that starts most incident investigations: which build is running.

Two exit codes

1 means the program could not be constructed. 2 means it was constructed and then failed while running.

The distinction is for whatever is watching the process. Exit 1 is almost always a bad flag or a broken build: restarting will produce the same result, and a crash loop is the correct visible symptom. Exit 2 can be transient, an unreachable database at startup being the common case, and a restart may well fix it. One generic exit 1 for both would make those indistinguishable from outside.

exitFn

var exitFn = os.Exit

A package-level variable rather than a direct os.Exit call, so a test can replace it and assert on the code without terminating the test binary. Testing the error paths of main is otherwise close to impossible.

That variable is also why the success path is an else branch where Go style would normally use an early return. os.Exit does not come back, so the compiler and the reader both treat the call as terminal; a test double does come back, and execution would fall through to rootCmd.Execute() on a nil command. The else is load-bearing precisely because the call above it is replaceable.


The command surface

cli.New builds a cobra root command with three optional flags:

rootCmd.Flags().StringVarP(&argConfigDir, "configDir", "c", "",
	"Configuration directory to be added on top of the search list")
rootCmd.Flags().StringVarP(&argLogFormat, "logFormat", "f", "",
	"Logging format: CONSOLE, JSON")
rootCmd.Flags().StringVarP(&argLogLevel, "logLevel", "o", "",
	"Log level: EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG")

That is a deliberate ceiling. Everything else the service can be told is configuration, which has a defined precedence and a schema (page 04). Flags that shadow configuration keys give you two sources for one value and no rule about which wins.

These three are needed before configuration is usable. configDir says where to look for the configuration, so it cannot come from it. The log flags turn up the detail on a failing startup, including one that fails while loading configuration.

There is also a version subcommand, which prints the version and exits. Package managers, deployment scripts and support tickets all want it.

Flags are parsed before the command runs

err := rootCmd.ParseFlags(os.Args[1:])
if err != nil && !errors.Is(err, pflag.ErrHelp) {
	return nil, fmt.Errorf("failed parsing command-line arguments: %w", err)
}

return rootCmd, nil

Cobra would parse the flags itself inside Execute(). Doing it early in New moves an invalid flag from an exit 2 to an exit 1, which is the correct code for it: a typo in an argument is a configuration error, not a runtime failure, and it will recur on every restart.

pflag.ErrHelp is the wrinkle. Cobra registers its own -h and --help flag only inside Execute, so an early ParseFlags sees -h as unknown and returns ErrHelp. Swallowing it here lets Execute print the help text and exit normally.

bootstrapFn is a parameter

type bootstrapFunc func(bindFn bootstrap.BindFunc, opts ...bootstrap.Option) error

func New(version, release string, bootstrapFn bootstrapFunc) (*cobra.Command, error)

main passes bootstrap.Bootstrap. A test passes a stub that records what it was given and returns.

Without this, testing cli.New means testing a function whose final act is to block until it receives a signal, having bound three ports. With it, a test can assert that configuration was loaded, that the log level override was applied, and that the options handed to the lifecycle carry the right shutdown timeout, all in microseconds and with nothing bound.

The shape recurs throughout the service: whatever blocks, touches the network or reads the clock arrives as a parameter. sqlconn takes a WithSQLOpenFunc, bootstrap takes a WithCreateMetricsClientFunc, and each is a seam a test can reach.


What Execute does

rootCmd.RunE is the body of the service’s startup. Six steps.

1. Load the configuration.

cfg := &appConfig{}

err := config.Load(AppName, argConfigDir, appEnvPrefix, cfg)
if err != nil {
	return fmt.Errorf("failed loading config: %w", err)
}

Defaults, then files from a search path, then a remote provider if one is configured, then environment variables, then validation. A failure here returns before anything is allocated, and the process exits 2 with the field name that failed. Page 04 is this call in full.

2. Build the logger configuration.

logcfg, err := newLogConfig(cfg, version, release, argLogFormat, argLogLevel)

The CLI flags win over the configured values:

if argLogFormat != "" {
	cfg.Log.Format = argLogFormat
}

logFormat, err := logutil.ParseFormat(cfg.Log.Format)
if err != nil {
	return nil, fmt.Errorf("log config error: %w", err)
}

Parsing happens here rather than at the point of use, so logLevel=WRONG fails during startup with a message naming the problem. A service that accepts the value and silently falls back to a default is one where the operator believes they turned on debug logging and did not.

3. Assemble the program identity.

appInfo := &jsendx.AppInfo{
	ProgramName:    AppName,
	ProgramVersion: version,
	ProgramRelease: release,
}

These three strings appear in every response envelope the service sends, which means a response captured from a client says which build produced it. Page 10 is about that envelope and what it costs.

4. Create the metrics client.

mtr := metrics.New()

This constructs the service’s own collectors. The backend client is built later, by bootstrap, from the CreateMetricsClientFunc passed in step 6. The two-step arrangement exists so custom collectors can be registered before the backend exists, and page 13 covers why.

5. Create the two shutdown primitives.

wg := &sync.WaitGroup{}
sc := make(chan struct{})

A wait group that every long-lived component registers with, and a channel that gets closed when shutdown starts. Both are created here and passed down through everything. Page 05 is entirely about why there are two.

6. Hand over to the lifecycle.

return bootstrapFn(
	bind(cfg, appInfo, mtr, wg, sc),
	bootstrap.WithLogConfig(logcfg),
	bootstrap.WithCreateMetricsClientFunc(mtr.CreateMetricsClientFunc),
	bootstrap.WithShutdownTimeout(time.Duration(cfg.ShutdownTimeout)*time.Second),
	bootstrap.WithShutdownWaitGroup(wg),
	bootstrap.WithShutdownSignalChan(sc),
)

bind(...) returns a function rather than calling anything. The configuration, program identity, metrics and shutdown primitives are captured in a closure that bootstrap will call once it has built the context, the logger and the metrics backend.

This function returns when the service stops. Everything after it is teardown.


Inside bootstrap

Bootstrap does five things before your code runs again.

It creates a cancellable context from the root context, which becomes the lifetime of the whole service.

It creates the metrics client by calling the function from step 6, so that a failure to reach a metrics backend is a startup failure with a clear message.

It builds the logger from the log configuration, installs a hook that increments a metrics counter for every log record broken down by level, and replaces the process-wide default via slog.SetDefault. That last part means a library somewhere calling slog.Info lands in your configured output rather than on stderr in a different format.

It registers signal handling before calling your bind function. The ordering is deliberate: a SIGTERM that arrives while the service is still wiring itself up is caught by a buffered channel and honoured as soon as binding finishes, instead of hard-killing a half-constructed process. Deployments that roll back quickly hit this more often than you would expect.

Then it calls your BindFunc:

type BindFunc func(ctx context.Context, l *slog.Logger, m metrics.Client) error

A context, a logger and a metrics client. Everything else the service needs was captured in the closure.


Where the graph is built

bind is the function that holds the whole service. It runs once, it returns nil when everything is up, and any error it returns aborts startup with the reason.

The order it works in is a dependency order:

jsx := jsendx.NewJSXResp(httputil.NewHTTPResp(l))
logRedactor := newLogRedactor()

httpClientOpts := []httpclient.Option{ /* logger, metrics, trace, redaction */ }

ipifyClient, err := newIpifyClient(cfg, httpClientOpts)
if err != nil {
	return err
}

serviceBinderPrivate, serviceBinderPublic, statusHandler, err :=
	bindServiceHandlers(ctx, cfg, appInfo, jsx, l, mtr, wg, sc)
if err != nil {
	return err
}

// ... three servers created and started

Response writing, then redaction, then the shared client options, then the clients, then the handlers and their databases, then the servers. Nothing is constructed before the things it needs.

The last step creates the three servers from page 01 and starts each one. Server construction binds the listener, so when bind returns, all three ports are held and the service is answering.

httpMonitoringServer, err := httpserver.New(ctx, httpserver.NopBinder(), httpMonitoringOpts...)
if err != nil {
	return fmt.Errorf("error creating monitoring HTTP server: %w", err)
}

httpMonitoringServer.StartServer()

Page 07 covers what New validates before it binds, and why a route with a lowercase method is rejected there rather than becoming an endpoint nothing can reach.

Back in Bootstrap, with bind returned and no error, the process logs application started and blocks. From here it is doing nothing except serving requests until a signal arrives, which is page 05.


The shape to keep

The ordering carries the principle. A bad flag never reaches configuration loading, a bad configuration never reaches a database connection, and a database that will not answer never reaches a bound port. The alternative is a service that binds first, discovers its problems on the first request, and so reports itself healthy while failing traffic. Page 07 has more on front-loading validation.


Next: Configuration That Is Wrong Before It Is Late.