← Back to the section

In a small Go service everything often lives in a single main.go: we create the database, spin up the router, start the server. Over time the file grows, logic gets mixed with infrastructure, and in tests there's no way to swap the real database for a stub — everything is tied to global variables and init().

Hexagonal architecture solves this problem through a Composition Root — the single place in the program where all the parts come together. In Go this is bootstrap/main.go.

What a Composition Root is

Imagine you have a LEGO set. The pieces lie separately: the business-logic block, the database adapter, the HTTP handlers. None of them knows about the others. The Composition Root is the assembly instruction: it knows all the pieces and connects them in the right order.

In Go terms:

  • core/ — business logic, knows nothing about HTTP or the database.
  • adapter/in/http/ — HTTP handlers, receive requests and call the logic.
  • adapter/out/persistence/ — works with the database, implements ports from core/.
  • bootstrap/main.go — assembles everything together. It's the only place that imports core/ and all the adapters at the same time.

No other package depends on bootstrap/. This is a fundamental constraint: adapters don't know about each other, and core/ doesn't know about the adapters.

The bootstrap/ structure

bootstrap/
  main.go          # assembling the whole application, starting the server
  config.go        # configuration struct, reading from the environment
  Dockerfile
  docker-compose.yml

There should be no handler.go, service.go, or repository.go here. Only assembly, configuration, and startup.

Configuration from environment variables

Instead of calling os.Getenv("DATABASE_URL") deep inside an adapter, all environment variables are read in one place — bootstrap/config.go. Adapters receive the values they need through the constructor.

// bootstrap/config.go
package main

import (
    "fmt"
    "os"
    "time"

    "github.com/kelseyhightower/envconfig"
)

type Config struct {
    Addr        string        `env:"ADDR"         envDefault:":8080"`
    DBURL       string        `env:"DATABASE_URL"  required:"true"`
    SberURL     string        `env:"SBER_API_URL"  required:"true"`
    SberKey     string        `env:"SBER_API_KEY"  required:"true"`
    ShutdownTTL time.Duration `env:"SHUTDOWN_TTL"  envDefault:"15s"`
    LogLevel    string        `env:"LOG_LEVEL"     envDefault:"info"`
}

func mustLoadConfig() Config {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        fmt.Fprintf(os.Stderr, "config: %v\n", err)
        os.Exit(1)
    }
    return cfg
}

envconfig reads the variables, checks required fields, and applies default values. If something is missing, the program terminates immediately with a clear message instead of continuing with an incomplete configuration.

An important consequence: in tests you can pass any connection string directly into the adapter's constructor, without swapping environment variables.

Assembly in main()

Order matters: first the infrastructure (database, HTTP clients), then the adapters, then the handlers, then the router.

// bootstrap/main.go
package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/jackc/pgx/v5/pgxpool"

    httpadapter "example.com/order-service/internal/adapter/in/http"
    "example.com/order-service/internal/adapter/out/persistence"
    "example.com/order-service/internal/adapter/out/sber"
    "example.com/order-service/internal/core/order/usecase"
)

func main() {
    cfg := mustLoadConfig()
    initLogger(cfg.LogLevel)

    db := mustOpenDB(cfg.DBURL)
    defer db.Close()

    // out adapters: working with the database and external systems
    orderRepo := persistence.NewOrderRepository(db)
    productRepo := persistence.NewProductRepository(db)
    sberClient := sber.NewClient(cfg.SberURL, cfg.SberKey)
    paymentAdapter := sber.NewPaymentAdapter(sberClient)

    // use case handlers from core/
    confirmOrder := usecase.NewConfirmOrderHandler(orderRepo, paymentAdapter)
    createOrder := usecase.NewCreateOrderHandler(orderRepo, productRepo)
    cancelOrder := usecase.NewCancelOrderHandler(orderRepo, paymentAdapter)

    // in adapter: HTTP router
    r := buildRouter(cfg, confirmOrder, createOrder, cancelOrder)

    srv := &http.Server{
        Addr:    cfg.Addr,
        Handler: r,
    }
    runWithGracefulShutdown(srv, cfg.ShutdownTTL)
}

Each constructor (NewOrderRepository, NewConfirmOrderHandler, and so on) takes only what it needs. No global variables, no init(). If a dependency isn't passed, the code won't compile.

The router in a separate function

The router is built in bootstrap/, but in a separate function — this keeps main() easier to read:

func buildRouter(
    cfg Config,
    confirmOrder *usecase.ConfirmOrderHandler,
    createOrder *usecase.CreateOrderHandler,
    cancelOrder *usecase.CancelOrderHandler,
) http.Handler {
    r := chi.NewRouter()

    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)
    r.Use(slogMiddleware())

    orderH := httpadapter.NewOrderHandler(confirmOrder, createOrder, cancelOrder)
    r.Route("/orders", func(r chi.Router) {
        r.Post("/", orderH.CreateOrder)
        r.Post("/{id}/confirm", orderH.ConfirmOrder)
        r.Post("/{id}/cancel", orderH.CancelOrder)
    })

    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    return r
}

Note: adapter/in/http/ does not create the router itself and does not expose it to the outside. The package exports OrderHandler, and the router is built by bootstrap/. This lets you attach several handlers to a single router without any mutual dependency between adapters.

