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 fromcore/.bootstrap/main.go— assembles everything together. It's the only place that importscore/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.gois the only place wherecore/and all the adapters are imported at the same time. No business logic, only assembly.- Configuration is read in
bootstrap/config.gothroughenvconfig; adapters receive the values they need through the constructor, not throughos.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 inbootstrap/. slogis initialized once inbootstrap/;core/doesn't import the logger.init()and global variables for wiring are forbidden — they make the code untestable.os.Exitonly inbootstrap/; from core and adapters you return an error.google/wireis 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.