Init & Main Lifecycle
How a Go program starts, why init() should be rare, wiring dependencies by hand instead of Spring, and a production main() with graceful shutdown.
Section 22 of 31 5 min read
A Go service has no container, no classpath scanning and no application context. main() is the composition root: it reads configuration, constructs every dependency, starts the servers and waits for a signal to stop. Reading main.go tells you how the whole program fits together.
Startup order
What runs before main()
1. Imported packages are initialised first, dependencies before dependents, each package exactly once.
2. Within a package, package-level variables are initialised in dependency order, then every init() function runs in the order the files are presented to the compiler.
3. main.main() runs.
If main imports b and b imports c: c’s variables → c’s init() → b’s variables → b’s init() → main’s variables → main’s init() → main().
main imports b, b imports c
run(): from start to graceful shutdown
main() runs, every imported package is initialised exactly once, dependencies first: its variables, then its init() functions. After that, a production run() owns the service's whole life, from wiring to a graceful shutdown when SIGTERM arrives.init(): automatic, invisible, hard to test
package metrics
// Package-level vars are initialised before init()
var registry = prometheus.NewRegistry()
// init() takes no arguments, returns nothing, and cannot be
// called explicitly. A package may have several; all run.
func init() {
registry.MustRegister(collectors.NewGoCollector())
}Spring DI vs wiring by hand
@Repository
class PostgresMessageStore implements MessageStore { ... }
@Service
class MessageService {
private final MessageStore store;
MessageService(MessageStore store) { this.store = store; }
}
@RestController
class MessageController {
private final MessageService service;
MessageController(MessageService service) {
this.service = service;
}
}
// The container scans, instantiates and injects at startup.func run(ctx context.Context, cfg Config) error {
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connect database: %w", err)
}
defer pool.Close()
messages := store.NewPostgres(pool) // needs the pool
svc := service.NewMessages(messages) // needs the store
api := handler.NewMessages(svc) // needs the service
return serve(ctx, cfg.HTTPAddr, api.Routes())
}A production main.go
func main() {
// main only reports; run does the work and can use defer freely
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
// Cancelled on Ctrl+C or SIGTERM (what Kubernetes sends)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connect database: %w", err)
}
defer pool.Close() // runs last: after the server has drained
api := handler.NewMessages(service.NewMessages(store.NewPostgres(pool)))
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: api.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
serveErr := make(chan error, 1)
go func() {
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
serveErr <- err // failed to start, e.g. port already in use
}
close(serveErr)
}()
select {
case err := <-serveErr:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
stop() // a second Ctrl+C now kills the process immediately
slog.Info("shutting down")
}
// Stop accepting connections; wait up to 20s for in-flight requests
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("http shutdown: %w", err)
}
return nil
}run() errorkeepsmaintiny and lets everydeferrun:os.Exitandlog.Fatalskip deferred calls, so they belong only inmain.signal.NotifyContextturns SIGTERM into context cancellation, so the samectxstops the server and any background workers.srv.Shutdownstops accepting new connections and waits for active requests, bounded by its own timeout. Kubernetes waitsterminationGracePeriodSeconds(30s by default) before sending SIGKILL, so keep this shorter.- Server timeouts default to none.
ReadHeaderTimeoutin particular protects against slow-header (Slowloris) clients.