Connecting to the database

func mustOpenDB(url string) *pgxpool.Pool {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, url)
    if err != nil {
        slog.Error("db connect", "err", err)
        os.Exit(1)
    }
    if err := pool.Ping(ctx); err != nil {
        slog.Error("db ping", "err", err)
        os.Exit(1)
    }
    slog.Info("db connected")
    return pool
}

The pool is created in bootstrap/ and passed into the adapter's constructor. The adapter treats the pool as a dependency — it doesn't create it itself and doesn't store the database URL.

Graceful shutdown

Without graceful shutdown, on receiving SIGTERM the server instantly drops all active connections. Users get errors in the middle of a request, and unclosed transactions can leave data in an inconsistent state.

func runWithGracefulShutdown(srv *http.Server, ttl time.Duration) {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    go func() {
        slog.Info("server started", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    <-ctx.Done()
    stop()
    slog.Info("shutdown signal received")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), ttl)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("graceful shutdown failed", "err", err)
        os.Exit(1)
    }
    slog.Info("server stopped")
}

signal.NotifyContext is the idiomatic approach in Go 1.21+. When a signal arrives, the context is cancelled, the server stops accepting new connections and waits for active requests to finish. ShutdownTTL (15 seconds by default) sets the maximum wait time.

The entire server lifecycle — startup, waiting for the signal, graceful shutdown — lives in bootstrap/. Adapters and core/ never call os.Exit.

Logging

slog.SetDefault is called once in bootstrap/main.go. Adapters use slog.Default() or receive a *slog.Logger through the constructor. core/ doesn't import slog at all — logging belongs to infrastructure, not to business logic.

func initLogger(level string) {
    var lvl slog.Level
    if err := lvl.UnmarshalText([]byte(level)); err != nil {
        lvl = slog.LevelInfo
    }
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
    slog.SetDefault(slog.New(h))
}

In production — JSON format for log-collection systems. During local development you can switch to slog.NewTextHandler — this is a decision for bootstrap/, and the other packages know nothing about it.

When to bring in google/wire

Manual assembly in main() works perfectly well for most services. If the project grows and main() turns into a 100-line list of constructors, you can bring in google/wire. Providers are declared in the adapter packages, and the wire set is assembled in bootstrap/:

// bootstrap/wire.go
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "example.com/order-service/internal/adapter/out/persistence"
    "example.com/order-service/internal/adapter/out/sber"
    "example.com/order-service/internal/core/order/usecase"
)

func initApp(cfg Config) (*App, error) {
    wire.Build(
        persistence.ProviderSet,
        sber.ProviderSet,
        usecase.ProviderSet,
        newApp,
    )
    return nil, nil
}

persistence.ProviderSet contains NewOrderRepository — without any knowledge of bootstrap/. The only place where all the provider sets come together is bootstrap/wire.go.

Wire is an option, not a requirement. Start with manual wiring, and move to wire when assembly becomes cumbersome.

Common mistakes

Business logic in bootstrap/. bootstrap/main.go is assembly only. Any logic (validation, calculations, rules) should live in core/. If a conditional tied to a business rule appears in main(), that's a signal to extract something.

init() in adapters. init() runs automatically when a package is loaded and cannot be overridden. An adapter with an init() that opens a database connection is impossible to test properly. Connections are created in bootstrap/ and passed in through the constructor.

os.Getenv inside an adapter. An environment variable read deep inside an adapter is invisible from the outside. The test is forced to swap the environment variable, which is fragile. All env variables are read in bootstrap/config.go.

The router in adapter/in/http/. If the adapter itself creates and returns a chi.Router, it takes on too much. The package registers handlers through a struct (OrderHandler), and the routes are attached by bootstrap/buildRouter.

os.Exit outside bootstrap/. If core/ or an adapter calls os.Exit, the defer statements in main() won't run — connections won't close correctly. From core/ you return an error, from an adapter you also return an error; only bootstrap/ decides what to do with it.

In short

  • bootstrap/main.go is the only place where core/ and all the adapters are imported at the same time. No business logic, only assembly.
  • Configuration is read in bootstrap/config.go through envconfig; adapters receive the values they need through the constructor, not through os.Getenv.
  • Assembly order: infrastructure → out adapters → use case handlers → in adapter (router).
  • The router is built in bootstrap/buildRouter; adapter/in/http/ exports only the handler struct.
  • Graceful shutdown through signal.NotifyContext + srv.Shutdown(ctx) — the entire server lifecycle lives in bootstrap/.
  • slog is initialized once in bootstrap/; core/ doesn't import the logger.
  • init() and global variables for wiring are forbidden — they make the code untestable.
  • os.Exit only in bootstrap/; from core and adapters you return an error.
  • google/wire is an option for large projects, not an obligation.

Further reading

  • Module structure (Go) — the package layout of internal/core/, internal/adapter/, bootstrap/ and the dependency rules.
  • Core layer (Go) — what's allowed to be imported into core/; what business logic looks like without infrastructure.
  • In adapters (Go) — chi handler → mapper → use case; authentication middleware.
  • Out adapters (Go) — a persistence adapter on sqlc/pgx; verifying the port implementation at compile time.